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.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) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.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
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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.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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue