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.SessionSettingsScreen
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 com.google.android.material.color.DynamicColors
import dagger.hilt.android.AndroidEntryPoint
@ -377,6 +380,19 @@ fun AppEntrypoint(
composable("settings/debug") { DebugSettingsScreen(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/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.BulkFrame
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.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelUpdateFrame
@ -403,6 +404,45 @@ object RealtimeSocket {
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" -> {
val channelAckFrame =
RevoltJson.decodeFromString(ChannelAckFrame.serializer(), rawFrame)

View File

@ -1,5 +1,7 @@
package chat.revolt.api.routes.channel
import android.util.Log
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltError
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
@ -21,6 +23,9 @@ import io.ktor.http.contentType
import kotlinx.serialization.SerialName
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.JsonElement
suspend fun fetchMessagesFromChannel(
channelId: String,
@ -180,4 +185,73 @@ suspend fun fetchSingleMessage(channelId: String, messageId: String): Message {
Message.serializer(),
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.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import java.io.File
const val MAX_ATTACHMENTS_PER_MESSAGE = 5
@ -62,9 +63,12 @@ suspend fun uploadToAutumn(
val error = RevoltJson.decodeFromString(AutumnError.serializer(), response.bodyAsText())
throw Exception(error.type)
} catch (e: Exception) {
if (response.status.value == 429) {
if (response.status == HttpStatusCode.TooManyRequests) {
throw Exception("Rate limited")
}
if (response.status == HttpStatusCode.PayloadTooLarge) {
throw Exception("File too large")
}
throw Exception("Unknown error")
}
}

View File

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

View File

@ -36,15 +36,18 @@ import com.bumptech.glide.integration.compose.GlideImage
@Composable
fun InlineMediaPicker(
currentModel: Any?,
modifier: Modifier = Modifier,
mimeType: String = "image/*",
circular: Boolean = false,
onPick: (Uri) -> Unit,
canRemove: Boolean = true,
onRemove: () -> Unit = {}
onRemove: () -> Unit = {},
enabled: Boolean = true
) {
if (circular) {
Row(
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
InlineMediaPickerMediaPicker(
currentModel = currentModel,
@ -60,7 +63,7 @@ fun InlineMediaPicker(
onClick = {
onRemove()
},
enabled = currentModel != null
enabled = (currentModel != null) && enabled
) {
Icon(
imageVector = Icons.Default.Close,
@ -70,7 +73,7 @@ fun InlineMediaPicker(
}
}
} else {
Column {
Column(modifier) {
InlineMediaPickerMediaPicker(
currentModel = currentModel,
mimeType = mimeType,
@ -85,7 +88,7 @@ fun InlineMediaPicker(
onClick = {
onRemove()
},
enabled = currentModel != null,
enabled = (currentModel != null) && enabled,
modifier = Modifier.width(480.dp)
) {
Icon(
@ -111,6 +114,7 @@ fun InlineMediaPickerMediaPicker(
currentModel: Any?,
mimeType: String = "image/*",
circular: Boolean = false,
enabled: Boolean = true,
onPick: (Uri) -> Unit
) {
val documentsUiLauncher = rememberLauncherForActivityResult(
@ -137,7 +141,7 @@ fun InlineMediaPickerMediaPicker(
.width(480.dp)
.height(140.dp)
}.clickable {
documentsUiLauncher.launch(mimeType)
if (enabled) documentsUiLauncher.launch(mimeType)
},
transition = CrossFade,
)
@ -155,7 +159,7 @@ fun InlineMediaPickerMediaPicker(
.height(140.dp)
}
.clickable {
documentsUiLauncher.launch(mimeType)
if (enabled) documentsUiLauncher.launch(mimeType)
}
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)),
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.Environment
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.BackHandler
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.schemas.Channel
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.NativeMessageField
import chat.revolt.components.chat.SystemMessage
@ -188,7 +191,7 @@ fun ChannelScreen(
}
if (channelInfoSheetShown) {
val channelInfoSheetState = rememberModalBottomSheetState()
val channelInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = channelInfoSheetState,
@ -197,7 +200,11 @@ fun ChannelScreen(
}
) {
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.hasPermission
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.ChannelStopTypingFrame
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.schemas.Channel
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.UiCallbacks
import io.ktor.http.ContentType
@ -425,6 +428,13 @@ class ChannelScreenViewModel : ViewModel() {
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 -> {
Log.d("ChannelScreen", "Reconnected to WS.")
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.List
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -23,6 +24,7 @@ import androidx.compose.runtime.Composable
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
@ -36,16 +38,23 @@ import chat.revolt.api.internals.PermissionBit
import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.has
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.screens.chat.ChannelSheetHeader
import chat.revolt.internals.extensions.rememberChannelPermissions
import chat.revolt.screens.chat.dialogs.InviteDialog
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelInfoSheet(channelId: String) {
fun ChannelInfoSheet(channelId: String, onHideSheet: suspend () -> Unit) {
val channel = RevoltAPI.channelCache[channelId]
var memberListSheetShown by remember { mutableStateOf(false) }
var inviteDialogShown by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val permissions by rememberChannelPermissions(channelId)
if (memberListSheetShown) {
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))
}
}

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_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_login_first">Please log in before sharing to Revolt.</string>
<string name="share_target_invalid_intent">This is not a valid share intent.</string>