diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt index bf6f749bd..2e480fb5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt @@ -21,24 +21,42 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity import android.content.Context +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +@Immutable +data class CommunityRelayEntry( + val url: NormalizedRelayUrl, + val marker: String? = null, +) + @Stable class NewCommunityModel : ViewModel() { var account: Account? = null @@ -47,15 +65,65 @@ class NewCommunityModel : ViewModel() { var name by mutableStateOf("") var description by mutableStateOf("") - var imageUrl by mutableStateOf("") var rules by mutableStateOf("") - var moderatorsText by mutableStateOf("") - var relayRequestsUrl by mutableStateOf("") - var relayApprovalsUrl by mutableStateOf("") + + // Image upload state - mirrors NewBadgeModel. + var multiOrchestrator by mutableStateOf(null) + var selectedServer by mutableStateOf(null) + + // 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED + var mediaQualitySlider by mutableIntStateOf(1) + var stripMetadata by mutableStateOf(true) + + val strippingFailureConfirmation = SuspendableConfirmation() + + // Moderator/Relay lists - backed by mutableStateListOf for direct Compose observation. + val moderators = mutableStateListOf() + val relays = mutableStateListOf() fun init(account: Account) { if (this.account == account) return this.account = account + this.selectedServer = defaultServer() + this.stripMetadata = account.settings.stripLocationOnUpload + } + + fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] + + fun setPickedMedia(uris: ImmutableList) { + this.multiOrchestrator = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } + + fun hasPickedImage(): Boolean = multiOrchestrator != null + + fun addModerator(user: User) { + if (moderators.none { it.pubkeyHex == user.pubkeyHex }) { + moderators.add(user) + } + } + + fun removeModerator(user: User) { + moderators.removeAll { it.pubkeyHex == user.pubkeyHex } + } + + fun addRelay(url: NormalizedRelayUrl) { + if (relays.none { it.url == url }) { + relays.add(CommunityRelayEntry(url)) + } + } + + fun removeRelay(entry: CommunityRelayEntry) { + relays.removeAll { it.url == entry.url } + } + + fun setRelayMarker( + entry: CommunityRelayEntry, + marker: String?, + ) { + val index = relays.indexOfFirst { it.url == entry.url } + if (index >= 0) { + relays[index] = entry.copy(marker = marker) + } } fun canPost(): Boolean = @@ -88,15 +156,33 @@ class NewCommunityModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { isPublishing = true try { - val moderatorTags = parseModeratorTags(myAccount) - val relayTags = parseRelayTags() + val imageUrl = + uploadImageIfAny(context, myAccount, onError) ?: run { + if (multiOrchestrator != null) { + // upload failed; error was reported, bail out + return@launch + } + null + } + + val ownerKey = myAccount.signer.pubKey + val moderatorTags = + buildList { + add(ModeratorTag(ownerKey, null, "moderator")) + moderators + .asSequence() + .filter { it.pubkeyHex != ownerKey } + .forEach { add(ModeratorTag(it.pubkeyHex, null, "moderator")) } + } + + val relayTags = relays.map { RelayTag(it.url, it.marker) } val definition = myAccount.sendCommunityDefinition( name = name.trim(), description = description.trim(), moderators = moderatorTags, - image = imageUrl.trim().ifBlank { null }, + image = imageUrl, rules = rules.trim().ifBlank { null }, relays = relayTags.ifEmpty { null }, dTag = Uuid.random().toString(), @@ -110,10 +196,13 @@ class NewCommunityModel : ViewModel() { return@launch } - // Also follow the community so it appears on the user's list. + // Follow it so it shows up under "Mine". val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address()) myAccount.follow(communityNote) + selectedServer?.let { myAccount.settings.changeDefaultFileServer(it) } + myAccount.settings.changeStripLocationOnUpload(stripMetadata) + reset() onSuccess() } finally { @@ -122,42 +211,58 @@ class NewCommunityModel : ViewModel() { } } - private fun parseModeratorTags(account: Account): List { - val hexes = - moderatorsText - .lineSequence() - .map { it.trim() } - .filter { it.length == 64 && it.all { ch -> ch.isDigit() || (ch in 'a'..'f') || (ch in 'A'..'F') } } - .map { it.lowercase() } - .toSet() + private suspend fun uploadImageIfAny( + context: Context, + myAccount: Account, + onError: (String, String) -> Unit, + ): String? { + val orch = multiOrchestrator ?: return null + val serverToUse = selectedServer ?: defaultServer() - val withOwner = hexes + account.signer.pubKey + val results = + orch.upload( + alt = name.trim().ifBlank { "Community cover" }, + contentWarningReason = null, + mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider), + server = serverToUse, + account = myAccount, + context = context, + useH265 = false, + stripMetadata = stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) - return withOwner.map { ModeratorTag(it, null, "moderator") } - } - - private fun parseRelayTags(): List { - val tags = mutableListOf() - relayRequestsUrl.trim().takeIf { it.isNotEmpty() }?.let { - RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> - tags += RelayTag(url, RelayTag.MARKER_REQUESTS) - } + if (!results.allGood) { + val messages = + results.errors + .map { stringRes(context, it.errorResource, *it.params) } + .distinct() + .joinToString(".\n") + onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages) + return null } - relayApprovalsUrl.trim().takeIf { it.isNotEmpty() }?.let { - RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> - tags += RelayTag(url, RelayTag.MARKER_APPROVALS) + + val uploaded = + results.successful.firstNotNullOfOrNull { + it.result as? UploadOrchestrator.OrchestratorResult.ServerResult + } ?: run { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "Upload succeeded but no image URL was returned by the server.", + ) + return null } - } - return tags + + return uploaded.url } fun reset() { name = "" description = "" - imageUrl = "" rules = "" - moderatorsText = "" - relayRequestsUrl = "" - relayApprovalsUrl = "" + multiOrchestrator = null + moderators.clear() + relays.clear() + selectedServer = defaultServer() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt index fe4055a4a..c68d871d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -20,35 +20,77 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import kotlinx.collections.immutable.persistentListOf @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -57,117 +99,440 @@ fun NewCommunityScreen( nav: INav, ) { val model: NewCommunityModel = viewModel() + val context = LocalContext.current LaunchedEffect(accountViewModel.account) { model.init(accountViewModel.account) } - val context = LocalContext.current - var errorTitle by remember { mutableStateOf(null) } - var errorBody by remember { mutableStateOf(null) } + StrippingFailureDialog(model.strippingFailureConfirmation) + + var wantsToPickImage by remember { mutableStateOf(false) } + + if (wantsToPickImage) { + GallerySelect( + onImageUri = { uris -> + wantsToPickImage = false + model.setPickedMedia( + if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(), + ) + }, + ) + } Scaffold( topBar = { - TopBarWithBackButton( - caption = stringRes(R.string.new_community), - popBack = { nav.popBack() }, - ) - }, - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 16.dp, vertical = 12.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedTextField( - value = model.name, - onValueChange = { model.name = it }, - label = { Text(stringRes(R.string.new_community_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.description, - onValueChange = { model.description = it }, - label = { Text(stringRes(R.string.new_community_description)) }, - minLines = 3, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.imageUrl, - onValueChange = { model.imageUrl = it }, - label = { Text(stringRes(R.string.new_community_image_url)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.rules, - onValueChange = { model.rules = it }, - label = { Text(stringRes(R.string.new_community_rules)) }, - minLines = 3, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.moderatorsText, - onValueChange = { model.moderatorsText = it }, - label = { Text(stringRes(R.string.new_community_moderators)) }, - minLines = 2, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.relayRequestsUrl, - onValueChange = { model.relayRequestsUrl = it }, - label = { Text(stringRes(R.string.new_community_relay_requests)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.relayApprovalsUrl, - onValueChange = { model.relayApprovalsUrl = it }, - label = { Text(stringRes(R.string.new_community_relay_approvals)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - errorTitle?.let { title -> - Text( - text = title, - fontWeight = FontWeight.Bold, - ) - errorBody?.let { body -> - Text(text = body) - } - } - - Button( - onClick = { - errorTitle = null - errorBody = null + CreatingTopBar( + titleRes = R.string.new_community, + isActive = model::canPost, + onCancel = { + model.reset() + nav.popBack() + }, + onPost = { model.publish( context = context, onSuccess = { nav.popBack() }, - onError = { title, body -> - errorTitle = title - errorBody = body - }, + onError = accountViewModel.toastManager::toast, ) }, - enabled = model.canPost(), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp), - modifier = Modifier.fillMaxWidth(), + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text(text = stringRes(R.string.new_community_publish)) + CommunityImagePicker( + model = model, + accountViewModel = accountViewModel, + onPickImage = { wantsToPickImage = true }, + ) + + OutlinedTextField( + value = model.name, + onValueChange = { model.name = it }, + label = { Text(stringRes(R.string.new_community_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + OutlinedTextField( + value = model.description, + onValueChange = { model.description = it }, + label = { Text(stringRes(R.string.new_community_description)) }, + minLines = 3, + maxLines = 8, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + OutlinedTextField( + value = model.rules, + onValueChange = { model.rules = it }, + label = { Text(stringRes(R.string.new_community_rules)) }, + minLines = 2, + maxLines = 8, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + HorizontalDivider() + + SectionHeader(R.string.new_community_moderators_section) + ModeratorsSection( + model = model, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider() + + SectionHeader(R.string.new_community_relays_section) + RelaysSection( + model = model, + accountViewModel = accountViewModel, + nav = nav, + ) } } } } + +@Composable +private fun SectionHeader(resourceId: Int) { + Text( + text = stringRes(resourceId), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) +} + +@Composable +private fun CommunityImagePicker( + model: NewCommunityModel, + accountViewModel: AccountViewModel, + onPickImage: () -> Unit, +) { + if (model.hasPickedImage()) { + model.multiOrchestrator?.let { + Box(modifier = Modifier.clickable(onClick = onPickImage)) { + ShowImageUploadGallery( + list = it, + onDelete = { model.setPickedMedia(persistentListOf()) }, + accountViewModel = accountViewModel, + ) + } + } + } else { + CommunityImagePlaceholder(onClick = onPickImage) + } +} + +@Composable +private fun CommunityImagePlaceholder(onClick: () -> Unit) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(16.dp), + ).clickable(onClick = onClick) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringRes(R.string.new_community_pick_cover), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(R.string.new_community_pick_cover_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +// --- Moderators -------------------------------------------------------------------------------- + +@Composable +private fun ModeratorsSection( + model: NewCommunityModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + var search by remember { mutableStateOf("") } + + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + DisposableEffect(Unit) { + onDispose { userSuggestions.reset() } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringRes(R.string.new_community_moderators_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Current user is always a moderator (creator) + SelectedModeratorRow( + user = accountViewModel.account.userProfile(), + accountViewModel = accountViewModel, + nav = nav, + isOwner = true, + onRemove = null, + ) + + model.moderators.toList().forEach { user -> + SelectedModeratorRow( + user = user, + accountViewModel = accountViewModel, + nav = nav, + isOwner = false, + onRemove = { model.removeModerator(user) }, + ) + } + + OutlinedTextField( + value = search, + onValueChange = { + search = it + if (it.length > 1) { + userSuggestions.processCurrentWord(it) + } else { + userSuggestions.reset() + } + }, + label = { Text(stringRes(R.string.new_community_add_moderator)) }, + placeholder = { Text(stringRes(R.string.new_community_add_moderator_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + if (search.length > 1) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (user.pubkeyHex != accountViewModel.account.userProfile().pubkeyHex) { + model.addModerator(user) + } + search = "" + userSuggestions.reset() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightPage, + ) + } + } +} + +@Composable +private fun SelectedModeratorRow( + user: User, + accountViewModel: AccountViewModel, + nav: INav, + isOwner: Boolean, + onRemove: (() -> Unit)?, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = user.pubkeyHex, + size = 40.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Column( + modifier = Modifier.weight(1f).padding(start = 12.dp), + ) { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ModeratorSecondaryLine(user) + } + + if (isOwner) { + AssistChip( + onClick = {}, + label = { Text(stringRes(R.string.new_community_owner)) }, + enabled = false, + colors = + AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.primaryContainer, + disabledLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } else if (onRemove != null) { + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringRes(R.string.remove), + ) + } + } + } +} + +@Composable +private fun ModeratorSecondaryLine(user: User) { + val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle() + + val text = + when (val state = nip05StateMetadata) { + is Nip05State.Exists -> { + val name = state.nip05.name + if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}" + } + + else -> { + user.pubkeyDisplayHex() + } + } + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +// --- Relays ------------------------------------------------------------------------------------ + +@Composable +private fun RelaysSection( + model: NewCommunityModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringRes(R.string.new_community_relays_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + model.relays.toList().forEach { entry -> + val info = remember(entry.url) { relaySetupInfoBuilder(entry.url) } + + Column { + BasicRelaySetupInfoClickableRow( + item = info, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + onClick = {}, + onDelete = { model.removeRelay(entry) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, + accountViewModel = accountViewModel, + nav = nav, + ) + + RelayMarkerChips( + current = entry.marker, + onSelect = { model.setRelayMarker(entry, it) }, + ) + } + } + + RelayUrlEditField( + onNewRelay = { model.addRelay(it) }, + modifier = Modifier.fillMaxWidth(), + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun RelayMarkerChips( + current: String?, + onSelect: (String?) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 56.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_none), + selected = current == null, + onClick = { onSelect(null) }, + ) + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_author), + selected = current == RelayTag.MARKER_AUTHOR, + onClick = { onSelect(RelayTag.MARKER_AUTHOR) }, + ) + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_requests), + selected = current == RelayTag.MARKER_REQUESTS, + onClick = { onSelect(RelayTag.MARKER_REQUESTS) }, + ) + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_approvals), + selected = current == RelayTag.MARKER_APPROVALS, + onClick = { onSelect(RelayTag.MARKER_APPROVALS) }, + ) + } +} + +@Composable +private fun RelayMarkerOption( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text(label, style = MaterialTheme.typography.labelSmall) }, + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6cc43d0fc..7474e1f40 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -424,12 +424,20 @@ New Community Community name Description - Cover image URL (optional) Rules (optional) - Moderators (one pubkey per line, optional) - Relay for requests (optional) - Relay for approvals (optional) - Publish + Add a cover image + Tap to pick a photo — it will be uploaded to your media server. + Moderators + Moderators can approve posts. You are always a moderator. + Add a moderator + Name, npub, or NIP-05 + Owner + Relays + Pick relays that will host requests, approvals, or the community author\'s metadata. + Any + Author + Requests + Approvals No communities match this filter. Received Mine