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.
This commit is contained in:
Claude
2026-04-20 20:28:09 +00:00
parent 30a78c5922
commit 0f84981879
3 changed files with 618 additions and 140 deletions
@@ -21,24 +21,42 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity
import android.content.Context import android.content.Context
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account 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.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.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@Immutable
data class CommunityRelayEntry(
val url: NormalizedRelayUrl,
val marker: String? = null,
)
@Stable @Stable
class NewCommunityModel : ViewModel() { class NewCommunityModel : ViewModel() {
var account: Account? = null var account: Account? = null
@@ -47,15 +65,65 @@ class NewCommunityModel : ViewModel() {
var name by mutableStateOf("") var name by mutableStateOf("")
var description by mutableStateOf("") var description by mutableStateOf("")
var imageUrl by mutableStateOf("")
var rules by mutableStateOf("") var rules by mutableStateOf("")
var moderatorsText by mutableStateOf("")
var relayRequestsUrl by mutableStateOf("") // Image upload state - mirrors NewBadgeModel.
var relayApprovalsUrl by mutableStateOf("") var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
var selectedServer by mutableStateOf<ServerName?>(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<User>()
val relays = mutableStateListOf<CommunityRelayEntry>()
fun init(account: Account) { fun init(account: Account) {
if (this.account == account) return if (this.account == account) return
this.account = account this.account = account
this.selectedServer = defaultServer()
this.stripMetadata = account.settings.stripLocationOnUpload
}
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
fun setPickedMedia(uris: ImmutableList<SelectedMedia>) {
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 = fun canPost(): Boolean =
@@ -88,15 +156,33 @@ class NewCommunityModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
isPublishing = true isPublishing = true
try { try {
val moderatorTags = parseModeratorTags(myAccount) val imageUrl =
val relayTags = parseRelayTags() 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 = val definition =
myAccount.sendCommunityDefinition( myAccount.sendCommunityDefinition(
name = name.trim(), name = name.trim(),
description = description.trim(), description = description.trim(),
moderators = moderatorTags, moderators = moderatorTags,
image = imageUrl.trim().ifBlank { null }, image = imageUrl,
rules = rules.trim().ifBlank { null }, rules = rules.trim().ifBlank { null },
relays = relayTags.ifEmpty { null }, relays = relayTags.ifEmpty { null },
dTag = Uuid.random().toString(), dTag = Uuid.random().toString(),
@@ -110,10 +196,13 @@ class NewCommunityModel : ViewModel() {
return@launch 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()) val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address())
myAccount.follow(communityNote) myAccount.follow(communityNote)
selectedServer?.let { myAccount.settings.changeDefaultFileServer(it) }
myAccount.settings.changeStripLocationOnUpload(stripMetadata)
reset() reset()
onSuccess() onSuccess()
} finally { } finally {
@@ -122,42 +211,58 @@ class NewCommunityModel : ViewModel() {
} }
} }
private fun parseModeratorTags(account: Account): List<ModeratorTag> { private suspend fun uploadImageIfAny(
val hexes = context: Context,
moderatorsText myAccount: Account,
.lineSequence() onError: (String, String) -> Unit,
.map { it.trim() } ): String? {
.filter { it.length == 64 && it.all { ch -> ch.isDigit() || (ch in 'a'..'f') || (ch in 'A'..'F') } } val orch = multiOrchestrator ?: return null
.map { it.lowercase() } val serverToUse = selectedServer ?: defaultServer()
.toSet()
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") } if (!results.allGood) {
} val messages =
results.errors
private fun parseRelayTags(): List<RelayTag> { .map { stringRes(context, it.errorResource, *it.params) }
val tags = mutableListOf<RelayTag>() .distinct()
relayRequestsUrl.trim().takeIf { it.isNotEmpty() }?.let { .joinToString(".\n")
RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages)
tags += RelayTag(url, RelayTag.MARKER_REQUESTS) return null
}
} }
relayApprovalsUrl.trim().takeIf { it.isNotEmpty() }?.let {
RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> val uploaded =
tags += RelayTag(url, RelayTag.MARKER_APPROVALS) 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() { fun reset() {
name = "" name = ""
description = "" description = ""
imageUrl = ""
rules = "" rules = ""
moderatorsText = "" multiOrchestrator = null
relayRequestsUrl = "" moderators.clear()
relayApprovalsUrl = "" relays.clear()
selectedServer = defaultServer()
} }
} }
@@ -20,35 +20,77 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState 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.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.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.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R 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.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.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.stringRes
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -57,117 +99,440 @@ fun NewCommunityScreen(
nav: INav, nav: INav,
) { ) {
val model: NewCommunityModel = viewModel() val model: NewCommunityModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel.account) { LaunchedEffect(accountViewModel.account) {
model.init(accountViewModel.account) model.init(accountViewModel.account)
} }
val context = LocalContext.current StrippingFailureDialog(model.strippingFailureConfirmation)
var errorTitle by remember { mutableStateOf<String?>(null) }
var errorBody by remember { mutableStateOf<String?>(null) } var wantsToPickImage by remember { mutableStateOf(false) }
if (wantsToPickImage) {
GallerySelect(
onImageUri = { uris ->
wantsToPickImage = false
model.setPickedMedia(
if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(),
)
},
)
}
Scaffold( Scaffold(
topBar = { topBar = {
TopBarWithBackButton( CreatingTopBar(
caption = stringRes(R.string.new_community), titleRes = R.string.new_community,
popBack = { nav.popBack() }, isActive = model::canPost,
) onCancel = {
}, model.reset()
) { padding -> nav.popBack()
Column( },
modifier = onPost = {
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
model.publish( model.publish(
context = context, context = context,
onSuccess = { nav.popBack() }, onSuccess = { nav.popBack() },
onError = { title, body -> onError = accountViewModel.toastManager::toast,
errorTitle = title
errorBody = body
},
) )
}, },
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) },
)
}
+13 -5
View File
@@ -424,12 +424,20 @@
<string name="new_community">New Community</string> <string name="new_community">New Community</string>
<string name="new_community_name">Community name</string> <string name="new_community_name">Community name</string>
<string name="new_community_description">Description</string> <string name="new_community_description">Description</string>
<string name="new_community_image_url">Cover image URL (optional)</string>
<string name="new_community_rules">Rules (optional)</string> <string name="new_community_rules">Rules (optional)</string>
<string name="new_community_moderators">Moderators (one pubkey per line, optional)</string> <string name="new_community_pick_cover">Add a cover image</string>
<string name="new_community_relay_requests">Relay for requests (optional)</string> <string name="new_community_pick_cover_hint">Tap to pick a photo — it will be uploaded to your media server.</string>
<string name="new_community_relay_approvals">Relay for approvals (optional)</string> <string name="new_community_moderators_section">Moderators</string>
<string name="new_community_publish">Publish</string> <string name="new_community_moderators_hint">Moderators can approve posts. You are always a moderator.</string>
<string name="new_community_add_moderator">Add a moderator</string>
<string name="new_community_add_moderator_placeholder">Name, npub, or NIP-05</string>
<string name="new_community_owner">Owner</string>
<string name="new_community_relays_section">Relays</string>
<string name="new_community_relays_hint">Pick relays that will host requests, approvals, or the community author\'s metadata.</string>
<string name="new_community_relay_marker_none">Any</string>
<string name="new_community_relay_marker_author">Author</string>
<string name="new_community_relay_marker_requests">Requests</string>
<string name="new_community_relay_marker_approvals">Approvals</string>
<string name="new_community_empty">No communities match this filter.</string> <string name="new_community_empty">No communities match this filter.</string>
<string name="received_badges">Received</string> <string name="received_badges">Received</string>
<string name="my_badges">Mine</string> <string name="my_badges">Mine</string>