feat: group settings screen

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-03-02 21:25:33 +01:00
parent a5739f0f58
commit a63f142bbc
15 changed files with 1025 additions and 11 deletions

View File

@ -67,6 +67,9 @@ import chat.revolt.screens.settings.DebugSettingsScreen
import chat.revolt.screens.settings.ProfileSettingsScreen import chat.revolt.screens.settings.ProfileSettingsScreen
import chat.revolt.screens.settings.SessionSettingsScreen import chat.revolt.screens.settings.SessionSettingsScreen
import chat.revolt.screens.settings.SettingsScreen import chat.revolt.screens.settings.SettingsScreen
import chat.revolt.screens.settings.channel.ChannelSettingsHome
import chat.revolt.screens.settings.channel.ChannelSettingsOverview
import chat.revolt.screens.settings.channel.ChannelSettingsPermissions
import chat.revolt.ui.theme.RevoltTheme import chat.revolt.ui.theme.RevoltTheme
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -377,6 +380,19 @@ fun AppEntrypoint(
composable("settings/debug") { DebugSettingsScreen(navController) } composable("settings/debug") { DebugSettingsScreen(navController) }
composable("settings/changelogs") { ChangelogsSettingsScreen(navController) } composable("settings/changelogs") { ChangelogsSettingsScreen(navController) }
composable("settings/channel/{channelId}") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelSettingsHome(navController, channelId)
}
composable("settings/channel/{channelId}/overview") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelSettingsOverview(navController, channelId)
}
composable("settings/channel/{channelId}/permissions") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelSettingsPermissions(navController, channelId)
}
composable("about") { AboutScreen(navController) } composable("about") { AboutScreen(navController) }
composable("about/oss") { AttributionScreen(navController) } composable("about/oss") { AttributionScreen(navController) }

View File

@ -9,6 +9,7 @@ import chat.revolt.api.RevoltJson
import chat.revolt.api.realtime.frames.receivable.AnyFrame import chat.revolt.api.realtime.frames.receivable.AnyFrame
import chat.revolt.api.realtime.frames.receivable.BulkFrame import chat.revolt.api.realtime.frames.receivable.BulkFrame
import chat.revolt.api.realtime.frames.receivable.ChannelAckFrame import chat.revolt.api.realtime.frames.receivable.ChannelAckFrame
import chat.revolt.api.realtime.frames.receivable.ChannelDeleteFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelUpdateFrame import chat.revolt.api.realtime.frames.receivable.ChannelUpdateFrame
@ -403,6 +404,45 @@ object RealtimeSocket {
RevoltAPI.channelCache[channelCreateFrame.id!!] = channelCreateFrame RevoltAPI.channelCache[channelCreateFrame.id!!] = channelCreateFrame
} }
"ChannelDelete" -> {
val channelDeleteFrame =
RevoltJson.decodeFromString(ChannelDeleteFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received channel delete frame for ${channelDeleteFrame.id}. Removing from cache."
)
val currentChannel = RevoltAPI.channelCache[channelDeleteFrame.id]
if (currentChannel == null) {
Log.d(
"RealtimeSocket",
"Channel ${channelDeleteFrame.id} not found in cache. Ignoring."
)
return
}
RevoltAPI.channelCache.remove(channelDeleteFrame.id)
if (currentChannel.server != null) {
val existingServer = RevoltAPI.serverCache[currentChannel.server]
if (existingServer == null) {
Log.d(
"RealtimeSocket",
"Server ${currentChannel.server} not found in cache. Ignoring."
)
return
}
RevoltAPI.serverCache[currentChannel.server] = existingServer.copy(
channels = existingServer.channels?.filter { it != channelDeleteFrame.id }
?: emptyList()
)
}
RevoltAPI.wsFrameChannel.send(channelDeleteFrame)
}
"ChannelAck" -> { "ChannelAck" -> {
val channelAckFrame = val channelAckFrame =
RevoltJson.decodeFromString(ChannelAckFrame.serializer(), rawFrame) RevoltJson.decodeFromString(ChannelAckFrame.serializer(), rawFrame)

View File

@ -1,5 +1,7 @@
package chat.revolt.api.routes.channel package chat.revolt.api.routes.channel
import android.util.Log
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltError import chat.revolt.api.RevoltError
import chat.revolt.api.RevoltHttp import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson import chat.revolt.api.RevoltJson
@ -21,6 +23,9 @@ import io.ktor.http.contentType
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.JsonElement
suspend fun fetchMessagesFromChannel( suspend fun fetchMessagesFromChannel(
channelId: String, channelId: String,
@ -180,4 +185,73 @@ suspend fun fetchSingleMessage(channelId: String, messageId: String): Message {
Message.serializer(), Message.serializer(),
response response
) )
}
suspend fun leaveDeleteOrCloseChannel(channelId: String, leaveSilently: Boolean = false) {
RevoltHttp.delete("/channels/$channelId") {
parameter("leave_silently", leaveSilently)
}
}
suspend fun patchChannel(
channelId: String,
name: String? = null,
description: String? = null,
icon: String? = null,
banner: String? = null,
remove: List<String>? = null,
nsfw: Boolean? = null,
pure: Boolean = false
) {
val body = mutableMapOf<String, JsonElement>()
if (name != null) {
body["name"] = RevoltJson.encodeToJsonElement(String.serializer(), name)
}
if (description != null) {
body["description"] = RevoltJson.encodeToJsonElement(String.serializer(), description)
}
if (icon != null) {
body["icon"] = RevoltJson.encodeToJsonElement(String.serializer(), icon)
}
if (banner != null) {
body["banner"] = RevoltJson.encodeToJsonElement(String.serializer(), banner)
}
if (remove != null) {
body["remove"] = RevoltJson.encodeToJsonElement(ListSerializer(String.serializer()), remove)
}
if (nsfw != null) {
body["nsfw"] = RevoltJson.encodeToJsonElement(Boolean.serializer(), nsfw)
}
val response = RevoltHttp.patch("/channels/$channelId") {
contentType(ContentType.Application.Json)
setBody(
RevoltJson.encodeToString(
MapSerializer(
String.serializer(),
JsonElement.serializer()
),
body
)
)
}
.bodyAsText()
try {
val error = RevoltJson.decodeFromString(RevoltError.serializer(), response)
throw Exception(error.type)
} catch (e: SerializationException) {
// Not an error
}
if (!pure) {
val channel = RevoltJson.decodeFromString(Channel.serializer(), response)
RevoltAPI.channelCache[channelId] = channel
}
} }

View File

@ -14,6 +14,7 @@ import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.Headers import io.ktor.http.Headers
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import java.io.File import java.io.File
const val MAX_ATTACHMENTS_PER_MESSAGE = 5 const val MAX_ATTACHMENTS_PER_MESSAGE = 5
@ -62,9 +63,12 @@ suspend fun uploadToAutumn(
val error = RevoltJson.decodeFromString(AutumnError.serializer(), response.bodyAsText()) val error = RevoltJson.decodeFromString(AutumnError.serializer(), response.bodyAsText())
throw Exception(error.type) throw Exception(error.type)
} catch (e: Exception) { } catch (e: Exception) {
if (response.status.value == 429) { if (response.status == HttpStatusCode.TooManyRequests) {
throw Exception("Rate limited") throw Exception("Rate limited")
} }
if (response.status == HttpStatusCode.PayloadTooLarge) {
throw Exception("File too large")
}
throw Exception("Unknown error") throw Exception("Unknown error")
} }
} }

View File

@ -12,8 +12,10 @@ data class Server(
val description: String? = null, val description: String? = null,
val channels: List<String>? = null, val channels: List<String>? = null,
val categories: List<Category>? = null, val categories: List<Category>? = null,
@SerialName("system_messages")
val systemMessages: SystemMessages? = null, val systemMessages: SystemMessages? = null,
val roles: Map<String, Role>? = null, val roles: Map<String, Role>? = null,
@SerialName("default_permissions")
val defaultPermissions: Long? = null, val defaultPermissions: Long? = null,
val icon: AutumnResource? = null, val icon: AutumnResource? = null,
val banner: AutumnResource? = null, val banner: AutumnResource? = null,

View File

@ -36,15 +36,18 @@ import com.bumptech.glide.integration.compose.GlideImage
@Composable @Composable
fun InlineMediaPicker( fun InlineMediaPicker(
currentModel: Any?, currentModel: Any?,
modifier: Modifier = Modifier,
mimeType: String = "image/*", mimeType: String = "image/*",
circular: Boolean = false, circular: Boolean = false,
onPick: (Uri) -> Unit, onPick: (Uri) -> Unit,
canRemove: Boolean = true, canRemove: Boolean = true,
onRemove: () -> Unit = {} onRemove: () -> Unit = {},
enabled: Boolean = true
) { ) {
if (circular) { if (circular) {
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) { ) {
InlineMediaPickerMediaPicker( InlineMediaPickerMediaPicker(
currentModel = currentModel, currentModel = currentModel,
@ -60,7 +63,7 @@ fun InlineMediaPicker(
onClick = { onClick = {
onRemove() onRemove()
}, },
enabled = currentModel != null enabled = (currentModel != null) && enabled
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
@ -70,7 +73,7 @@ fun InlineMediaPicker(
} }
} }
} else { } else {
Column { Column(modifier) {
InlineMediaPickerMediaPicker( InlineMediaPickerMediaPicker(
currentModel = currentModel, currentModel = currentModel,
mimeType = mimeType, mimeType = mimeType,
@ -85,7 +88,7 @@ fun InlineMediaPicker(
onClick = { onClick = {
onRemove() onRemove()
}, },
enabled = currentModel != null, enabled = (currentModel != null) && enabled,
modifier = Modifier.width(480.dp) modifier = Modifier.width(480.dp)
) { ) {
Icon( Icon(
@ -111,6 +114,7 @@ fun InlineMediaPickerMediaPicker(
currentModel: Any?, currentModel: Any?,
mimeType: String = "image/*", mimeType: String = "image/*",
circular: Boolean = false, circular: Boolean = false,
enabled: Boolean = true,
onPick: (Uri) -> Unit onPick: (Uri) -> Unit
) { ) {
val documentsUiLauncher = rememberLauncherForActivityResult( val documentsUiLauncher = rememberLauncherForActivityResult(
@ -137,7 +141,7 @@ fun InlineMediaPickerMediaPicker(
.width(480.dp) .width(480.dp)
.height(140.dp) .height(140.dp)
}.clickable { }.clickable {
documentsUiLauncher.launch(mimeType) if (enabled) documentsUiLauncher.launch(mimeType)
}, },
transition = CrossFade, transition = CrossFade,
) )
@ -155,7 +159,7 @@ fun InlineMediaPickerMediaPicker(
.height(140.dp) .height(140.dp)
} }
.clickable { .clickable {
documentsUiLauncher.launch(mimeType) if (enabled) documentsUiLauncher.launch(mimeType)
} }
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)), .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center

View File

@ -0,0 +1,33 @@
package chat.revolt.internals.extensions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableLongState
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.Roles
@Composable
fun rememberChannelPermissions(channelId: String): MutableLongState {
val permissions = remember { mutableLongStateOf(0L) }
LaunchedEffect(channelId) {
if (RevoltAPI.selfId == null) return@LaunchedEffect
if (RevoltAPI.userCache[RevoltAPI.selfId] == null) return@LaunchedEffect
if (RevoltAPI.channelCache[channelId] == null) return@LaunchedEffect
val channel = RevoltAPI.channelCache[channelId]
val selfUser = RevoltAPI.userCache[RevoltAPI.selfId]
val member = channel?.let {
it.server?.let { server ->
RevoltAPI.selfId?.let { selfId ->
RevoltAPI.members.getMember(server, selfId)
}
}
}
channel?.let { permissions.longValue = Roles.permissionFor(it, selfUser, member) }
}
return permissions
}

View File

@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@ -72,6 +73,8 @@ import chat.revolt.api.routes.channel.react
import chat.revolt.api.routes.microservices.autumn.FileArgs import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.schemas.Channel import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.chat.Message import chat.revolt.components.chat.Message
import chat.revolt.components.chat.NativeMessageField import chat.revolt.components.chat.NativeMessageField
import chat.revolt.components.chat.SystemMessage import chat.revolt.components.chat.SystemMessage
@ -188,7 +191,7 @@ fun ChannelScreen(
} }
if (channelInfoSheetShown) { if (channelInfoSheetShown) {
val channelInfoSheetState = rememberModalBottomSheetState() val channelInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( ModalBottomSheet(
sheetState = channelInfoSheetState, sheetState = channelInfoSheetState,
@ -197,7 +200,11 @@ fun ChannelScreen(
} }
) { ) {
ChannelInfoSheet( ChannelInfoSheet(
channelId = channelId channelId = channelId,
onHideSheet = {
channelInfoSheetState.hide()
channelInfoSheetShown = false
}
) )
} }
} }

View File

@ -21,6 +21,7 @@ import chat.revolt.api.internals.SpecialUsers
import chat.revolt.api.internals.ULID import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.hasPermission import chat.revolt.api.internals.hasPermission
import chat.revolt.api.realtime.RealtimeSocketFrames import chat.revolt.api.realtime.RealtimeSocketFrames
import chat.revolt.api.realtime.frames.receivable.ChannelDeleteFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.MessageAppendFrame import chat.revolt.api.realtime.frames.receivable.MessageAppendFrame
@ -42,6 +43,8 @@ import chat.revolt.api.routes.server.fetchMember
import chat.revolt.api.routes.user.addUserIfUnknown import chat.revolt.api.routes.user.addUserIfUnknown
import chat.revolt.api.schemas.Channel import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.Message import chat.revolt.api.schemas.Message
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.callbacks.UiCallback import chat.revolt.callbacks.UiCallback
import chat.revolt.callbacks.UiCallbacks import chat.revolt.callbacks.UiCallbacks
import io.ktor.http.ContentType import io.ktor.http.ContentType
@ -425,6 +428,13 @@ class ChannelScreenViewModel : ViewModel() {
typingUsers.remove(it.user) typingUsers.remove(it.user)
} }
is ChannelDeleteFrame -> {
// activeChannelId is used deliberately because it doesn't become null when the channel is deleted.
if (it.id != activeChannelId) return@onEach
// FIXME This is UI logic from the view model. Too bad!
ActionChannel.send(Action.ChatNavigate("no_current_channel"))
}
is RealtimeSocketFrames.Reconnected -> { is RealtimeSocketFrames.Reconnected -> {
Log.d("ChannelScreen", "Reconnected to WS.") Log.d("ChannelScreen", "Reconnected to WS.")
listenForWsFrames() listenForWsFrames()

View File

@ -0,0 +1,210 @@
package chat.revolt.screens.settings.channel
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.PermissionBit
import chat.revolt.api.internals.hasPermission
import chat.revolt.api.routes.channel.leaveDeleteOrCloseChannel
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.settings.FeatureFlags
import chat.revolt.api.settings.LabsAccessControlVariates
import chat.revolt.internals.extensions.rememberChannelPermissions
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelSettingsHome(navController: NavController, channelId: String) {
val channel = RevoltAPI.channelCache[channelId]
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val permissions by rememberChannelPermissions(channelId)
var showDeletionConfirmation by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (showDeletionConfirmation) {
AlertDialog(
onDismissRequest = {
showDeletionConfirmation = false
},
title = { Text(stringResource(R.string.channel_settings_delete_confirm)) },
text = { Text(stringResource(R.string.channel_settings_delete_confirm_description)) },
dismissButton = {
TextButton(onClick = {
showDeletionConfirmation = false
}) {
Text(stringResource(R.string.channel_settings_delete_confirm_no))
}
},
confirmButton = {
Button(onClick = {
showDeletionConfirmation = false
scope.launch {
leaveDeleteOrCloseChannel(channelId)
navController.popBackStack()
}
}) {
Text(stringResource(R.string.channel_settings_delete_confirm_yes))
}
}
)
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
scrollBehavior = scrollBehavior,
title = {
Text(
text = channel?.let {
when (it.channelType) {
ChannelType.TextChannel, ChannelType.VoiceChannel -> stringResource(
R.string.channel_settings_header,
channel.name ?: channelId
)
else -> channel.name ?: stringResource(R.string.channel_settings)
}
} ?: stringResource(R.string.channel_settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = stringResource(id = R.string.back)
)
}
},
)
},
) { pv ->
Box(Modifier.padding(pv)) {
channel?.let {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
if (permissions.hasPermission(PermissionBit.ManageChannel)) {
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.channel_settings_overview)
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
)
},
modifier = Modifier
.testTag("channel_settings_view_overview")
.clickable {
navController.navigate("settings/channel/${channel.id}/overview")
}
)
}
// TODO Implement permissions UI and remove the predicate check
if (permissions.hasPermission(PermissionBit.ManageRole) && (FeatureFlags.labsAccessControl is LabsAccessControlVariates.Restricted && (FeatureFlags.labsAccessControl as LabsAccessControlVariates.Restricted).predicate())) {
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.channel_settings_permissions)
)
},
leadingContent = {
Icon(
painter = painterResource(id = R.drawable.ic_list_status_24dp),
contentDescription = null,
)
},
modifier = Modifier
.testTag("channel_settings_view_permissions")
.clickable {
navController.navigate("settings/channel/${channel.id}/permissions")
}
)
}
if (permissions.hasPermission(PermissionBit.ManageChannel) && channel.channelType != ChannelType.DirectMessage && channel.channelType != ChannelType.Group) {
ListItem(
headlineContent = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
Text(
text = stringResource(id = R.string.channel_settings_delete)
)
}
},
leadingContent = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
)
}
},
modifier = Modifier
.testTag("channel_settings_click_delete")
.clickable {
showDeletionConfirmation = true
}
)
}
}
} ?: run {
Box(
modifier = Modifier
.fillMaxSize()
) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
}
}

View File

@ -0,0 +1,455 @@
package chat.revolt.screens.settings.channel
import android.content.Context
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.input.nestedscroll.nestedScroll
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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.activities.RevoltTweenFloat
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.channel.patchChannel
import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
import chat.revolt.api.schemas.Channel
import chat.revolt.components.generic.InlineMediaPicker
import chat.revolt.components.generic.ListHeader
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.http.ContentType
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
@HiltViewModel
class ChannelSettingsOverviewViewModel @Inject constructor(@ApplicationContext val context: Context) :
ViewModel() {
var initialChannel by mutableStateOf<Channel?>(null)
var channelName by mutableStateOf("")
var channelDescription by mutableStateOf("")
var iconModel by mutableStateOf<Any?>(null)
var iconIsUploading by mutableStateOf(false)
var iconUploadProgress by mutableFloatStateOf(0f)
var uploadError by mutableStateOf<String?>(null)
var updateError by mutableStateOf<String?>(null)
var showNsfwToggleDialogue by mutableStateOf(false)
fun populateWithChannel(channelId: String) {
val channel = RevoltAPI.channelCache[channelId]
initialChannel = channel
channel?.let {
channelName = it.name ?: ""
channelDescription = it.description ?: ""
iconModel = it.icon?.let { icon -> "$REVOLT_FILES/icons/${icon.id}" }
}
}
private fun unsetIcon() {
iconIsUploading = true
iconUploadProgress = 0f
uploadError = null
initialChannel?.id?.let { channelId ->
viewModelScope.launch {
try {
patchChannel(channelId, remove = listOf("Icon"))
iconModel = null
} catch (e: Exception) {
updateError = e.message
}
iconIsUploading = false
}
} ?: run {
iconIsUploading = false
}
}
fun pickIcon(newModel: Any?) {
iconModel = newModel
uploadError = null
iconUploadProgress = 0f
val uri = when (newModel) {
is Uri -> newModel
is String -> Uri.parse(newModel)
else -> null
} ?: run {
unsetIcon()
return
}
val mFile = File(context.cacheDir, uri.lastPathSegment ?: "icon")
mFile.outputStream().use { output ->
context.contentResolver.openInputStream(uri)?.use { input ->
input.copyTo(output)
}
}
val mime = context.contentResolver.getType(uri)
if (mime?.endsWith("webp") == true) {
uploadError = "WebP is not supported"
return
}
viewModelScope.launch {
iconIsUploading = true
try {
val id = uploadToAutumn(
mFile,
uri.lastPathSegment ?: "icon",
"icons",
ContentType.parse(mime ?: "image/*"),
onProgress = { soFar, outOf ->
iconUploadProgress = soFar.toFloat() / outOf.toFloat()
}
)
patchChannel(initialChannel?.id ?: "", icon = id)
iconIsUploading = false
} catch (e: Exception) {
uploadError = e.message
iconUploadProgress = 0f
iconIsUploading = false
return@launch
}
}
}
fun updateChannel() {
updateError = null
viewModelScope.launch {
try {
patchChannel(
initialChannel?.id ?: "",
name = if (channelName != initialChannel?.name) channelName else null,
description = if (channelDescription != initialChannel?.description) channelDescription else null
)
initialChannel = initialChannel?.copy(
name = channelName,
description = channelDescription
)
} catch (e: Exception) {
updateError = e.message
}
}
}
fun toggleNsfw() {
updateError = null
viewModelScope.launch {
try {
patchChannel(
initialChannel?.id ?: "",
nsfw = !(initialChannel?.nsfw ?: false)
)
initialChannel = initialChannel?.copy(
nsfw = !(initialChannel?.nsfw ?: false)
)
} catch (e: Exception) {
updateError = e.message
}
showNsfwToggleDialogue = false
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelSettingsOverview(
navController: NavController,
channelId: String,
viewModel: ChannelSettingsOverviewViewModel = hiltViewModel()
) {
val currentChannel = RevoltAPI.channelCache[channelId]
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
LaunchedEffect(channelId) {
viewModel.populateWithChannel(channelId)
}
val channelInfoUpdated by remember(
currentChannel,
viewModel.channelName,
viewModel.channelDescription
) {
derivedStateOf {
currentChannel?.let { channel ->
(channel.name ?: "") != viewModel.channelName ||
(channel.description ?: "") != viewModel.channelDescription
} ?: false
}
}
if (viewModel.showNsfwToggleDialogue) {
AlertDialog(
onDismissRequest = {
viewModel.showNsfwToggleDialogue = false
},
title = {
if (currentChannel?.nsfw == true) {
Text(stringResource(R.string.channel_settings_overview_nsfw_undo_confirm_title))
} else {
Text(stringResource(R.string.channel_settings_overview_nsfw_confirm_title))
}
},
text = {
if (currentChannel?.nsfw == true) {
Text(stringResource(R.string.channel_settings_overview_nsfw_undo_confirm_description))
} else {
Text(stringResource(R.string.channel_settings_overview_nsfw_confirm_description))
}
},
confirmButton = {
Button(
onClick = {
viewModel.toggleNsfw()
}
) {
if (currentChannel?.nsfw == true) {
Text(stringResource(R.string.channel_settings_overview_nsfw_undo_confirm_yes))
} else {
Text(stringResource(R.string.channel_settings_overview_nsfw_confirm_yes))
}
}
},
dismissButton = {
TextButton(
onClick = {
viewModel.showNsfwToggleDialogue = false
}
) {
if (currentChannel?.nsfw == true) {
Text(stringResource(R.string.channel_settings_overview_nsfw_undo_confirm_no))
} else {
Text(stringResource(R.string.channel_settings_overview_nsfw_confirm_no))
}
}
}
)
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
scrollBehavior = scrollBehavior,
title = {
Text(
text = stringResource(R.string.channel_settings_overview),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = stringResource(id = R.string.back)
)
}
},
)
},
floatingActionButton = {
AnimatedVisibility(
visible = channelInfoUpdated,
enter = scaleIn(animationSpec = RevoltTweenFloat),
exit = scaleOut(animationSpec = RevoltTweenFloat)
) {
FloatingActionButton(onClick = { viewModel.updateChannel() }) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.channel_settings_overview_save)
)
}
}
}
) { pv ->
Box(
Modifier
.padding(pv)
.imePadding()
) {
currentChannel?.let {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
ListHeader {
Text(stringResource(R.string.channel_settings_overview_info))
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
InlineMediaPicker(
currentModel = viewModel.iconModel,
onPick = { viewModel.pickIcon(it) },
circular = true,
mimeType = "image/*",
canRemove = true,
enabled = !viewModel.iconIsUploading,
onRemove = { viewModel.pickIcon(null) },
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
AnimatedVisibility(visible = viewModel.iconIsUploading) {
LinearProgressIndicator(
progress = viewModel.iconUploadProgress,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
AnimatedVisibility(visible = viewModel.uploadError != null) {
Text(
viewModel.uploadError
?: stringResource(R.string.channel_settings_overview_update_info_error_fallback),
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
AnimatedVisibility(visible = viewModel.updateError != null) {
Text(
viewModel.updateError
?: stringResource(R.string.channel_settings_overview_update_info_error_fallback),
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
TextField(
label = {
Text(stringResource(R.string.channel_settings_overview_name))
},
value = viewModel.channelName,
onValueChange = { viewModel.channelName = it },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp),
singleLine = true
)
TextField(
label = {
Text(stringResource(R.string.channel_settings_overview_description))
},
placeholder = {
Text(stringResource(R.string.channel_settings_overview_description_hint))
},
value = viewModel.channelDescription,
onValueChange = { viewModel.channelDescription = it },
modifier = Modifier
.animateContentSize()
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp),
singleLine = false,
minLines = 3
)
ListHeader {
Text(stringResource(R.string.channel_settings_overview_nsfw))
}
Text(
stringResource(R.string.channel_settings_overview_nsfw_description),
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 16.dp),
)
OutlinedButton(
content = {
if (currentChannel.nsfw == true) {
Text(stringResource(R.string.channel_settings_overview_nsfw_undo_confirm_yes))
} else {
Text(stringResource(R.string.channel_settings_overview_nsfw_confirm_yes))
}
},
onClick = {
viewModel.showNsfwToggleDialogue = true
},
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 16.dp),
)
}
} ?: run {
Box(
modifier = Modifier
.fillMaxSize()
) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
}
}

View File

@ -0,0 +1,84 @@
package chat.revolt.screens.settings.channel
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelSettingsPermissions(navController: NavController, channelId: String) {
val channel = RevoltAPI.channelCache[channelId]
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
scrollBehavior = scrollBehavior,
title = {
Text(
text = stringResource(R.string.channel_settings_permissions),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = stringResource(id = R.string.back)
)
}
},
)
},
) { pv ->
Box(
Modifier
.padding(pv)
.imePadding()
) {
channel?.let {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Text(text = "Channel settings permissions")
}
} ?: run {
Box(
modifier = Modifier
.fillMaxSize()
) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
}
}

View File

@ -12,6 +12,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -23,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -36,16 +38,23 @@ import chat.revolt.api.internals.PermissionBit
import chat.revolt.api.internals.Roles import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.has import chat.revolt.api.internals.has
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.generic.SheetClickable import chat.revolt.components.generic.SheetClickable
import chat.revolt.components.screens.chat.ChannelSheetHeader import chat.revolt.components.screens.chat.ChannelSheetHeader
import chat.revolt.internals.extensions.rememberChannelPermissions
import chat.revolt.screens.chat.dialogs.InviteDialog import chat.revolt.screens.chat.dialogs.InviteDialog
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChannelInfoSheet(channelId: String) { fun ChannelInfoSheet(channelId: String, onHideSheet: suspend () -> Unit) {
val channel = RevoltAPI.channelCache[channelId] val channel = RevoltAPI.channelCache[channelId]
var memberListSheetShown by remember { mutableStateOf(false) } var memberListSheetShown by remember { mutableStateOf(false) }
var inviteDialogShown by remember { mutableStateOf(false) } var inviteDialogShown by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val permissions by rememberChannelPermissions(channelId)
if (memberListSheetShown) { if (memberListSheetShown) {
val memberListSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val memberListSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -231,6 +240,37 @@ fun ChannelInfoSheet(channelId: String) {
) { ) {
} }
if (
(permissions has PermissionBit.ManageChannel || permissions has PermissionBit.ManageRole)
&& (channel.channelType != ChannelType.SavedMessages && channel.channelType != ChannelType.DirectMessage)
) {
SheetClickable(
icon = { modifier ->
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(
id = R.string.settings
),
style = style
)
}
) {
scope.launch {
onHideSheet()
}
scope.launch {
delay(100) // wait for the sheet to close or at least start closing
ActionChannel.send(Action.TopNavigate("settings/channel/${channel.id}"))
}
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} }

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M16.5 11L13 7.5L14.4 6.1L16.5 8.2L20.7 4L22.1 5.4L16.5 11M11 7H2V9H11V7M21 13.4L19.6 12L17 14.6L14.4 12L13 13.4L15.6 16L13 18.6L14.4 20L17 17.4L19.6 20L21 18.6L18.4 16L21 13.4M11 15H2V17H11V15Z" />
</vector>

View File

@ -544,6 +544,32 @@
<string name="settings_changelogs_historical_version_header">Changelog for %1$s</string> <string name="settings_changelogs_historical_version_header">Changelog for %1$s</string>
<string name="settings_changelogs_historical_version_header_placeholder">that version</string> <string name="settings_changelogs_historical_version_header_placeholder">that version</string>
<string name="channel_settings">Channel Settings</string>
<string name="channel_settings_header">#%1$s</string>
<string name="channel_settings_overview">Overview</string>
<string name="channel_settings_overview_update_info_error_fallback">There was an error updating the channel.</string>
<string name="channel_settings_overview_info">Channel Info</string>
<string name="channel_settings_overview_name">Channel Name</string>
<string name="channel_settings_overview_description">Channel Description</string>
<string name="channel_settings_overview_description_hint">This channel is exclusively for conversation about…</string>
<string name="channel_settings_overview_nsfw">Mark as NSFW</string>
<string name="channel_settings_overview_nsfw_description">Users are asked to confirm their age before joining NSFW channels.</string>
<string name="channel_settings_overview_nsfw_confirm_title">Mark this channel as NSFW?</string>
<string name="channel_settings_overview_nsfw_confirm_description">Users will be asked to confirm their age before joining this channel.</string>
<string name="channel_settings_overview_nsfw_confirm_yes">Mark as NSFW</string>
<string name="channel_settings_overview_nsfw_confirm_no">Keep as is</string>
<string name="channel_settings_overview_nsfw_undo_confirm_title">Unmark this channel as NSFW?</string>
<string name="channel_settings_overview_nsfw_undo_confirm_description">Users will no longer be asked to confirm their age before joining this channel. Please ensure the content is appropriate for all ages.</string>
<string name="channel_settings_overview_nsfw_undo_confirm_yes">Unmark as NSFW</string>
<string name="channel_settings_overview_nsfw_undo_confirm_no">Keep as is</string>
<string name="channel_settings_overview_save">Save</string>
<string name="channel_settings_permissions">Permissions</string>
<string name="channel_settings_delete">Delete Channel</string>
<string name="channel_settings_delete_confirm">Are you sure you want to delete this channel?</string>
<string name="channel_settings_delete_confirm_description">All messages and settings for this channel will be gone forever (a long time!)</string>
<string name="channel_settings_delete_confirm_yes">Delete</string>
<string name="channel_settings_delete_confirm_no">Keep</string>
<string name="share_target_heading">Share to Revolt</string> <string name="share_target_heading">Share to Revolt</string>
<string name="share_target_login_first">Please log in before sharing to Revolt.</string> <string name="share_target_login_first">Please log in before sharing to Revolt.</string>
<string name="share_target_invalid_intent">This is not a valid share intent.</string> <string name="share_target_invalid_intent">This is not a valid share intent.</string>