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:
+141
-36
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+464
-99
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user