From 0f8498187966016726276bec8ebc861a33e2dd8d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:28:09 +0000 Subject: [PATCH] feat(communities): redesign New Community screen - Large tap-to-pick cover image at the top (Badge-style): shows an aspect-16:9 placeholder that opens the gallery, uploads to the user's chosen media server, and previews via ShowImageUploadGallery. - Moderators section: lists the current user as pinned "Owner" plus each added moderator (picture + display name + NIP-05) with a remove icon. The add field is powered by ShowUserSuggestionList so names, npubs and NIP-05s all search live. - Relays section: each row uses BasicRelaySetupInfoClickableRow so users see the full relay stats (icon, users, event counts, status) just like the AllRelayList screen. Below each row a FilterChip group sets the NIP-72 marker (any / author / requests / approvals). A RelayUrlEditField at the bottom adds new relays with live search suggestions. - Reworked NewCommunityModel: dedicated mutableStateListOf for moderators and relays, proper image upload via MultiOrchestrator, CommunityRelayEntry data class, publish() now uploads then signs kind 34550 and follows it (so the new community appears in "Mine"). - Updated strings and localized resources. --- .../newCommunity/NewCommunityModel.kt | 177 ++++-- .../newCommunity/NewCommunityScreen.kt | 563 +++++++++++++++--- amethyst/src/main/res/values/strings.xml | 18 +- 3 files changed, 618 insertions(+), 140 deletions(-) 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