feat: group settings screen
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
a5739f0f58
commit
a63f142bbc
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue