diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index a0caf671..c06d0da3 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -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) } diff --git a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt index 4c34bc98..6bed64f2 100644 --- a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt @@ -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) diff --git a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt index e2608c9d..9143389b 100644 --- a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt +++ b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt @@ -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? = null, + nsfw: Boolean? = null, + pure: Boolean = false +) { + val body = mutableMapOf() + + 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 + } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/routes/microservices/autumn/Autumn.kt b/app/src/main/java/chat/revolt/api/routes/microservices/autumn/Autumn.kt index 46079c37..3b048389 100644 --- a/app/src/main/java/chat/revolt/api/routes/microservices/autumn/Autumn.kt +++ b/app/src/main/java/chat/revolt/api/routes/microservices/autumn/Autumn.kt @@ -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") } } diff --git a/app/src/main/java/chat/revolt/api/schemas/Server.kt b/app/src/main/java/chat/revolt/api/schemas/Server.kt index 043fe750..b97bd8d2 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Server.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Server.kt @@ -12,8 +12,10 @@ data class Server( val description: String? = null, val channels: List? = null, val categories: List? = null, + @SerialName("system_messages") val systemMessages: SystemMessages? = null, val roles: Map? = null, + @SerialName("default_permissions") val defaultPermissions: Long? = null, val icon: AutumnResource? = null, val banner: AutumnResource? = null, diff --git a/app/src/main/java/chat/revolt/components/generic/InlineMediaPicker.kt b/app/src/main/java/chat/revolt/components/generic/InlineMediaPicker.kt index 15f97ada..8336b15a 100644 --- a/app/src/main/java/chat/revolt/components/generic/InlineMediaPicker.kt +++ b/app/src/main/java/chat/revolt/components/generic/InlineMediaPicker.kt @@ -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 diff --git a/app/src/main/java/chat/revolt/internals/extensions/Permissions.kt b/app/src/main/java/chat/revolt/internals/extensions/Permissions.kt new file mode 100644 index 00000000..c94e59cd --- /dev/null +++ b/app/src/main/java/chat/revolt/internals/extensions/Permissions.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index f6107498..3f60030b 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -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 + } ) } } diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt index e977331d..917cda9f 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -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() diff --git a/app/src/main/java/chat/revolt/screens/settings/channel/ChannelSettingsHome.kt b/app/src/main/java/chat/revolt/screens/settings/channel/ChannelSettingsHome.kt new file mode 100644 index 00000000..4cc452c6 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/settings/channel/ChannelSettingsHome.kt @@ -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)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/settings/channel/ChannelSettingsOverview.kt b/app/src/main/java/chat/revolt/screens/settings/channel/ChannelSettingsOverview.kt new file mode 100644 index 00000000..e625654a --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/settings/channel/ChannelSettingsOverview.kt @@ -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(null) + + var channelName by mutableStateOf("") + var channelDescription by mutableStateOf("") + + var iconModel by mutableStateOf(null) + var iconIsUploading by mutableStateOf(false) + var iconUploadProgress by mutableFloatStateOf(0f) + + var uploadError by mutableStateOf(null) + var updateError by mutableStateOf(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)) + } + } + } + } +} diff --git a/app/src/main/java/chat/revolt/screens/settings/channel/ChannelSettingsPermissions.kt b/app/src/main/java/chat/revolt/screens/settings/channel/ChannelSettingsPermissions.kt new file mode 100644 index 00000000..5846cdbe --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/settings/channel/ChannelSettingsPermissions.kt @@ -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)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt b/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt index a0c4c361..1dc27417 100644 --- a/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt +++ b/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt @@ -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)) } } diff --git a/app/src/main/res/drawable/ic_list_status_24dp.xml b/app/src/main/res/drawable/ic_list_status_24dp.xml new file mode 100644 index 00000000..40934f7a --- /dev/null +++ b/app/src/main/res/drawable/ic_list_status_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f074a5bf..6612c98d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -544,6 +544,32 @@ Changelog for %1$s that version + Channel Settings + #%1$s + Overview + There was an error updating the channel. + Channel Info + Channel Name + Channel Description + This channel is exclusively for conversation about… + Mark as NSFW + Users are asked to confirm their age before joining NSFW channels. + Mark this channel as NSFW? + Users will be asked to confirm their age before joining this channel. + Mark as NSFW + Keep as is + Unmark this channel as NSFW? + Users will no longer be asked to confirm their age before joining this channel. Please ensure the content is appropriate for all ages. + Unmark as NSFW + Keep as is + Save + Permissions + Delete Channel + Are you sure you want to delete this channel? + All messages and settings for this channel will be gone forever (a long time!) + Delete + Keep + Share to Revolt Please log in before sharing to Revolt. This is not a valid share intent.