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:
+140
-35
@@ -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
|
||||||
|
.map { stringRes(context, it.errorResource, *it.params) }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(".\n")
|
||||||
|
onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseRelayTags(): List<RelayTag> {
|
val uploaded =
|
||||||
val tags = mutableListOf<RelayTag>()
|
results.successful.firstNotNullOfOrNull {
|
||||||
relayRequestsUrl.trim().takeIf { it.isNotEmpty() }?.let {
|
it.result as? UploadOrchestrator.OrchestratorResult.ServerResult
|
||||||
RelayUrlNormalizer.normalizeOrNull(it)?.let { url ->
|
} ?: run {
|
||||||
tags += RelayTag(url, RelayTag.MARKER_REQUESTS)
|
onError(
|
||||||
|
stringRes(context, R.string.failed_to_upload_media_no_details),
|
||||||
|
"Upload succeeded but no image URL was returned by the server.",
|
||||||
|
)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
relayApprovalsUrl.trim().takeIf { it.isNotEmpty() }?.let {
|
return uploaded.url
|
||||||
RelayUrlNormalizer.normalizeOrNull(it)?.let { url ->
|
|
||||||
tags += RelayTag(url, RelayTag.MARKER_APPROVALS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tags
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
name = ""
|
name = ""
|
||||||
description = ""
|
description = ""
|
||||||
imageUrl = ""
|
|
||||||
rules = ""
|
rules = ""
|
||||||
moderatorsText = ""
|
multiOrchestrator = null
|
||||||
relayRequestsUrl = ""
|
moderators.clear()
|
||||||
relayApprovalsUrl = ""
|
relays.clear()
|
||||||
|
selectedServer = defaultServer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+430
-65
@@ -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,38 +99,77 @@ 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()
|
||||||
|
nav.popBack()
|
||||||
|
},
|
||||||
|
onPost = {
|
||||||
|
model.publish(
|
||||||
|
context = context,
|
||||||
|
onSuccess = { nav.popBack() },
|
||||||
|
onError = accountViewModel.toastManager::toast,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
)
|
||||||
|
},
|
||||||
|
) { pad ->
|
||||||
|
Surface(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(pad)
|
||||||
|
.consumeWindowInsets(pad)
|
||||||
|
.imePadding(),
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
.verticalScroll(rememberScrollState()),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
) {
|
||||||
|
CommunityImagePicker(
|
||||||
|
model = model,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onPickImage = { wantsToPickImage = true },
|
||||||
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = model.name,
|
value = model.name,
|
||||||
onValueChange = { model.name = it },
|
onValueChange = { model.name = it },
|
||||||
label = { Text(stringRes(R.string.new_community_name)) },
|
label = { Text(stringRes(R.string.new_community_name)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions.Default.copy(
|
||||||
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -96,78 +177,362 @@ fun NewCommunityScreen(
|
|||||||
onValueChange = { model.description = it },
|
onValueChange = { model.description = it },
|
||||||
label = { Text(stringRes(R.string.new_community_description)) },
|
label = { Text(stringRes(R.string.new_community_description)) },
|
||||||
minLines = 3,
|
minLines = 3,
|
||||||
|
maxLines = 8,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
keyboardOptions =
|
||||||
|
KeyboardOptions.Default.copy(
|
||||||
OutlinedTextField(
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
value = model.imageUrl,
|
),
|
||||||
onValueChange = { model.imageUrl = it },
|
|
||||||
label = { Text(stringRes(R.string.new_community_image_url)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = model.rules,
|
value = model.rules,
|
||||||
onValueChange = { model.rules = it },
|
onValueChange = { model.rules = it },
|
||||||
label = { Text(stringRes(R.string.new_community_rules)) },
|
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,
|
minLines = 2,
|
||||||
|
maxLines = 8,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions.Default.copy(
|
||||||
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
HorizontalDivider()
|
||||||
value = model.relayRequestsUrl,
|
|
||||||
onValueChange = { model.relayRequestsUrl = it },
|
SectionHeader(R.string.new_community_moderators_section)
|
||||||
label = { Text(stringRes(R.string.new_community_relay_requests)) },
|
ModeratorsSection(
|
||||||
singleLine = true,
|
model = model,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
accountViewModel = accountViewModel,
|
||||||
|
nav = nav,
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
HorizontalDivider()
|
||||||
value = model.relayApprovalsUrl,
|
|
||||||
onValueChange = { model.relayApprovalsUrl = it },
|
|
||||||
label = { Text(stringRes(R.string.new_community_relay_approvals)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
errorTitle?.let { title ->
|
SectionHeader(R.string.new_community_relays_section)
|
||||||
|
RelaysSection(
|
||||||
|
model = model,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
nav = nav,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(resourceId: Int) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = stringRes(resourceId),
|
||||||
fontWeight = FontWeight.Bold,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
errorBody?.let { body ->
|
}
|
||||||
Text(text = body)
|
|
||||||
|
@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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
@Composable
|
||||||
onClick = {
|
private fun CommunityImagePlaceholder(onClick: () -> Unit) {
|
||||||
errorTitle = null
|
Box(
|
||||||
errorBody = null
|
modifier =
|
||||||
model.publish(
|
Modifier
|
||||||
context = context,
|
.fillMaxWidth()
|
||||||
onSuccess = { nav.popBack() },
|
.aspectRatio(16f / 9f)
|
||||||
onError = { title, body ->
|
.border(
|
||||||
errorTitle = title
|
width = 1.dp,
|
||||||
errorBody = body
|
color = MaterialTheme.colorScheme.outline,
|
||||||
},
|
shape = RoundedCornerShape(16.dp),
|
||||||
)
|
).clickable(onClick = onClick)
|
||||||
},
|
.padding(24.dp),
|
||||||
enabled = model.canPost(),
|
contentAlignment = Alignment.Center,
|
||||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
) {
|
||||||
Text(text = stringRes(R.string.new_community_publish))
|
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">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>
|
||||||
|
|||||||
Reference in New Issue
Block a user