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
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<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) {
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<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 =
@@ -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<ModeratorTag> {
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<RelayTag> {
val tags = mutableListOf<RelayTag>()
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()
}
}
@@ -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<String?>(null) }
var errorBody by remember { mutableStateOf<String?>(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) },
)
}
+13 -5
View File
@@ -424,12 +424,20 @@
<string name="new_community">New Community</string>
<string name="new_community_name">Community name</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_moderators">Moderators (one pubkey per line, optional)</string>
<string name="new_community_relay_requests">Relay for requests (optional)</string>
<string name="new_community_relay_approvals">Relay for approvals (optional)</string>
<string name="new_community_publish">Publish</string>
<string name="new_community_pick_cover">Add a cover image</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_moderators_section">Moderators</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="received_badges">Received</string>
<string name="my_badges">Mine</string>