From fcdaf0759ded88c330f6ee9556ece05817bed8f3 Mon Sep 17 00:00:00 2001 From: Infi Date: Thu, 1 Aug 2024 01:38:19 +0200 Subject: [PATCH] refactor: sidebar Signed-off-by: Infi --- .../revolt/activities/ShareTargetActivity.kt | 60 +- .../components/generic/IconPlaceholder.kt | 17 - .../screens/chat/drawer/ChannelSideDrawer.kt | 1005 +++++++++++++++++ .../chat/drawer/channel/ChannelList.kt | 623 ---------- .../chat/drawer/server/DrawerChannel.kt | 169 --- .../chat/drawer/server/DrawerServer.kt | 78 -- .../drawer/server/DrawerServerlikeIcon.kt | 26 - .../drawer/server/ServerDrawerSeparator.kt | 26 - .../screens/settings/ServerOverview.kt | 2 +- .../revolt/screens/chat/ChatRouterScreen.kt | 232 +--- app/src/main/res/values/strings.xml | 1 + 11 files changed, 1052 insertions(+), 1187 deletions(-) create mode 100644 app/src/main/java/chat/revolt/components/screens/chat/drawer/ChannelSideDrawer.kt delete mode 100644 app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt delete mode 100644 app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt delete mode 100644 app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerServer.kt delete mode 100644 app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerServerlikeIcon.kt delete mode 100644 app/src/main/java/chat/revolt/components/screens/chat/drawer/server/ServerDrawerSeparator.kt diff --git a/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt b/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt index 6bffa1fc..536da358 100644 --- a/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt +++ b/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt @@ -63,10 +63,10 @@ import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.SyncedSettings import chat.revolt.components.chat.NativeMessageField import chat.revolt.components.emoji.EmojiPicker -import chat.revolt.components.generic.presenceFromStatus import chat.revolt.components.screens.chat.AttachmentManager -import chat.revolt.components.screens.chat.drawer.server.DrawerChannel -import chat.revolt.components.screens.chat.drawer.server.DrawerChannelIconType +import chat.revolt.components.screens.chat.drawer.ChannelItem +import chat.revolt.components.screens.chat.drawer.ChannelItemIconType +import chat.revolt.components.screens.chat.drawer.DMOrGroupItem import chat.revolt.persistence.KVStorage import chat.revolt.screens.chat.views.channel.ChannelScreenActivePane import chat.revolt.ui.theme.RevoltTheme @@ -342,39 +342,29 @@ fun ShareTargetScreen( items(filteredChannels.count()) { val channel = filteredChannels.elementAt(it) - DrawerChannel( - iconType = DrawerChannelIconType.Channel( - channel.channelType ?: ChannelType.TextChannel - ), - name = (if (channel.server != null) "${channel.name} (${RevoltAPI.serverCache[channel.server]?.name})" else channel.name) - ?: ChannelUtils.resolveName(channel) - ?: stringResource(R.string.unknown), - selected = selectedChannel == channel.id, - hasUnread = false, - onClick = { - selectedChannel = channel.id - }, - dmPartnerIcon = ChannelUtils.resolveDMPartner( - channel - )?.let { u -> RevoltAPI.userCache[u] }?.avatar, - dmPartnerName = ChannelUtils.resolveName( - channel - ), - dmPartnerStatus = ChannelUtils.resolveDMPartner( - channel - ) - ?.let { u -> RevoltAPI.userCache[u] }?.status?.presence?.let { p -> - presenceFromStatus( - p, - RevoltAPI.userCache[ChannelUtils.resolveDMPartner( - channel - )]?.online ?: false - ) + when (channel.channelType) { + ChannelType.Group, ChannelType.DirectMessage -> DMOrGroupItem( + channel = channel, + partner = ChannelUtils.resolveDMPartner(channel)?.let { u -> + RevoltAPI.userCache[u] }, - dmPartnerId = ChannelUtils.resolveDMPartner( - channel - ), - ) + isCurrent = selectedChannel == channel.id, + hasUnread = false, + onDestinationChanged = { selectedChannel = channel.id }, + onOpenChannelContextSheet = {} + ) + + else -> ChannelItem( + iconType = ChannelItemIconType.Channel( + channel.channelType ?: ChannelType.TextChannel + ), + channel = channel, + isCurrent = selectedChannel == channel.id, + onDestinationChanged = { selectedChannel = channel.id }, + onOpenChannelContextSheet = {}, + appendServerName = true + ) + } Spacer(modifier = Modifier.height(8.dp)) } 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 0e47f466..9b5e4615 100644 --- a/app/src/main/java/chat/revolt/components/generic/IconPlaceholder.kt +++ b/app/src/main/java/chat/revolt/components/generic/IconPlaceholder.kt @@ -1,8 +1,6 @@ package chat.revolt.components.generic -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -15,31 +13,16 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -private val NoopHandler = {} - -@OptIn(ExperimentalFoundationApi::class) @Composable fun IconPlaceholder( name: String, modifier: Modifier = Modifier, - onClick: () -> Unit = NoopHandler, - onLongClick: () -> Unit = NoopHandler, fontSize: TextUnit = 20.sp ) { Box( contentAlignment = Alignment.Center, modifier = modifier .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) - .then( - if (onClick != NoopHandler || onLongClick != NoopHandler) { - Modifier.combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ) - } else { - Modifier - } - ) ) { Text( text = name.first().uppercase(), diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/ChannelSideDrawer.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/ChannelSideDrawer.kt new file mode 100644 index 00000000..64e3d759 --- /dev/null +++ b/app/src/main/java/chat/revolt/components/screens/chat/drawer/ChannelSideDrawer.kt @@ -0,0 +1,1005 @@ +package chat.revolt.components.screens.chat.drawer + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.DrawerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +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.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.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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 androidx.navigation.NavController +import chat.revolt.R +import chat.revolt.api.REVOLT_FILES +import chat.revolt.api.RevoltAPI +import chat.revolt.api.internals.CategorisedChannelList +import chat.revolt.api.internals.ChannelUtils +import chat.revolt.api.internals.DirectMessages +import chat.revolt.api.internals.FriendRequests +import chat.revolt.api.schemas.Category +import chat.revolt.api.schemas.Channel +import chat.revolt.api.schemas.ChannelType +import chat.revolt.api.schemas.ServerFlags +import chat.revolt.api.schemas.User +import chat.revolt.api.schemas.has +import chat.revolt.api.settings.SyncedSettings +import chat.revolt.components.generic.GroupIcon +import chat.revolt.components.generic.IconPlaceholder +import chat.revolt.components.generic.RemoteImage +import chat.revolt.components.generic.UserAvatar +import chat.revolt.components.generic.presenceFromStatus +import chat.revolt.components.screens.chat.ChannelIcon +import chat.revolt.internals.extensions.BottomSheetInsets +import chat.revolt.screens.chat.ChatRouterDestination +import chat.revolt.sheets.ChannelContextSheet +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChannelSideDrawer( + currentServer: String?, + currentDestination: ChatRouterDestination, + onDestinationChanged: (ChatRouterDestination) -> Unit, + onLongPressAvatar: () -> Unit, + drawerState: DrawerState?, + navigateToServer: (String) -> Unit, + onShowServerContextSheet: (String) -> Unit, + showSettingsIcon: Boolean, + onOpenSettings: () -> Unit, + topNav: NavController, + onShowAddServerSheet: () -> Unit, + modifier: Modifier = Modifier +) { + val server = RevoltAPI.serverCache[currentServer] + val categorisedChannels = server?.let { + ChannelUtils.categoriseServerFlat(it) + } + val channelListState = rememberLazyListState() + + LaunchedEffect(currentDestination) { + if (currentDestination is ChatRouterDestination.Channel && currentServer != null) { + val channelIndex = categorisedChannels?.indexOfFirst { + when (it) { + is CategorisedChannelList.Channel -> it.channel.id == currentDestination.channelId + else -> false + } + } ?: 0 + val firstVisibleIndex = kotlin.math.max(0, channelIndex - 2) + + // Add an offset to the scroll position so it is obvious to the user that they are not at the top. + channelListState.animateScrollToItem( + firstVisibleIndex, + if (firstVisibleIndex == 0) 0 else 85 + ) + } + } + + val isAtFirst by remember { derivedStateOf { channelListState.firstVisibleItemIndex == 0 } } + val serverBannerHeight by animateDpAsState( + targetValue = if (server?.banner == null) { + 76.dp // Magic number deducted by trial and error + } else if (isAtFirst) { + 192.dp + } else { + 128.dp + }, + animationSpec = tween( + durationMillis = 300, + delayMillis = 0 + ), label = "Server banner height" + ) + + // - Take the list of servers and filter them by the ones that are in the ordering. + // - Sort the servers that are in the ordering using the ordering. + // - Add the servers that aren't in the ordering to the end of the list. + // - Sort the servers that aren't in the ordering by their ID (creation order). + val serverList = ((RevoltAPI.serverCache.values.filter { + SyncedSettings.ordering.servers.contains( + it.id + ) + } + .sortedBy { SyncedSettings.ordering.servers.indexOf(it.id) }) + (RevoltAPI.serverCache.values.filter { + !SyncedSettings.ordering.servers.contains( + it.id + ) + }.sortedBy { it.id })) + + var channelContextSheetTarget by remember { mutableStateOf(null) } + + if (channelContextSheetTarget != null) { + val channelContextSheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + sheetState = channelContextSheetState, + onDismissRequest = { + channelContextSheetTarget = null + }, + windowInsets = BottomSheetInsets + ) { + ChannelContextSheet( + channelId = channelContextSheetTarget!!, + onHideSheet = { + channelContextSheetState.hide() + channelContextSheetTarget = null + } + ) + } + } + + val scope = rememberCoroutineScope() + + Row(modifier.fillMaxSize()) { + LazyColumn( + Modifier.width(64.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues( + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ) + ) { + item(key = "self") { + UserAvatar( + username = RevoltAPI.userCache[RevoltAPI.selfId]?.let { + User.resolveDefaultName( + it + ) + } + ?: "", + presence = presenceFromStatus( + RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence, + RevoltAPI.userCache[RevoltAPI.selfId]?.online ?: false + ), + userId = RevoltAPI.selfId ?: "", + avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar, + size = 48.dp, + presenceSize = 16.dp, + onClick = { + onDestinationChanged(ChatRouterDestination.defaultForDMList) + }, + onLongClick = onLongPressAvatar, + modifier = Modifier + .padding(8.dp) + .size(48.dp) + ) + } + + items( + DirectMessages.unreadDMs().size, + key = { DirectMessages.unreadDMs()[it].id ?: it } + ) { + val dm = DirectMessages.unreadDMs()[it] + when (dm.channelType) { + ChannelType.Group -> GroupIcon( + name = dm.name ?: "?", + size = 48.dp, + onClick = { + dm.id?.let { id -> + onDestinationChanged(ChatRouterDestination.Channel(id)) + } + }, + icon = dm.icon, + modifier = Modifier + .padding(8.dp) + .size(48.dp) + ) + + else -> { + val partner = + if (dm.channelType == ChannelType.DirectMessage) { + RevoltAPI.userCache[ + ChannelUtils.resolveDMPartner( + dm + ) + ] + } else { + null + } + + UserAvatar( + username = partner?.let { p -> + User.resolveDefaultName( + p + ) + } ?: dm.name ?: "?", + presence = presenceFromStatus( + partner?.status?.presence, + partner?.online ?: false + ), + userId = partner?.id ?: dm.id ?: "", + avatar = partner?.avatar ?: dm.icon, + size = 48.dp, + presenceSize = 16.dp, + onClick = { + dm.id?.let { id -> + onDestinationChanged(ChatRouterDestination.Channel(id)) + } + }, + modifier = Modifier + .padding(8.dp) + .size(48.dp) + ) + } + } + } + + item(key = "divider") { + HorizontalDivider( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) + } + + items( + serverList.size, + key = { serverList[it].id ?: it } + ) { + val serverInList = serverList[it] + val serverHasUnread = + serverInList.id?.let { srvId -> RevoltAPI.unreads.serverHasUnread(srvId) } + ?: false + val leftIndicatorHeight = animateDpAsState( + targetValue = if (serverInList.id == currentServer) 32.dp + else if (serverHasUnread) 8.dp + else 0.dp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), label = "Left indicator width" + ) + val leftIndicatorColour = animateColorAsState( + targetValue = + if (serverInList.id == currentServer) + MaterialTheme.colorScheme.primary + else if (serverHasUnread) + MaterialTheme.colorScheme.onSurfaceVariant + else + Color.Transparent, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "Left indicator colour" + ) + + Box(Modifier.fillMaxWidth()) { + Box( + Modifier + .padding(8.dp) + .clip(CircleShape) + .clickable { + serverInList.id?.let { srvId -> navigateToServer(srvId) } + scope.launch { + drawerState?.close() + } + }) { + val icon = serverInList.icon?.id?.let { iconId -> + "$REVOLT_FILES/icons/$iconId/server.png?max_side=256" + } + if (icon != null) { + RemoteImage( + url = icon, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + description = serverInList.name ?: stringResource(R.string.unknown) + ) + } else { + IconPlaceholder( + name = serverInList.name ?: stringResource(R.string.unknown), + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + ) + } + } + + Box( + Modifier + .height(leftIndicatorHeight.value) + .width(8.dp) + .offset(x = (-4).dp) + .clip(CircleShape) + .background(leftIndicatorColour.value) + .align(Alignment.CenterStart) + ) + } + } + + item(key = "add_server") { + Box( + Modifier + .padding(8.dp) + .clip(CircleShape) + .clickable { + onShowAddServerSheet() + }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.settings) + ) + } + } + + item(key = "discover") { + Box( + Modifier + .padding(8.dp) + .clip(CircleShape) + .clickable { + topNav.navigate("discover") + }) { + Icon( + painter = painterResource(R.drawable.ic_compass_24dp), + contentDescription = stringResource(R.string.settings) + ) + } + } + + if (showSettingsIcon) { + item(key = "settings") { + Box( + Modifier + .padding(8.dp) + .clip(CircleShape) + .clickable { + onOpenSettings() + scope.launch { + drawerState?.close() + } + }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringResource(R.string.settings) + ) + } + } + } + } + Column( + Modifier + .clip( + MaterialTheme.shapes.extraLarge.copy( + bottomEnd = CornerSize(0) + ) + ) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)) + .weight(1f) + .fillMaxHeight() + ) { + Box(Modifier.height(serverBannerHeight)) { + if (server?.banner != null) { + RemoteImage( + url = "$REVOLT_FILES/banners/${server.banner.id}", + description = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip( + MaterialTheme.shapes.medium.copy( + topStart = CornerSize(0), topEnd = CornerSize(0) + ) + ) + .fillMaxSize() + ) + + with(MaterialTheme.colorScheme) { + Box( + Modifier + .fillMaxSize() + .drawBehind { + drawRect( + Brush.linearGradient( + listOf( + surfaceColorAtElevation(1.dp).copy(alpha = 0.8f), + Color.Transparent + ), + Offset.Zero, + Offset.Infinite.copy(x = 0f) + ), + ) + }) + } + } + + Row( + Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (server?.flags has ServerFlags.Official) { + Icon( + painter = painterResource( + id = R.drawable.ic_revolt_decagram_24dp + ), + contentDescription = stringResource( + R.string.server_flag_official + ), + tint = LocalContentColor.current, + modifier = Modifier + .size(24.dp) + ) + } + if (server?.flags has ServerFlags.Verified) { + Icon( + painter = painterResource( + id = R.drawable.ic_check_decagram_24dp + ), + contentDescription = stringResource( + R.string.server_flag_verified + ), + tint = LocalContentColor.current, + modifier = Modifier + .size(24.dp) + ) + } + + Text( + text = when (currentServer) { + null -> stringResource(R.string.direct_messages) + else -> server?.name ?: stringResource(R.string.unknown) + }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (currentServer != null) { + IconButton(onClick = { + server?.id?.let { srvId -> onShowServerContextSheet(srvId) } + }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.menu) + ) + } + } else { + Spacer(Modifier.height(64.dp)) + } + } + } + + if (currentServer == null) { + DirectMessagesChannelListRenderer( + currentDestination, + onDestinationChanged, + drawerState, + channelListState, + onOpenChannelContextSheet = { channelContextSheetTarget = it } + ) + } else { + ServerChannelListRenderer( + categorisedChannels, + currentDestination, + onDestinationChanged, + drawerState, + channelListState, + onOpenChannelContextSheet = { channelContextSheetTarget = it } + ) + } + } + } +} + +@Composable +fun ColumnScope.DirectMessagesChannelListRenderer( + currentDestination: ChatRouterDestination, + onDestinationChanged: (ChatRouterDestination) -> Unit, + drawerState: DrawerState?, + channelListState: LazyListState, + onOpenChannelContextSheet: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + val dmAbleChannels = + RevoltAPI.channelCache.values + .filter { it.channelType == ChannelType.DirectMessage || it.channelType == ChannelType.Group } + .filter { if (it.channelType == ChannelType.DirectMessage) it.active == true else true } + .sortedBy { it.lastMessageID ?: it.id } + .reversed() + + LazyColumn( + state = channelListState, + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + item(key = "home") { + ChannelItem( + channel = Channel( + id = "home", + name = stringResource(R.string.home), + channelType = ChannelType.TextChannel + ), + iconType = ChannelItemIconType.Painter(painterResource(R.drawable.ic_home_24dp)), + isCurrent = currentDestination is ChatRouterDestination.Home, + onDestinationChanged = { + onDestinationChanged(ChatRouterDestination.Home) + scope.launch { + drawerState?.close() + } + }, + hasUnread = false, + onOpenChannelContextSheet = {} + ) + Spacer(Modifier.height(4.dp)) + } + + item(key = "friends") { + ChannelItem( + channel = Channel( + id = "friends", + name = stringResource(R.string.friends), + channelType = ChannelType.TextChannel + ), + iconType = ChannelItemIconType.Painter(painterResource(R.drawable.ic_human_greeting_variant_24dp)), + isCurrent = currentDestination is ChatRouterDestination.Friends, + onDestinationChanged = { + onDestinationChanged(ChatRouterDestination.Friends) + scope.launch { + drawerState?.close() + } + }, + hasUnread = FriendRequests.getIncoming().isNotEmpty(), + onOpenChannelContextSheet = {}, + ) + Spacer(Modifier.height(4.dp)) + } + + item(key = "saved_messages") { + val notesChannel = + RevoltAPI.channelCache.values.firstOrNull { it.channelType == ChannelType.SavedMessages } + + if (notesChannel != null) { + ChannelItem( + channel = Channel( + id = notesChannel.id, + name = stringResource(R.string.channel_notes), + channelType = ChannelType.SavedMessages + ), + isCurrent = currentDestination is ChatRouterDestination.Channel && + currentDestination.channelId == notesChannel.id, + onDestinationChanged = { + onDestinationChanged(it) + scope.launch { + drawerState?.close() + } + }, + hasUnread = false, + onOpenChannelContextSheet = {}, + ) + Spacer(Modifier.height(4.dp)) + } + } + + item("divider") { + HorizontalDivider( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + Spacer(Modifier.height(4.dp)) + } + + items( + dmAbleChannels.size, + key = { dmAbleChannels[it].id ?: it } + ) { + val channel = dmAbleChannels.getOrNull(it) ?: return@items + + val partner = + if (channel.channelType == ChannelType.DirectMessage) { + RevoltAPI.userCache[ + ChannelUtils.resolveDMPartner( + channel + ) + ] + } else { + null + } + + DMOrGroupItem( + channel = channel, + partner = partner, + isCurrent = when (currentDestination) { + is ChatRouterDestination.Channel -> { + currentDestination.channelId == channel.id + } + + else -> false + }, + hasUnread = channel.lastMessageID?.let { lastMessageID -> + RevoltAPI.unreads.hasUnread( + channel.id!!, lastMessageID + ) + } ?: false, + onDestinationChanged = { dest -> + onDestinationChanged(dest) + scope.launch { + drawerState?.close() + } + }, + onOpenChannelContextSheet = onOpenChannelContextSheet + ) + } + + item(key = "last") { + Spacer( + Modifier.height( + WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + ) + ) + } + } +} + +@Composable +fun ColumnScope.ServerChannelListRenderer( + categorisedChannels: List?, + currentDestination: ChatRouterDestination, + onDestinationChanged: (ChatRouterDestination) -> Unit, + drawerState: DrawerState?, + channelListState: LazyListState, + onOpenChannelContextSheet: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + + LazyColumn( + state = channelListState, + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(top = 8.dp), + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + if (categorisedChannels.isNullOrEmpty()) { + item { + Column( + Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.no_channels_heading), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + fontSize = 24.sp, + modifier = Modifier.padding(bottom = 16.dp) + ) + Text( + text = stringResource(R.string.no_channels_body), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + } + } + + items(categorisedChannels?.size ?: 0) { + when (val channelOrCat = categorisedChannels?.get(it)) { + is CategorisedChannelList.Channel -> { + ChannelItem(channel = channelOrCat.channel, + isCurrent = when (currentDestination) { + is ChatRouterDestination.Channel -> { + currentDestination.channelId == channelOrCat.channel.id + } + + else -> false + }, + onDestinationChanged = { + onDestinationChanged(it) + scope.launch { + drawerState?.close() + } + }, + hasUnread = channelOrCat.channel.lastMessageID?.let { lastMessageID -> + RevoltAPI.unreads.hasUnread( + channelOrCat.channel.id!!, lastMessageID + ) + } ?: false, + onOpenChannelContextSheet = onOpenChannelContextSheet + ) + } + + is CategorisedChannelList.Category -> { + CategoryItem(category = channelOrCat.category) + } + + else -> {} + } + } + item(key = "last") { + Spacer( + Modifier.height( + WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + ) + ) + } + } +} + +sealed class ChannelItemIconType { + data class Channel(val type: ChannelType) : ChannelItemIconType() + data class Painter(val painter: androidx.compose.ui.graphics.painter.Painter) : + ChannelItemIconType() +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChannelItem( + channel: Channel, + isCurrent: Boolean, + iconType: ChannelItemIconType = ChannelItemIconType.Channel( + channel.channelType ?: ChannelType.TextChannel + ), + hasUnread: Boolean = false, + appendServerName: Boolean = false, + onDestinationChanged: (ChatRouterDestination) -> Unit, + onOpenChannelContextSheet: (String) -> Unit +) { + CompositionLocalProvider( + LocalContentColor provides if (isCurrent) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + if (hasUnread) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + } + ) { + Row(verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), + modifier = Modifier + .padding(start = 8.dp, end = 8.dp) + .clip( + CircleShape + ) + .combinedClickable( + onLongClickLabel = stringResource(R.string.channel_context_sheet_open), + onLongClick = { + channel.id?.let { chId -> + onOpenChannelContextSheet(chId) + } + }, + onClick = { + channel.id?.let { chId -> + onDestinationChanged(ChatRouterDestination.Channel(chId)) + } + } + ) + .then( + if (isCurrent) { + Modifier.background(MaterialTheme.colorScheme.secondaryContainer) + } else { + Modifier + } + ) + .padding(16.dp) + .fillMaxWidth()) { + when (iconType) { + is ChannelItemIconType.Channel -> { + ChannelIcon(iconType.type) + } + + is ChannelItemIconType.Painter -> { + Icon(painter = iconType.painter, contentDescription = null) + } + } + Text( + text = (ChannelUtils.resolveName(channel) ?: stringResource(R.string.unknown)) + + if (appendServerName && channel.server != null) { + " (${RevoltAPI.serverCache[channel.server]?.name ?: stringResource(R.string.unknown)})" + } else { + "" + }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (hasUnread && !isCurrent) { + Spacer(Modifier.weight(1f)) + Box( + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .requiredSize(8.dp) + ) + } + } + } +} + +@Composable +fun CategoryItem( + category: Category +) { + Text( + text = category.title ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.labelLarge, + fontSize = 16.sp, + modifier = Modifier.padding( + start = 24.dp, end = 24.dp, top = 24.dp, bottom = 16.dp + ) + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DMOrGroupItem( + channel: Channel, + partner: User?, + isCurrent: Boolean, + hasUnread: Boolean, + onDestinationChanged: (ChatRouterDestination) -> Unit, + onOpenChannelContextSheet: (String) -> Unit +) { + val currentIndicatorOpacity = animateFloatAsState( + targetValue = if (isCurrent) 1f else 0f, + animationSpec = tween(durationMillis = 150), + label = "Current indicator opacity" + ) + val currentIndicatorSize = animateDpAsState( + targetValue = if (isCurrent) 24.dp else 0.dp, + animationSpec = tween(durationMillis = 150), + label = "Current indicator size" + ) + + Row( + Modifier + .combinedClickable( + onLongClickLabel = stringResource(R.string.channel_context_sheet_open), + onLongClick = { + channel.id?.let { chId -> + onOpenChannelContextSheet(chId) + } + }, + onClick = { + channel.id?.let { chId -> + onDestinationChanged(ChatRouterDestination.Channel(chId)) + } + } + ) + .padding(vertical = 16.dp) + .fillMaxWidth() + ) { + Box( + Modifier + .offset(x = (-4).dp) + .clip( + CircleShape + .copy( + topStart = CornerSize(0), + bottomStart = CornerSize(0) + ) + ) + .background(MaterialTheme.colorScheme.primary) + .height(currentIndicatorSize.value) + .width(8.dp) + .alpha(currentIndicatorOpacity.value) + .align(Alignment.CenterVertically) + ) + + Row( + Modifier + .weight(1f) + .padding(start = 12.dp, end = 24.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when (channel.channelType) { + ChannelType.Group -> GroupIcon( + name = channel.name ?: stringResource(R.string.unknown), + size = 28.dp, + icon = channel.icon + ) + + else -> UserAvatar( + username = partner?.let { User.resolveDefaultName(it) } ?: channel.name + ?: stringResource(R.string.unknown), + presence = presenceFromStatus( + partner?.status?.presence, + partner?.online ?: false + ), + userId = partner?.id ?: channel.id ?: "", + avatar = partner?.avatar ?: channel.icon, + size = 28.dp, + presenceSize = 12.dp + ) + } + + Column(Modifier.weight(1f)) { + Text( + text = partner?.let { User.resolveDefaultName(it) } ?: channel.name + ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (hasUnread && !isCurrent) { + Box( + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .requiredSize(8.dp) + ) + } + } + } +} \ No newline at end of file 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 deleted file mode 100644 index cfda7a8f..00000000 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt +++ /dev/null @@ -1,623 +0,0 @@ -package chat.revolt.components.screens.chat.drawer.channel - -import android.view.ViewGroup -import android.widget.ImageView -import androidx.appcompat.widget.AppCompatImageView -import androidx.compose.animation.animateColorAsState -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.Spacer -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.layout.size -import androidx.compose.foundation.layout.width -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.LocalContentColor -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.rememberCoroutineScope -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.painterResource -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 androidx.compose.ui.viewinterop.AndroidView -import chat.revolt.R -import chat.revolt.activities.RevoltTweenColour -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.CategorisedChannelList -import chat.revolt.api.internals.ChannelUtils -import chat.revolt.api.routes.user.openDM -import chat.revolt.api.schemas.ChannelType -import chat.revolt.api.schemas.ServerFlags -import chat.revolt.api.schemas.User -import chat.revolt.api.schemas.has -import chat.revolt.components.generic.presenceFromStatus -import chat.revolt.components.screens.chat.drawer.server.DrawerChannel -import chat.revolt.components.screens.chat.drawer.server.DrawerChannelIconType -import chat.revolt.screens.chat.ChatRouterDestination -import chat.revolt.sheets.ChannelContextSheet -import com.bumptech.glide.Glide -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions -import kotlinx.coroutines.launch -import kotlin.math.max - -const val BANNER_HEIGHT_COMPACT = 56 -const val BANNER_HEIGHT_EXPANDED = 128 - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) -@Composable -fun RowScope.ChannelList( - serverId: String?, - currentDestination: ChatRouterDestination, - onDestinationChange: (ChatRouterDestination) -> 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" - ) - val bannerTextColour by animateColorAsState( - targetValue = if (enableSmallBanner) LocalContentColor.current else Color.White, - animationSpec = RevoltTweenColour, - label = "Banner Text Colour" - ) - - var channelContextSheetShown by remember { mutableStateOf(false) } - var channelContextSheetTarget by remember { mutableStateOf("") } - - if (channelContextSheetShown) { - val channelContextSheetState = rememberModalBottomSheetState() - - ModalBottomSheet( - sheetState = channelContextSheetState, - onDismissRequest = { - channelContextSheetShown = false - } - ) { - ChannelContextSheet( - channelId = channelContextSheetTarget, - onHideSheet = { - channelContextSheetState.hide() - channelContextSheetShown = false - } - ) - } - } - - val dmAbleChannels = - RevoltAPI.channelCache.values - .filter { it.channelType == ChannelType.DirectMessage || it.channelType == ChannelType.Group } - .filter { if (it.channelType == ChannelType.DirectMessage) it.active == true else true } - .sortedBy { it.lastMessageID ?: it.id } - .reversed() - - val server = RevoltAPI.serverCache[serverId] - val categorisedChannels = server?.let { - ChannelUtils.categoriseServerFlat(it) - } - - val scope = rememberCoroutineScope() - - Surface( - tonalElevation = 1.dp, - modifier = Modifier - .padding(start = 4.dp, top = 8.dp, bottom = 8.dp) - .clip(RoundedCornerShape(16.dp)) - .fillMaxWidth() - ) { - LazyColumn( - Modifier - .weight(1f) - .fillMaxSize(), - state = lazyListState - ) { - if (serverId == null) { - stickyHeader( - key = "header" - ) { - 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() - .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( - key = "home" - ) { - DrawerChannel( - name = stringResource(R.string.home), - iconType = DrawerChannelIconType.Painter(painterResource(R.drawable.ic_home_24dp)), - selected = currentDestination == ChatRouterDestination.Home, - hasUnread = false, - onClick = { - onDestinationChange(ChatRouterDestination.Home) - }, - large = true - ) - } - - item( - key = "friends" - ) { - DrawerChannel( - name = stringResource(R.string.friends), - iconType = DrawerChannelIconType.Painter(painterResource(R.drawable.ic_human_greeting_variant_24dp)), - selected = currentDestination == ChatRouterDestination.Friends, - hasUnread = false, - onClick = { - onDestinationChange(ChatRouterDestination.Friends) - }, - large = true - ) - } - - item( - key = "notes" - ) { - val notesChannelId = - RevoltAPI.channelCache.values.firstOrNull { it.channelType == ChannelType.SavedMessages }?.id - - DrawerChannel( - name = stringResource(R.string.channel_notes), - iconType = DrawerChannelIconType.Channel(ChannelType.SavedMessages), - selected = currentDestination == ChatRouterDestination.Channel( - notesChannelId ?: "" - ), - hasUnread = false, - onClick = { - if (notesChannelId != null) { - onDestinationChange(ChatRouterDestination.Channel(notesChannelId)) - return@DrawerChannel - } - - scope.launch { - val notesChannel = openDM(RevoltAPI.selfId ?: return@launch) - if (notesChannel.id != null) { - if (RevoltAPI.channelCache[notesChannel.id] == null) - RevoltAPI.channelCache[notesChannel.id] = notesChannel - } - onDestinationChange( - ChatRouterDestination.Channel( - notesChannel.id ?: return@launch - ) - ) - } - }, - large = true - ) - } - - item( - key = "divider" - ) { - Surface( - Modifier - .padding(vertical = 8.dp) - .fillMaxWidth() - .height(1.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) - ) {} - } - - items( - dmAbleChannels.size, - key = { index -> - val channel = dmAbleChannels.getOrNull(index) - channel?.id ?: index - } - ) { - val channel = dmAbleChannels.getOrNull(it) ?: return@items - - val partner = - if (channel.channelType == ChannelType.DirectMessage) { - RevoltAPI.userCache[ - ChannelUtils.resolveDMPartner( - channel - ) - ] - } else { - null - } - - DrawerChannel( - name = partner?.let { p -> User.resolveDefaultName(p) } ?: channel.name - ?: stringResource(R.string.unknown), - iconType = DrawerChannelIconType.Channel( - channel.channelType ?: ChannelType.TextChannel - ), - selected = currentDestination == ChatRouterDestination.Channel( - channel.id ?: "" - ), - hasUnread = channel.lastMessageID?.let { lastMessageID -> - RevoltAPI.unreads.hasUnread( - channel.id!!, - lastMessageID - ) - } ?: false, - dmPartnerIcon = partner?.avatar ?: channel.icon, - dmPartnerId = partner?.id, - dmPartnerName = partner?.let { p -> User.resolveDefaultName(p) }, - dmPartnerStatus = presenceFromStatus( - status = partner?.status?.presence, - online = partner?.online ?: false - ), - onClick = { - onDestinationChange( - ChatRouterDestination.Channel( - channel.id ?: return@DrawerChannel - ) - ) - }, - onLongClick = { - channelContextSheetTarget = channel.id ?: return@DrawerChannel - channelContextSheetShown = true - } - ) - } - } else { - 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) - ) - - // *** ANDROIDVIEW RATIONALE *** - // Compose w/ Glide looks super laggy when resizing, because - // it tries to refetch the image every time. (luckily from cache) - // This is a temporary workaround until Glide can be resized - // without refetching in Compose. - AndroidView( - factory = { ctx -> - AppCompatImageView(ctx).apply { - scaleType = ImageView.ScaleType.CENTER_CROP - - Glide.with(this) - .load("$REVOLT_FILES/banners/${server.banner.id}") - .transition( - DrawableTransitionOptions.withCrossFade() - ) - .into(this) - } - }, - update = { - it.layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - }, - 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 - ) { - Spacer(Modifier.width(16.dp)) - - if (server?.flags has ServerFlags.Official) { - Icon( - painter = painterResource( - id = R.drawable.ic_revolt_decagram_24dp - ), - contentDescription = stringResource( - R.string.server_flag_official - ), - tint = if (server?.banner != null) { - bannerTextColour - } else { - LocalContentColor.current - }, - modifier = Modifier - .padding(end = 8.dp) - .size(24.dp) - ) - } - if (server?.flags has ServerFlags.Verified) { - Icon( - painter = painterResource( - id = R.drawable.ic_check_decagram_24dp - ), - contentDescription = stringResource( - R.string.server_flag_verified - ), - tint = if (server?.banner != null) { - bannerTextColour - } else { - LocalContentColor.current - }, - modifier = Modifier - .padding(end = 8.dp) - .size(24.dp) - ) - } - - Text( - text = ( - server?.name - ?: stringResource(R.string.unknown) - ), - style = MaterialTheme.typography.labelLarge, - color = if (server?.banner != null) { - bannerTextColour - } else { - LocalContentColor.current - }, - fontSize = 16.sp, - modifier = Modifier - .then( - if (server?.banner != null) { - Modifier.padding( - start = 0.dp, - end = 16.dp, - top = 16.dp, - bottom = 16.dp - ) - } else { - Modifier.padding( - start = 0.dp, - end = 24.dp, - top = 16.dp, - bottom = 16.dp - ) - } - ) - .weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - IconButton(onClick = { - onServerSheetOpenFor(serverId ?: return@IconButton) - }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource( - id = R.string.settings - ), - tint = if (server?.banner != null) { - bannerTextColour - } else { - LocalContentColor.current - } - ) - } - } - } - } - - if (categorisedChannels.isNullOrEmpty()) { - item { - Column( - Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(R.string.no_channels_heading), - style = MaterialTheme.typography.labelLarge, - textAlign = TextAlign.Center, - fontSize = 24.sp, - modifier = Modifier.padding(bottom = 16.dp) - ) - Text( - text = stringResource(R.string.no_channels_body), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center - ) - } - } - } else { - items( - categorisedChannels.size, - key = { index -> - val channel = categorisedChannels.getOrNull(index) - channel?.let { - when (it) { - is CategorisedChannelList.Channel -> it.channel.id - is CategorisedChannelList.Category -> it.category.id - } - } ?: index - } - ) { - when (val item = categorisedChannels.getOrNull(it)) { - is CategorisedChannelList.Channel -> { - val channel = item.channel - - val partner = - if (channel.channelType == ChannelType.DirectMessage) { - RevoltAPI.userCache[ - ChannelUtils.resolveDMPartner( - channel - ) - ] - } else { - null - } - - DrawerChannel( - name = partner?.let { p -> User.resolveDefaultName(p) } - ?: channel.name - ?: stringResource(R.string.unknown), - iconType = DrawerChannelIconType.Channel( - channel.channelType ?: ChannelType.TextChannel - ), - selected = currentDestination == ChatRouterDestination.Channel( - channel.id ?: "" - ), - hasUnread = channel.lastMessageID?.let { lastMessageID -> - RevoltAPI.unreads.hasUnread( - channel.id!!, - lastMessageID - ) - } ?: false, - dmPartnerIcon = partner?.avatar ?: channel.icon, - dmPartnerId = partner?.id, - dmPartnerName = partner?.let { p -> - User.resolveDefaultName( - p - ) - }, - dmPartnerStatus = presenceFromStatus( - status = partner?.status?.presence, - online = partner?.online ?: false - ), - onClick = { - onDestinationChange( - ChatRouterDestination.Channel( - channel.id ?: return@DrawerChannel - ) - ) - }, - onLongClick = { - channelContextSheetTarget = - channel.id ?: return@DrawerChannel - channelContextSheetShown = true - } - ) - } - - is CategorisedChannelList.Category -> { - val category = item.category - - Text( - text = category.title ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.labelLarge, - fontSize = 16.sp, - modifier = Modifier - .padding( - start = 16.dp, - end = 16.dp, - top = 24.dp, - bottom = 16.dp - ) - ) - } - - else -> {} - } - } - } - } - } - } -} diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt deleted file mode 100644 index 1d796c2f..00000000 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt +++ /dev/null @@ -1,169 +0,0 @@ -package chat.revolt.components.screens.chat.drawer.server - -import androidx.compose.animation.animateColorAsState -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.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import chat.revolt.api.schemas.AutumnResource -import chat.revolt.api.schemas.ChannelType -import chat.revolt.components.generic.GroupIcon -import chat.revolt.components.generic.Presence -import chat.revolt.components.generic.UserAvatar -import chat.revolt.components.screens.chat.ChannelIcon - -sealed class DrawerChannelIconType { - data class Channel(val type: ChannelType) : DrawerChannelIconType() - data class Painter(val painter: androidx.compose.ui.graphics.painter.Painter) : - DrawerChannelIconType() -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun DrawerChannel( - iconType: DrawerChannelIconType, - name: String, - selected: Boolean, - hasUnread: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit = {}, - dmPartnerStatus: Presence? = null, - dmPartnerName: String? = null, - dmPartnerIcon: AutumnResource? = null, - dmPartnerId: String? = null, - large: Boolean = false -) { - val backgroundColor = animateColorAsState( - if (selected) { - MaterialTheme.colorScheme.background - } else { - Color.Transparent - }, - animationSpec = spring(), - label = "Channel background colour" - ) - - val unreadDotOpacity = animateFloatAsState( - if (hasUnread) 1f else 0f, - animationSpec = spring(), - label = "Unread dot opacity" - ) - - val channelAlpha = animateFloatAsState( - if (hasUnread || selected) 1f else 0.8f, - animationSpec = spring(), - label = "Channel alpha" - ) - - Row( - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 8.dp) - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .background(backgroundColor.value) - .alpha(channelAlpha.value) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ) - .padding(vertical = 8.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - when (iconType) { - is DrawerChannelIconType.Channel -> { - when (val channelType = iconType.type) { - ChannelType.DirectMessage -> UserAvatar( - username = dmPartnerName ?: "", - avatar = dmPartnerIcon, - userId = dmPartnerId ?: "", - presence = dmPartnerStatus, - size = 32.dp, - presenceSize = 16.dp, - modifier = Modifier.padding(end = 8.dp) - ) - - ChannelType.Group -> GroupIcon( - name = name, - icon = dmPartnerIcon, - size = 32.dp, - modifier = Modifier.padding(end = 8.dp) - ) - - else -> ChannelIcon( - channelType = channelType, - modifier = Modifier.then( - if (large) { - Modifier.padding( - end = 12.dp, - start = 4.dp, - top = 4.dp, - bottom = 4.dp - ) - } else { - Modifier.padding(end = 8.dp) - } - ) - ) - } - } - - is DrawerChannelIconType.Painter -> { - Icon( - painter = iconType.painter, - contentDescription = null, - tint = LocalContentColor.current, - modifier = Modifier - .padding(end = 8.dp) - .size(32.dp) - .padding(4.dp) - ) - } - } - - Text( - text = name, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - .padding(end = 8.dp) - ) - - if (hasUnread) { - Box( - modifier = Modifier - .offset(x = (-8).dp) - .clip(CircleShape) - .background(LocalContentColor.current) - .alpha(unreadDotOpacity.value) - .size(8.dp) - ) - } else { - Spacer(modifier = Modifier.size(8.dp)) - } - } -} 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 deleted file mode 100644 index d561c923..00000000 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerServer.kt +++ /dev/null @@ -1,78 +0,0 @@ -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.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.LocalContentColor -import androidx.compose.runtime.Composable -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.unit.dp -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( - if (hasUnreads) 1f else 0f, - animationSpec = spring(), - label = "Unread indicator alpha" - ) - - Box( - contentAlignment = Alignment.CenterStart - ) { - if (iconId != null) { - RemoteImage( - url = "$REVOLT_FILES/icons/$iconId/server.png?max_side=256", - modifier = Modifier - .padding(8.dp) - .size(48.dp) - .clip(CircleShape) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ), - description = serverName - ) - } else { - IconPlaceholder( - name = serverName, - onClick = onClick, - onLongClick = onLongClick, - modifier = Modifier - .padding(8.dp) - .size(48.dp) - .clip(CircleShape) - ) - } - - // Unread indicator - Box( - modifier = Modifier - .padding(8.dp) - .size(8.dp) - .offset(x = (-12).dp) - .clip(CircleShape) - .alpha(unreadIndicatorAlpha.value) - .background(LocalContentColor.current) - ) - } -} diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerServerlikeIcon.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerServerlikeIcon.kt deleted file mode 100644 index 8405aa90..00000000 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerServerlikeIcon.kt +++ /dev/null @@ -1,26 +0,0 @@ -package chat.revolt.components.screens.chat.drawer.server - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp - -@Composable -fun DrawerServerlikeIcon(onClick: () -> Unit, content: @Composable () -> Unit) { - IconButton( - onClick = onClick, - modifier = Modifier - .padding(8.dp) - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface) - ) { - content() - } -} diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/ServerDrawerSeparator.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/ServerDrawerSeparator.kt deleted file mode 100644 index c5e00e6c..00000000 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/ServerDrawerSeparator.kt +++ /dev/null @@ -1,26 +0,0 @@ -package chat.revolt.components.screens.chat.drawer.server - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun ServerDrawerSeparator() { - Box( - Modifier - .padding(horizontal = 8.dp, vertical = 4.dp) - .height(1.dp) - .width(48.dp) - .background( - MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = 0.1f - ) - ) - ) -} diff --git a/app/src/main/java/chat/revolt/components/screens/settings/ServerOverview.kt b/app/src/main/java/chat/revolt/components/screens/settings/ServerOverview.kt index 78b0eaf3..d6d44e5d 100644 --- a/app/src/main/java/chat/revolt/components/screens/settings/ServerOverview.kt +++ b/app/src/main/java/chat/revolt/components/screens/settings/ServerOverview.kt @@ -56,7 +56,7 @@ fun ServerOverview(server: Server) { modifier = Modifier .height(166.dp) .fillMaxWidth(), - contentScale = ContentScale.FillWidth + contentScale = ContentScale.Crop ) Box( 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 6a2a1652..bb4ddfbb 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -6,25 +6,15 @@ import android.view.accessibility.AccessibilityManager import android.view.inputmethod.InputMethodManager import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.DismissibleDrawerSheet import androidx.compose.material3.DismissibleNavigationDrawer @@ -49,7 +39,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -57,32 +46,23 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavController import chat.revolt.R import chat.revolt.api.RevoltAPI -import chat.revolt.api.internals.ChannelUtils import chat.revolt.api.internals.DirectMessages import chat.revolt.api.realtime.DisconnectionState import chat.revolt.api.realtime.RealtimeSocket -import chat.revolt.api.schemas.ChannelType -import chat.revolt.api.schemas.User -import chat.revolt.api.settings.SyncedSettings import chat.revolt.callbacks.Action import chat.revolt.callbacks.ActionChannel import chat.revolt.components.chat.DisconnectedNotice -import chat.revolt.components.generic.GroupIcon -import chat.revolt.components.generic.UserAvatar -import chat.revolt.components.generic.presenceFromStatus -import chat.revolt.components.screens.chat.drawer.channel.ChannelList -import chat.revolt.components.screens.chat.drawer.server.DrawerServer -import chat.revolt.components.screens.chat.drawer.server.DrawerServerlikeIcon -import chat.revolt.components.screens.chat.drawer.server.ServerDrawerSeparator +import chat.revolt.components.screens.chat.drawer.ChannelSideDrawer import chat.revolt.components.screens.voice.VoiceChannelOverlay import chat.revolt.internals.Changelogs +import chat.revolt.internals.extensions.BottomSheetInsets +import chat.revolt.internals.extensions.zero import chat.revolt.persistence.KVStorage import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog import chat.revolt.screens.chat.dialogs.safety.ReportServerDialog @@ -538,7 +518,8 @@ fun ChatRouterScreen( sheetState = serverContextSheetState, onDismissRequest = { showServerContextSheet = false - } + }, + windowInsets = BottomSheetInsets ) { ServerContextSheet( serverId = serverContextSheetTarget, @@ -718,7 +699,7 @@ fun ChatRouterScreen( Row { DismissibleDrawerSheet( drawerContainerColor = Color.Transparent, - windowInsets = WindowInsets.navigationBars + windowInsets = WindowInsets.zero ) { Sidebar( viewModel = viewModel, @@ -755,7 +736,7 @@ fun ChatRouterScreen( drawerContent = { DismissibleDrawerSheet( drawerContainerColor = Color.Transparent, - windowInsets = WindowInsets.navigationBars + windowInsets = WindowInsets.zero ) { Sidebar( viewModel = viewModel, @@ -809,192 +790,19 @@ fun Sidebar( showSettingsButton: Boolean, onOpenSettings: () -> Unit, ) { - val scope = rememberCoroutineScope() - - Column(Modifier.fillMaxWidth()) { - Row { - Column( - modifier = Modifier - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - UserAvatar( - username = RevoltAPI.userCache[RevoltAPI.selfId]?.let { - User.resolveDefaultName( - it - ) - } - ?: "", - presence = presenceFromStatus( - RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence, - RevoltAPI.userCache[RevoltAPI.selfId]?.online ?: false - ), - userId = RevoltAPI.selfId ?: "", - avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar, - size = 48.dp, - presenceSize = 16.dp, - onClick = { - viewModel.setSaveDestination(ChatRouterDestination.defaultForDMList) - }, - onLongClick = onShowStatusSheet, - modifier = Modifier - .padding(8.dp) - .size(48.dp) - ) - - DirectMessages.unreadDMs().forEach { - when (it.channelType) { - ChannelType.Group -> GroupIcon( - name = it.name ?: "?", - size = 48.dp, - onClick = { - it.id?.let { id -> - viewModel.setSaveDestination(ChatRouterDestination.Channel(id)) - } - }, - icon = it.icon, - modifier = Modifier - .padding(8.dp) - .size(48.dp) - ) - - else -> { - val partner = - if (it.channelType == ChannelType.DirectMessage) { - RevoltAPI.userCache[ - ChannelUtils.resolveDMPartner( - it - ) - ] - } else { - null - } - - UserAvatar( - username = partner?.let { p -> - User.resolveDefaultName( - p - ) - } ?: it.name ?: "?", - presence = presenceFromStatus( - partner?.status?.presence, - partner?.online ?: false - ), - userId = partner?.id ?: it.id ?: "", - avatar = partner?.avatar ?: it.icon, - size = 48.dp, - presenceSize = 16.dp, - onClick = { - it.id?.let { id -> - viewModel.setSaveDestination( - ChatRouterDestination.Channel( - id - ) - ) - } - }, - modifier = Modifier - .padding(8.dp) - .size(48.dp) - ) - } - } - } - - ServerDrawerSeparator() - - // This seems to confuse the formatter, here's what it does: - // - Take the list of servers and filter them by the ones that are in the ordering. - // - Sort the servers that are in the ordering using the ordering. - // - Add the servers that aren't in the ordering to the end of the list. - // - Sort the servers that aren't in the ordering by their ID (creation order). - ( - ( - RevoltAPI.serverCache.values.filter { - SyncedSettings.ordering.servers.contains( - it.id - ) - } - .sortedBy { SyncedSettings.ordering.servers.indexOf(it.id) } - ) + ( - RevoltAPI.serverCache.values.filter { - !SyncedSettings.ordering.servers.contains( - it.id - ) - }.sortedBy { it.id } - ) - ) - .forEach { server -> - if (server.id == null || server.name == null) return@forEach - - DrawerServer( - iconId = server.icon?.id, - serverName = server.name, - hasUnreads = RevoltAPI.unreads.serverHasUnread( - server.id - ), - onLongClick = { - onShowServerContextSheet(server.id) - } - ) { - viewModel.navigateToServer(server.id) - } - } - - DrawerServerlikeIcon( - onClick = onShowAddServerSheet - ) { - Icon( - Icons.Default.Add, - contentDescription = stringResource(id = R.string.server_plus_alt), - modifier = Modifier.padding(4.dp) - ) - } - - DrawerServerlikeIcon( - onClick = { topNav.navigate("discover") } - ) { - Icon( - painter = painterResource(id = R.drawable.ic_compass_24dp), - contentDescription = stringResource(id = R.string.discover_alt), - modifier = Modifier.padding(4.dp) - ) - } - - if (showSettingsButton) { - DrawerServerlikeIcon( - onClick = { onOpenSettings() } - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = stringResource(id = R.string.settings), - modifier = Modifier.padding(4.dp) - ) - } - } - } - - Crossfade( - targetState = currentServer, - label = "Channel List" - ) { - ChannelList( - serverId = it, - currentDestination = viewModel.currentDestination, - onDestinationChange = { destination -> - viewModel.setSaveDestination(destination) - scope.launch { - drawerState?.close() - } - }, - onServerSheetOpenFor = { target -> - onShowServerContextSheet(target) - } - ) - } - } - } + ChannelSideDrawer( + onDestinationChanged = viewModel::setSaveDestination, + currentDestination = viewModel.currentDestination, + currentServer = currentServer, + drawerState = drawerState, + navigateToServer = viewModel::navigateToServer, + onLongPressAvatar = onShowStatusSheet, + onShowServerContextSheet = onShowServerContextSheet, + showSettingsIcon = showSettingsButton, + onOpenSettings = onOpenSettings, + topNav = topNav, + onShowAddServerSheet = onShowAddServerSheet + ) } @Composable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8363520c..bce1530f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -295,6 +295,7 @@ Delete Keep + Open channel actions Copy ID Copied channel ID to clipboard Mark as read