feat(communities): prefer name() tag + add edit flow

Name display
- CommunityCard, CommunityName spinner option, FeedFilterSpinner
  renderer, and DisplayFollowingCommunityInPost now all prefer the
  NIP-72 `name` tag and fall back to the `d` tag. Previously they
  rendered the raw UUID d-tag for communities created by this app,
  which made them show up as /n/<hex> instead of the configured name.
- DisplayCommunity is now reactive via observeNote so the chip in
  NoteCompose recomposes when the community definition arrives.

Edit flow
- New Route.EditCommunity(kind, pubKeyHex, dTag) and
  EditCommunityScreen that reuses the create form via a shared
  CommunityFormScreen composable.
- NewCommunityModel gains loadFrom(CommunityDefinitionEvent) to
  preload name/description/rules/moderators/relays and to remember
  the existing d-tag + image URL. publish() reuses the existing
  d-tag so the replaceable kind-34550 event updates in place.
- Cover preview on the form supports the already-uploaded image
  (AsyncImage with remove button) and falls back to the placeholder
  if cleared.
- Owner (note author == signer) sees an "Edit" FilledTonalIconButton
  in LongCommunityActionOptions that opens the edit screen.
- Top bar becomes "Edit Community" with a Save button when editing.
This commit is contained in:
Claude
2026-04-20 21:05:06 +00:00
parent 0f84981879
commit 30646998ea
10 changed files with 255 additions and 50 deletions
@@ -94,6 +94,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.EditCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen
@@ -225,6 +226,7 @@ fun BuildNavigation(
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
composableFromEnd<Route.Communities> { CommunitiesScreen(accountViewModel, nav) }
composableFromEnd<Route.NewCommunity> { NewCommunityScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditCommunity> { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Badges> { BadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
@@ -49,6 +49,18 @@ sealed class Route {
@Serializable object NewCommunity : Route()
@Serializable data class EditCommunity(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable object Badges : Route()
@Serializable object ProfileBadges : Route()
@@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import kotlinx.collections.immutable.ImmutableList
@@ -351,7 +352,10 @@ fun RenderOption(
is CommunityName -> {
val it by observeNote(option.note, accountViewModel)
Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
val addressable = it.note as? AddressableNote
val definition = addressable?.event as? CommunityDefinitionEvent
val label = definition?.name()?.ifBlank { null } ?: addressable?.dTag() ?: ""
Text(text = "/n/$label", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface)
}
is RelayName -> {
@@ -24,9 +24,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -34,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
@Composable
fun DisplayFollowingCommunityInPost(
@@ -42,38 +46,46 @@ fun DisplayFollowingCommunityInPost(
nav: INav,
) {
Column(HalfStartPadding) {
Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, nav) }
Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, accountViewModel, nav) }
}
}
@Composable
private fun DisplayCommunity(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val communityTag = note.event?.communityAddress() ?: return
val communityNote = LocalCache.getOrCreateAddressableNote(communityTag)
val communityState by observeNote(communityNote, accountViewModel)
val label = communityShortLabel(communityTag, communityState.note.event as? CommunityDefinitionEvent)
ClickableTextColor(
getCommunityShortName(communityTag),
label,
linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
) {
nav.nav {
note.event?.communityAddress()?.let { communityTag ->
Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag)
note.event?.communityAddress()?.let { addr ->
Route.Community(addr.kind, addr.pubKeyHex, addr.dTag)
}
}
}
}
fun getCommunityShortName(communityAddress: Address): String {
val name =
if (communityAddress.dTag.length > 10) {
communityAddress.dTag.take(10) + "..."
private fun communityShortLabel(
address: Address,
definition: CommunityDefinitionEvent?,
): String {
val raw = definition?.name()?.ifBlank { null } ?: address.dTag
val shortened =
if (raw.length > 12) {
raw.take(12) + "..."
} else {
communityAddress.dTag.take(10)
raw
}
return "/n/$name"
return "/n/$shortened"
}
@@ -34,6 +34,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
@@ -70,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LikeReaction
@@ -439,6 +441,9 @@ private fun LongCommunityActionOptions(
nav: INav,
) {
Row {
if (note.author?.pubkeyHex == accountViewModel.account.signer.pubKey) {
EditCommunityButton(note, nav)
}
ShareCommunityButton(accountViewModel, note, nav)
WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (isFollowing) {
@@ -448,6 +453,22 @@ private fun LongCommunityActionOptions(
}
}
@Composable
fun EditCommunityButton(
note: AddressableNote,
nav: INav,
) {
FilledTonalIconButton(
onClick = { nav.nav(Route.EditCommunity(note.address)) },
) {
Icon(
imageVector = Icons.Outlined.Edit,
modifier = Size18Modifier,
contentDescription = stringRes(R.string.edit_community),
)
}
}
@Composable
fun WatchAddressableNoteFollows(
note: AddressableNote,
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.persistentListOf
@@ -381,7 +382,11 @@ class PeopleListName(
class CommunityName(
val note: AddressableNote,
) : Name() {
override fun name() = "/n/${(note.dTag())}"
override fun name(): String {
val definition = note.event as? CommunityDefinitionEvent
val label = definition?.name()?.ifBlank { null } ?: note.dTag()
return "/n/$label"
}
}
@Stable
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import kotlinx.collections.immutable.ImmutableList
@@ -61,11 +62,16 @@ data class CommunityRelayEntry(
class NewCommunityModel : ViewModel() {
var account: Account? = null
// When set, publish() reuses this d-tag so the kind-34550 replaceable event
// is updated in place (edit flow). Null in the create flow.
var existingDTag: String? = null
var isPublishing by mutableStateOf(false)
var name by mutableStateOf("")
var description by mutableStateOf("")
var rules by mutableStateOf("")
var existingImageUrl by mutableStateOf<String?>(null)
// Image upload state - mirrors NewBadgeModel.
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
@@ -88,6 +94,42 @@ class NewCommunityModel : ViewModel() {
this.stripMetadata = account.settings.stripLocationOnUpload
}
/**
* Preloads the form with the current contents of [existing] so the user can edit
* the replaceable kind 34550 event. Keeps the original `d` tag so relays replace
* the previous version instead of creating a new community.
*/
fun loadFrom(existing: CommunityDefinitionEvent) {
existingDTag = existing.dTag()
name = existing.name().orEmpty()
description = existing.description().orEmpty()
rules = existing.rules().orEmpty()
existingImageUrl = existing.image()?.imageUrl
val ownerKey = account?.signer?.pubKey
moderators.clear()
existing
.moderatorKeys()
.asSequence()
.filter { it != ownerKey }
.distinct()
.forEach { pubkey ->
val user = account?.cache?.getOrCreateUser(pubkey) ?: return@forEach
if (moderators.none { it.pubkeyHex == user.pubkeyHex }) {
moderators.add(user)
}
}
relays.clear()
existing.relays().forEach { tag ->
if (relays.none { it.url == tag.url }) {
relays.add(CommunityRelayEntry(tag.url, tag.marker))
}
}
}
fun isEditing(): Boolean = existingDTag != null
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
fun setPickedMedia(uris: ImmutableList<SelectedMedia>) {
@@ -156,15 +198,15 @@ class NewCommunityModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
isPublishing = true
try {
val imageUrl =
uploadImageIfAny(context, myAccount, onError) ?: run {
if (multiOrchestrator != null) {
// upload failed; error was reported, bail out
return@launch
}
val uploadedUrl =
if (multiOrchestrator != null) {
uploadImageIfAny(context, myAccount, onError) ?: return@launch
} else {
null
}
val imageUrl = uploadedUrl ?: existingImageUrl
val ownerKey = myAccount.signer.pubKey
val moderatorTags =
buildList {
@@ -177,6 +219,8 @@ class NewCommunityModel : ViewModel() {
val relayTags = relays.map { RelayTag(it.url, it.marker) }
val dTag = existingDTag ?: Uuid.random().toString()
val definition =
myAccount.sendCommunityDefinition(
name = name.trim(),
@@ -185,7 +229,7 @@ class NewCommunityModel : ViewModel() {
image = imageUrl,
rules = rules.trim().ifBlank { null },
relays = relayTags.ifEmpty { null },
dTag = Uuid.random().toString(),
dTag = dTag,
)
if (definition == null) {
@@ -196,9 +240,11 @@ class NewCommunityModel : ViewModel() {
return@launch
}
// Follow it so it shows up under "Mine".
val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address())
myAccount.follow(communityNote)
// Auto-follow only on the create flow; editing doesn't change the follow set.
if (existingDTag == null) {
val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address())
myAccount.follow(communityNote)
}
selectedServer?.let { myAccount.settings.changeDefaultFileServer(it) }
myAccount.settings.changeStripLocationOnUpload(stripMetadata)
@@ -260,6 +306,8 @@ class NewCommunityModel : ViewModel() {
name = ""
description = ""
rules = ""
existingImageUrl = null
existingDTag = null
multiOrchestrator = null
moderators.clear()
relays.clear()
@@ -71,14 +71,17 @@ 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 coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.LocalCache
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.ActionTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
@@ -89,14 +92,30 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditF
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.nip01Core.core.Address
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewCommunityScreen(
accountViewModel: AccountViewModel,
nav: INav,
) = CommunityFormScreen(editing = null, accountViewModel = accountViewModel, nav = nav)
@Composable
fun EditCommunityScreen(
editing: Address,
accountViewModel: AccountViewModel,
nav: INav,
) = CommunityFormScreen(editing = editing, accountViewModel = accountViewModel, nav = nav)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CommunityFormScreen(
editing: Address?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val model: NewCommunityModel = viewModel()
val context = LocalContext.current
@@ -105,6 +124,15 @@ fun NewCommunityScreen(
model.init(accountViewModel.account)
}
LaunchedEffect(editing) {
if (editing != null) {
val note = LocalCache.getOrCreateAddressableNote(editing)
(note.event as? CommunityDefinitionEvent)?.let { model.loadFrom(it) }
} else {
model.existingDTag = null
}
}
StrippingFailureDialog(model.strippingFailureConfirmation)
var wantsToPickImage by remember { mutableStateOf(false) }
@@ -122,21 +150,42 @@ fun NewCommunityScreen(
Scaffold(
topBar = {
CreatingTopBar(
titleRes = R.string.new_community,
isActive = model::canPost,
onCancel = {
model.reset()
nav.popBack()
},
onPost = {
model.publish(
context = context,
onSuccess = { nav.popBack() },
onError = accountViewModel.toastManager::toast,
)
},
)
val titleRes =
if (model.isEditing()) R.string.edit_community else R.string.new_community
if (model.isEditing()) {
ActionTopBar(
titleRes = titleRes,
postRes = R.string.save,
isActive = model::canPost,
onCancel = {
model.reset()
nav.popBack()
},
onPost = {
model.publish(
context = context,
onSuccess = { nav.popBack() },
onError = accountViewModel.toastManager::toast,
)
},
)
} else {
CreatingTopBar(
titleRes = titleRes,
isActive = model::canPost,
onCancel = {
model.reset()
nav.popBack()
},
onPost = {
model.publish(
context = context,
onSuccess = { nav.popBack() },
onError = accountViewModel.toastManager::toast,
)
},
)
}
},
) { pad ->
Surface(
@@ -235,18 +284,69 @@ private fun CommunityImagePicker(
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,
)
val existingUrl = model.existingImageUrl
when {
model.hasPickedImage() -> {
model.multiOrchestrator?.let {
Box(modifier = Modifier.clickable(onClick = onPickImage)) {
ShowImageUploadGallery(
list = it,
onDelete = { model.setPickedMedia(persistentListOf()) },
accountViewModel = accountViewModel,
)
}
}
}
} else {
CommunityImagePlaceholder(onClick = onPickImage)
!existingUrl.isNullOrBlank() -> {
ExistingCommunityCover(
url = existingUrl,
onClick = onPickImage,
onClear = { model.existingImageUrl = null },
)
}
else -> {
CommunityImagePlaceholder(onClick = onPickImage)
}
}
}
@Composable
private fun ExistingCommunityCover(
url: String,
onClick: () -> Unit,
onClear: () -> Unit,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clickable(onClick = onClick),
contentAlignment = Alignment.TopEnd,
) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(16.dp),
),
)
IconButton(onClick = onClear) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringRes(R.string.remove),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -96,7 +96,7 @@ fun RenderCommunitiesThumb(
RenderCommunitiesThumb(
CommunityCard(
name = noteEvent.dTag(),
name = noteEvent.name()?.ifBlank { null } ?: noteEvent.dTag(),
description = noteEvent.description(),
cover = noteEvent.image()?.imageUrl,
moderators = noteEvent.moderatorKeys().toImmutableList(),
+1
View File
@@ -422,6 +422,7 @@
<string name="badges">Badges</string>
<string name="communities">Communities</string>
<string name="new_community">New Community</string>
<string name="edit_community">Edit Community</string>
<string name="new_community_name">Community name</string>
<string name="new_community_description">Description</string>
<string name="new_community_rules">Rules (optional)</string>