diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt new file mode 100644 index 000000000..1b8f0e284 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerEditField.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.mediaServers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun MediaServerEditField( + modifier: Modifier = Modifier, + onAddServer: (String) -> Unit, +) { + var url by remember { mutableStateOf("") } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = + Arrangement.spacedBy( + Size10dp, + ), + ) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.add_a_relay)) }, + modifier = Modifier.weight(1f), + value = url, + onValueChange = { url = it }, + placeholder = { + Text( + text = "server.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + ) + + Button( + onClick = { + if (url.isNotBlank() && url != "/") { + onAddServer(url) + url = "" + } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (url.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + ) { + Text(text = stringRes(id = R.string.add), color = Color.White) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersLIstView.kt new file mode 100644 index 000000000..0cc597e90 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersLIstView.kt @@ -0,0 +1,287 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.mediaServers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.Nip96MediaServers +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.actions.SaveButton +import com.vitorpamplona.amethyst.ui.actions.relays.SettingsCategoryWithButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.grayText + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaServersListView( + onClose: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val mediaServersViewModel: MediaServersViewModel = viewModel() + val mediaServersState by mediaServersViewModel.fileServers.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { + mediaServersViewModel.load(accountViewModel.account) + } + + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(id = R.string.media_servers), + style = MaterialTheme.typography.titleLarge, + ) + } + }, + navigationIcon = { + CloseButton( + onPress = { + mediaServersViewModel.refresh() + onClose() + }, + ) + }, + actions = { + SaveButton( + onPost = { + mediaServersViewModel.saveFileServers() + onClose() + }, + isActive = true, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + start = 16.dp, + top = padding.calculateTopPadding(), + end = 16.dp, + bottom = padding.calculateBottomPadding(), + ), + verticalArrangement = Arrangement.spacedBy(5.dp, alignment = Alignment.Top), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringRes(id = R.string.set_preferred_media_servers), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.grayText, + ) + + LazyColumn( + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = FeedPadding, + ) { + renderMediaServerList( + mediaServersState = mediaServersState, + onAddServer = { server -> + mediaServersViewModel.addServer(server) + }, + onDeleteServer = { + mediaServersViewModel.removeServer(serverUrl = it) + }, + ) + + Nip96MediaServers.DEFAULT.let { + item { + SettingsCategoryWithButton( + title = stringRes(id = R.string.built_in_media_servers_title), + description = stringRes(id = R.string.built_in_servers_description), + action = { + OutlinedButton( + onClick = { + mediaServersViewModel.addServerList(it.map { s -> s.baseUrl }) + }, + ) { + Text(text = stringRes(id = R.string.use_default_servers)) + } + }, + ) + } + itemsIndexed( + it, + key = { index: Int, server: Nip96MediaServers.ServerName -> + server.baseUrl + }, + ) { index, server -> + MediaServerEntry( + serverEntry = server, + isAmethystDefault = true, + onAddOrDelete = { serverUrl -> + mediaServersViewModel.addServer(serverUrl) + }, + ) + } + } + } + } + } + } +} + +fun LazyListScope.renderMediaServerList( + mediaServersState: List, + onAddServer: (String) -> Unit, + onDeleteServer: (String) -> Unit, +) { + if (mediaServersState.isEmpty()) { + item { + Text( + text = stringRes(id = R.string.no_media_server_message), + modifier = DoubleVertPadding, + ) + } + } else { + itemsIndexed( + mediaServersState, + key = { index: Int, server: Nip96MediaServers.ServerName -> + server.baseUrl + }, + ) { index, entry -> + MediaServerEntry( + serverEntry = entry, + onAddOrDelete = { + onDeleteServer(it) + }, + ) + } + } + + item { + Spacer(modifier = StdVertSpacer) + MediaServerEditField { + onAddServer(it) + } + } +} + +@Composable +fun MediaServerEntry( + modifier: Modifier = Modifier, + serverEntry: Nip96MediaServers.ServerName, + isAmethystDefault: Boolean = false, + onAddOrDelete: (serverUrl: String) -> Unit, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround, + ) { + Column( + modifier = + Modifier + .weight(1f), + ) { + serverEntry.let { + Text( + text = it.name.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = StdVertSpacer) + Text( + text = it.baseUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + } + + Row( + horizontalArrangement = Arrangement.End, + ) { + IconButton( + onClick = { + onAddOrDelete(serverEntry.baseUrl) + }, + ) { + Icon( + imageVector = if (isAmethystDefault) Icons.Rounded.Add else Icons.Rounded.Delete, + contentDescription = + if (isAmethystDefault) { + stringRes(id = R.string.add_media_server) + } else { + stringRes(id = R.string.delete_media_server) + }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersViewModel.kt new file mode 100644 index 000000000..9805e0d79 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServersViewModel.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.mediaServers + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.Nip96MediaServers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.czeal.rfc3986.URIReference + +class MediaServersViewModel : ViewModel() { + lateinit var account: Account + + private val _fileServers = MutableStateFlow>(emptyList()) + val fileServers = _fileServers.asStateFlow() + private var isModified = false + + fun load(account: Account) { + this.account = account + refresh() + } + + fun refresh() { + isModified = false + _fileServers.update { + val obtainedFileServers = obtainFileServers() ?: emptyList() + obtainedFileServers.map { serverUrl -> + Nip96MediaServers + .ServerName( + URIReference.parse(serverUrl).host.value, + serverUrl, + ) + } + } + } + + fun addServerList(serverList: List) { + serverList.forEach { serverUrl -> + addServer(serverUrl) + } + } + + fun addServer(serverUrl: String) { + val normalizedUrl = + try { + URIReference.parse(serverUrl.trim()).normalize().toString() + } catch (e: Exception) { + serverUrl + } + val serverNameReference = + try { + URIReference.parse(normalizedUrl).host.value + } catch (e: Exception) { + normalizedUrl + } + val serverRef = Nip96MediaServers.ServerName(serverNameReference, normalizedUrl) + if (_fileServers.value.contains(serverRef)) { + return + } else { + _fileServers.update { + it.plus(serverRef) + } + } + isModified = true + } + + fun removeServer( + name: String = "", + serverUrl: String, + ) { + viewModelScope.launch { + val serverName = if (name.isNotBlank()) name else URIReference.parse(serverUrl).host.value + _fileServers.update { + it.minus( + Nip96MediaServers.ServerName(serverName, serverUrl), + ) + } + isModified = true + } + } + + fun removeAllServers() { + _fileServers.update { emptyList() } + isModified = true + } + + fun saveFileServers() { + if (isModified) { + viewModelScope.launch(Dispatchers.IO) { + val serverList = _fileServers.value.map { it.baseUrl } + account.sendFileServersList(serverList) + refresh() + } + } + } + + private fun obtainFileServers(): List? = account.getFileServersList()?.servers() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 1206b5696..3e0ea5087 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServersListView import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView import com.vitorpamplona.amethyst.ui.components.ClickableText import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @@ -449,6 +450,7 @@ fun ListContent( val coroutineScope = rememberCoroutineScope() var wantsToEditRelays by remember { mutableStateOf(false) } + var editMediaServers by remember { mutableStateOf(false) } var backupDialogOpen by remember { mutableStateOf(false) } var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) } @@ -493,6 +495,16 @@ fun ListContent( }, ) + IconRow( + title = "Media Servers", + icon = androidx.media3.ui.R.drawable.exo_icon_repeat_all, + tint = MaterialTheme.colorScheme.onBackground, + onClick = { + coroutineScope.launch { drawerState.close() } + editMediaServers = true + }, + ) + NavigationRow( title = stringRes(R.string.security_filters), icon = Route.BlockedUsers.icon, @@ -559,6 +571,9 @@ fun ListContent( if (wantsToEditRelays) { AllRelayListView({ wantsToEditRelays = false }, accountViewModel = accountViewModel, nav = nav) } + if (editMediaServers) { + MediaServersListView({ editMediaServers = false }, accountViewModel = accountViewModel, nav = nav) + } if (backupDialogOpen) { AccountBackupDialog(accountViewModel, onClose = { backupDialogOpen = false }) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 954c525c8..5be0b6d44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -122,6 +122,9 @@ val HalfVertPadding = Modifier.padding(vertical = 5.dp) val HorzPadding = Modifier.padding(horizontal = 10.dp) val VertPadding = Modifier.padding(vertical = 10.dp) +val DoubleHorzPadding = Modifier.padding(horizontal = 20.dp) +val DoubleVertPadding = Modifier.padding(vertical = 20.dp) + val MaxWidthWithHorzPadding = Modifier.fillMaxWidth().padding(horizontal = 10.dp) val Size5Modifier = Modifier.size(5.dp) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ebf7df349..fbee910d1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -379,6 +379,16 @@ LnAddress or @User + Media Servers + Set your preferred media upload servers. + You have no custom media servers set. You can use Amethyst\'s list, or add one below ↓ + Built-in Media Servers + Amethyst\'s default list. You can add them individually or add the list. + Use Default List + Add media server + Delete media server + + Your relays (NIP-95) Files are hosted by your relays. New NIP: check if they support