feat: channel settings and attachment rendering

- also general design improvements
This commit is contained in:
Infi 2023-01-04 03:39:27 +01:00
parent d93b9f1bcb
commit 4a5365c3c1
16 changed files with 403 additions and 97 deletions

View File

@ -61,6 +61,7 @@ dependencies {
// Jetpack Compose
implementation "androidx.compose.ui:ui:$compose_libraries_version"
implementation "androidx.compose.ui:ui-util:$compose_libraries_version"
implementation 'androidx.compose.material3:material3:1.0.1'
implementation "androidx.compose.ui:ui-tooling-preview:$compose_libraries_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'

View File

@ -156,6 +156,16 @@ object RealtimeSocket {
RevoltAPI.userCache[userUpdateFrame.id] =
existing.mergeWithPartial(userUpdateFrame.data)
}
"ChannelUpdate" -> {
val channelUpdateFrame =
RevoltJson.decodeFromString(ChannelUpdateFrame.serializer(), rawFrame)
val existing = RevoltAPI.channelCache[channelUpdateFrame.id]
?: return // if we don't have the channel no point in updating it
RevoltAPI.channelCache[channelUpdateFrame.id] =
existing.mergeWithPartial(channelUpdateFrame.data)
}
else -> {
Log.i("RealtimeSocket", "Unknown frame: $rawFrame")
}

View File

@ -168,12 +168,6 @@ data class ServerDeleteFrame(
val id: String
)
@Serializable
data class ServerUserChoice(
val server: String,
val user: String,
)
@Serializable
data class ServerMemberUpdateFrame(
val type: String = "ServerMemberUpdate",

View File

@ -12,10 +12,16 @@ data class MessagesInChannel(
val members: List<Member>? = null
)
@Serializable
data class ServerUserChoice(
val server: String,
val user: String,
)
@Serializable
data class Member(
@SerialName("_id")
val id: String? = null,
val id: ServerUserChoice? = null,
@SerialName("joined_at")
val joinedAt: String? = null,

View File

@ -1,28 +1,44 @@
package chat.revolt.components.chat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.revolt.api.REVOLT_BASE
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.schemas.AutumnResource
import chat.revolt.components.generic.RemoteImage
import chat.revolt.api.schemas.Message as MessageSchema
fun viewAttachmentInBrowser(ctx: android.content.Context, attachment: AutumnResource) {
val customTab = CustomTabsIntent
.Builder()
.build()
customTab.launchUrl(
ctx,
Uri.parse("$REVOLT_FILES/attachments/${attachment.id}/${attachment.filename}")
)
}
@Composable
fun Message(
message: MessageSchema
) {
val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator()
val context = LocalContext.current
Row(modifier = Modifier.padding(8.dp)) {
if (author.avatar != null) {
@ -55,6 +71,39 @@ fun Message(
text = it
)
}
message.attachments?.let {
if (message.attachments.isNotEmpty()) {
message.attachments.forEach { attachment ->
if (attachment.metadata?.type == "Image") {
RemoteImage(
url = "$REVOLT_FILES/attachments/${attachment.id}/image.png",
modifier = Modifier
.padding(top = 5.dp)
.clickable {
viewAttachmentInBrowser(context, attachment)
},
width = attachment.metadata.width?.toInt() ?: 0,
height = attachment.metadata.height?.toInt() ?: 0,
contentScale = ContentScale.Fit,
description = "Attached image ${attachment.filename}"
)
} else {
Text(
text = attachment.filename ?: "Attachment",
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.clickable {
viewAttachmentInBrowser(context, attachment)
}
.background(MaterialTheme.colorScheme.surface)
.padding(8.dp)
)
}
}
}
}
}
}
}

View File

@ -28,9 +28,9 @@ fun CollapsibleCard(
Column {
Row(
modifier = Modifier
.clickable { expanded = !expanded }
.fillMaxWidth()
.padding(16.dp)
.clickable { expanded = !expanded },
.padding(16.dp),
) {
Text(
text = title,

View File

@ -0,0 +1,36 @@
package chat.revolt.components.generic
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PageHeader(
text: String,
) {
Text(
text = text,
style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left,
fontSize = 24.sp
),
modifier = Modifier
.padding(horizontal = 15.dp, vertical = 15.dp)
.fillMaxWidth(),
)
}
@Preview
@Composable
fun PageHeaderPreview() {
PageHeader(text = "Page Header")
}

View File

@ -19,15 +19,26 @@ fun RemoteImage(
url: String,
description: String,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop
contentScale: ContentScale = ContentScale.Crop,
width: Int = 0,
height: Int = 0,
) {
val context = LocalContext.current
AsyncImage(
model = ImageRequest.Builder(context)
.data(url)
fun imageRequest() = run {
val builder = ImageRequest.Builder(context)
.crossfade(true)
.build(),
.data(url)
if (width != 0 && height != 0) {
builder.size(width, height)
}
builder.build()
}
AsyncImage(
model = imageRequest(),
imageLoader = ImageLoader.Builder(context)
.components {
if (Build.VERSION.SDK_INT >= 28) {
@ -45,7 +56,7 @@ fun RemoteImage(
.build(),
contentDescription = description,
contentScale = contentScale,
modifier = modifier
modifier = modifier,
)
}

View File

@ -0,0 +1,56 @@
package chat.revolt.components.screens.chat
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import chat.revolt.api.schemas.ChannelType
import chat.revolt.R
@Composable
fun ChannelIcon(
channelType: ChannelType,
modifier: Modifier = Modifier,
) {
when (channelType) {
ChannelType.TextChannel -> {
Icon(
painter = painterResource(R.drawable.ic_pound_24dp),
contentDescription = stringResource(R.string.channel_text),
modifier = modifier,
)
}
ChannelType.VoiceChannel -> {
Icon(
painter = painterResource(R.drawable.ic_volume_up_24dp),
contentDescription = stringResource(R.string.channel_voice),
modifier = modifier,
)
}
ChannelType.SavedMessages -> {
Icon(
painter = painterResource(R.drawable.ic_note_24dp),
contentDescription = stringResource(R.string.channel_notes),
modifier = modifier,
)
}
ChannelType.DirectMessage -> {
Icon(
imageVector = Icons.Default.AccountCircle,
contentDescription = stringResource(R.string.channel_dm),
modifier = modifier,
)
}
ChannelType.Group -> {
Icon(
imageVector = Icons.Default.AccountBox,
contentDescription = stringResource(R.string.channel_group),
modifier = modifier,
)
}
}
}

View File

@ -5,20 +5,14 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.revolt.api.schemas.ChannelType
import chat.revolt.R
@Composable
fun DrawerChannel(
@ -36,36 +30,7 @@ fun DrawerChannel(
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 16.dp)
) {
when (channelType) {
ChannelType.TextChannel -> {
Icon(
modifier = Modifier.padding(end = 8.dp),
painter = painterResource(R.drawable.ic_pound_24dp),
contentDescription = stringResource(R.string.channel_text)
)
}
ChannelType.VoiceChannel -> {
Icon(
modifier = Modifier.padding(end = 8.dp),
painter = painterResource(R.drawable.ic_volume_up_24dp),
contentDescription = stringResource(R.string.channel_voice)
)
}
ChannelType.SavedMessages -> {
Icon(
modifier = Modifier.padding(end = 8.dp),
painter = painterResource(R.drawable.ic_note_24dp),
contentDescription = stringResource(R.string.channel_notes)
)
}
else -> {
Icon(
modifier = Modifier.padding(end = 8.dp),
imageVector = Icons.Default.List,
contentDescription = "Channel"
)
}
}
ChannelIcon(channelType = channelType, modifier = Modifier.padding(end = 8.dp))
Text(
text = name,
fontWeight = FontWeight.Medium,

View File

@ -34,7 +34,6 @@ fun DisconnectedScreen(
textAlign = TextAlign.Center
),
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 10.dp)
.fillMaxWidth(),
)
@ -46,7 +45,7 @@ fun DisconnectedScreen(
fontWeight = FontWeight.Normal,
),
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 10.dp)
.padding(vertical = 10.dp, horizontal = 20.dp)
.fillMaxWidth()
)

View File

@ -55,14 +55,14 @@ class SplashScreenViewModel @Inject constructor(
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkCapabilities = connectivityManager.activeNetwork ?: return false
val actNw =
connectivityManager.getNetworkCapabilities(networkCapabilities) ?: return false
val network = connectivityManager.activeNetwork ?: return false
val capabilities =
connectivityManager.getNetworkCapabilities(network) ?: return false
return when {
actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
else -> false
}
}

View File

@ -51,8 +51,24 @@ class ChatRouterViewModel : ViewModel() {
_currentServer.value = serverId
}
fun goToHome() {
_currentServer.value = "home"
fun navigateToServer(serverId: String, navController: NavController) {
setCurrentServer(serverId)
if (serverId == "home") {
navController.navigate("home") {
popUpTo("home") {
inclusive = true
}
}
return
}
val channelId = RevoltAPI.serverCache[serverId]?.channels?.firstOrNull()
navController.navigate("channel/$channelId") {
popUpTo("home") {
inclusive = true
}
}
}
}
@ -85,7 +101,9 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
.background(MaterialTheme.colorScheme.surface)
) {
IconButton(
onClick = { viewModel.goToHome() },
onClick = {
viewModel.navigateToServer("home", navController)
},
modifier = Modifier
.padding(8.dp)
.size(48.dp)
@ -107,7 +125,12 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
.padding(8.dp)
.size(48.dp)
.clip(CircleShape)
.clickable { viewModel.setCurrentServer(server.id!!) },
.clickable {
viewModel.navigateToServer(
server.id!!,
navController
)
},
description = "${server.name}"
)
} else {
@ -119,7 +142,12 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { viewModel.setCurrentServer(server.id!!) }
.clickable {
viewModel.navigateToServer(
server.id!!,
navController
)
}
) {
Text(
text = server.name.first().toString(),
@ -143,11 +171,11 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
.weight(1f)
.verticalScroll(rememberScrollState())
) {
RevoltAPI.channelCache.values.filter { it.channelType == ChannelType.DirectMessage }
RevoltAPI.channelCache.values.filter { it.channelType == ChannelType.Group }
.forEach { channel ->
DrawerChannel(
name = "DM #${channel.id}", // TODO get user or group name
channelType = ChannelType.DirectMessage,
name = channel.name ?: "GDM #${channel.id}",
channelType = ChannelType.Group,
selected = channel.id == (navBackStackEntry?.arguments?.getString(
"channelId"
) ?: false),
@ -185,7 +213,11 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
) == ch.id,
onClick = {
scope.launch { channelDrawerState.close() }
navController.navigate("channel/${ch.id}")
navController.navigate("channel/${ch.id}") {
popUpTo("home") {
inclusive = true
}
}
})
}
}

View File

@ -1,18 +1,29 @@
package chat.revolt.screens.chat.views
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
@ -33,6 +44,9 @@ import chat.revolt.RevoltTweenFloat
import chat.revolt.RevoltTweenInt
import chat.revolt.api.routes.channel.fetchMessagesFromChannel
import chat.revolt.components.chat.MessageField
import chat.revolt.components.generic.CollapsibleCard
import chat.revolt.components.generic.PageHeader
import chat.revolt.components.screens.chat.ChannelIcon
class ChannelScreenViewModel : ViewModel() {
private var _channel by mutableStateOf<Channel?>(null)
@ -109,6 +123,8 @@ class ChannelScreenViewModel : ViewModel() {
return
}
_renderableMessages.clear()
viewModelScope.launch {
fetchMessagesFromChannel(channel!!.id!!, limit = 50, false).let {
it.messages!!.reversed().forEach { message ->
@ -122,6 +138,29 @@ class ChannelScreenViewModel : ViewModel() {
}
}
fun fetchOlderMessages() {
if (channel == null) {
return
}
viewModelScope.launch {
fetchMessagesFromChannel(
channel!!.id!!,
limit = 20,
true,
before = renderableMessages.first().id
).let {
it.messages!!.forEach { message ->
addUserIfUnknown(message.author!!)
if (!RevoltAPI.messageCache.containsKey(message.id)) {
RevoltAPI.messageCache[message.id!!] = message
}
_renderableMessages.add(0, message)
}
}
}
}
fun fetchChannel(id: String) {
if (id in RevoltAPI.channelCache) {
_channel = RevoltAPI.channelCache[id]
@ -158,6 +197,79 @@ class ChannelScreenViewModel : ViewModel() {
}
}
@Composable
fun ChannelInfoScreen(
channel: Channel,
viewModel: ChannelScreenViewModel,
onClosed: () -> Unit,
) {
val context = LocalContext.current
val clipboardManager: ClipboardManager =
LocalClipboardManager.current
val coroutineScope = rememberCoroutineScope()
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp)
.fillMaxSize()
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
ChannelIcon(
channelType = channel.channelType!!,
modifier = Modifier.size(32.dp)
)
PageHeader(text = channel.name ?: channel.id!!)
}
Column(modifier = Modifier.weight(1f)) {
CollapsibleCard(title = "Advanced") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text("Channel ID: ${channel.id}")
Button(onClick = {
clipboardManager.setText(AnnotatedString(channel.id!!))
Toast.makeText(
context,
"Copied",
Toast.LENGTH_SHORT
).show()
}) {
Text("Copy ID")
}
Button(
onClick = {
coroutineScope.launch {
viewModel.fetchMessages()
}
},
modifier = Modifier
.fillMaxWidth()
) {
Text("Refetch messages")
}
}
}
}
Button(
onClick = onClosed,
modifier = Modifier
.fillMaxWidth()
) {
Text("Close")
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ChannelScreen(
navController: NavController,
@ -166,6 +278,8 @@ fun ChannelScreen(
) {
val channel = viewModel.channel
val scrollState = rememberScrollState()
val channelInfoOpen = remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(channelId) {
viewModel.fetchChannel(channelId)
@ -184,20 +298,61 @@ fun ChannelScreen(
return
}
if (channelInfoOpen.value) {
Dialog(
onDismissRequest = {
channelInfoOpen.value = false
},
properties = DialogProperties(
usePlatformDefaultWidth = false,
)
) {
ChannelInfoScreen(channel, viewModel) {
channelInfoOpen.value = false
}
}
}
Column {
Row(
modifier = Modifier
.clickable {
coroutineScope.launch {
channelInfoOpen.value = true
}
}
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
ChannelIcon(
channelType = channel.channelType!!,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = channel.name ?: channel.id!!,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(16.dp)
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
}
LazyColumn(Modifier.weight(1f)) {
item {
Button(
onClick = {
coroutineScope.launch {
viewModel.fetchOlderMessages()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 8.dp)
) {
Text("Load older")
}
}
items(viewModel.renderableMessages) { message ->
Message(message)
}

View File

@ -3,14 +3,8 @@ package chat.revolt.screens.chat.views
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.navigation.NavController
@ -19,6 +13,7 @@ import chat.revolt.components.screens.home.LinkOnHome
import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel
import chat.revolt.R
import chat.revolt.components.generic.PageHeader
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@ -36,19 +31,8 @@ class HomeScreenViewModel @Inject constructor(
@Composable
fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hiltViewModel()) {
Column() {
Text(
text = stringResource(id = R.string.home),
style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left,
fontSize = 24.sp
),
modifier = Modifier
.padding(horizontal = 15.dp, vertical = 15.dp)
.fillMaxWidth(),
)
Column {
PageHeader(text = stringResource(id = R.string.home))
LinkOnHome(
heading = stringResource(id = R.string.logout),
icon = Icons.Default.Close,

View File

@ -15,6 +15,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat
const val FORCE_ANDROID_DEFAULTS = false
const val FOREGROUND = 0xffffffff
val DarkColorScheme = darkColorScheme(
@ -60,9 +62,15 @@ fun RevoltTheme(
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = RevoltTypography,
content = content
)
if (FORCE_ANDROID_DEFAULTS) {
MaterialTheme(
content = content
)
} else {
MaterialTheme(
colorScheme = colorScheme,
typography = RevoltTypography,
content = content
)
}
}