Moves the List/Pack creation Dialog to a screen and adds an Image upload option

This commit is contained in:
Vitor Pamplona
2025-11-12 17:17:22 -05:00
parent 8963024a95
commit 29d11c8281
24 changed files with 1343 additions and 455 deletions
@@ -37,11 +37,15 @@ import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.description import com.vitorpamplona.quartz.nip51Lists.followList.description
import com.vitorpamplona.quartz.nip51Lists.followList.image
import com.vitorpamplona.quartz.nip51Lists.followList.person import com.vitorpamplona.quartz.nip51Lists.followList.person
import com.vitorpamplona.quartz.nip51Lists.followList.personFirst import com.vitorpamplona.quartz.nip51Lists.followList.personFirst
import com.vitorpamplona.quartz.nip51Lists.followList.removePerson import com.vitorpamplona.quartz.nip51Lists.followList.removePerson
import com.vitorpamplona.quartz.nip51Lists.followList.title import com.vitorpamplona.quartz.nip51Lists.followList.title
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
import com.vitorpamplona.quartz.nip51Lists.peopleList.image
import com.vitorpamplona.quartz.nip51Lists.peopleList.name
import com.vitorpamplona.quartz.utils.flattenToSet import com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -110,6 +114,7 @@ class FollowListsState(
identifierTag = this.dTag(), identifierTag = this.dTag(),
title = this.title() ?: this.dTag(), title = this.title() ?: this.dTag(),
description = this.description(), description = this.description(),
image = this.image(),
privateMembers = emptySet(), privateMembers = emptySet(),
publicMembers = cache.load(this.followIdSet()), publicMembers = cache.load(this.followIdSet()),
) )
@@ -123,13 +128,17 @@ class FollowListsState(
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList())
fun selectListFlow(selectedDTag: String) = fun List<PeopleList>.select(dTag: String) =
this.firstOrNull {
it.identifierTag == dTag
}
fun selectList(dTag: String) = uiListFlow.value.select(dTag)
fun selectListFlow(dTag: String) =
uiListFlow uiListFlow
.map { peopleLists -> .map { it.select(dTag) }
peopleLists.firstOrNull { list -> list.identifierTag == selectedDTag } .onStart { emit(selectList(dTag)) }
}.onStart {
emit(uiListFlow.value.firstOrNull { it.identifierTag == selectedDTag })
}
fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex)
@@ -175,51 +184,43 @@ class FollowListsState(
suspend fun addFollowList( suspend fun addFollowList(
name: String, name: String,
desc: String?, desc: String?,
image: String?,
member: User? = null, member: User? = null,
isPrivate: Boolean = false, isPrivate: Boolean = false,
account: Account, account: Account,
) { ): String {
val dTag = UUID.randomUUID().toString()
val newListTemplate = val newListTemplate =
FollowListEvent.build( FollowListEvent.build(
name = name, name = name,
people = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), people = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
dTag = UUID.randomUUID().toString(), dTag = dTag,
) { ) {
if (desc != null) description(desc) if (desc != null) description(desc)
if (image != null) image(image)
} }
val newList = signer.sign(newListTemplate) val newList = signer.sign(newListTemplate)
account.sendMyPublicAndPrivateOutbox(newList) account.sendMyPublicAndPrivateOutbox(newList)
return dTag
} }
suspend fun renameFollowList( suspend fun updateMetadata(
newName: String, name: String?,
followPack: PeopleList, desc: String?,
image: String?,
peopleList: PeopleList,
account: Account, account: Account,
) { ) {
val listEvent = getPeopleList(followPack.identifierTag) val listEvent = getPeopleList(peopleList.identifierTag)
val template = val template =
listEvent.update { listEvent.update {
title(newName) if (name != null) title(name)
} if (desc != null) description(desc)
if (image != null) image(image)
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun modifyFollowSetDescription(
newDescription: String,
followPack: PeopleList,
account: Account,
) {
val listEvent = getPeopleList(followPack.identifierTag)
val template =
listEvent.update {
description(newDescription)
} }
val newList = signer.sign(template) val newList = signer.sign(template)
@@ -30,6 +30,7 @@ data class PeopleList(
val identifierTag: String, val identifierTag: String,
val title: String, val title: String,
val description: String?, val description: String?,
val image: String?,
val privateMembers: Set<User> = emptySet(), val privateMembers: Set<User> = emptySet(),
val publicMembers: Set<User> = emptySet(), val publicMembers: Set<User> = emptySet(),
) { ) {
@@ -32,9 +32,14 @@ import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.amethyst.model.updateFlow import com.vitorpamplona.amethyst.model.updateFlow
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.followList.description
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.description
import com.vitorpamplona.quartz.nip51Lists.peopleList.image
import com.vitorpamplona.quartz.nip51Lists.peopleList.name
import com.vitorpamplona.quartz.utils.flattenToSet import com.vitorpamplona.quartz.utils.flattenToSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -116,6 +121,7 @@ class PeopleListsState(
identifierTag = this.dTag(), identifierTag = this.dTag(),
title = this.nameOrTitle() ?: this.dTag(), title = this.nameOrTitle() ?: this.dTag(),
description = this.description(), description = this.description(),
image = this.image(),
privateMembers = cache.load(decryptionCache.privateUserIdSet(this)), privateMembers = cache.load(decryptionCache.privateUserIdSet(this)),
publicMembers = cache.load(this.publicUsersIdSet()), publicMembers = cache.load(this.publicUsersIdSet()),
) )
@@ -129,17 +135,17 @@ class PeopleListsState(
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList()) .stateIn(scope, SharingStarted.Eagerly, emptyList())
fun selectListFlow(selectedDTag: String) = fun List<PeopleList>.select(dTag: String) =
this.firstOrNull {
it.identifierTag == dTag
}
fun selectList(dTag: String) = uiListFlow.value.select(dTag)
fun selectListFlow(dTag: String) =
uiListFlow uiListFlow
.map { peopleLists -> .map { it.select(dTag) }
peopleLists.firstOrNull { list -> .onStart { emit(selectList(dTag)) }
list.identifierTag == selectedDTag
}
}.onStart {
emit(
uiListFlow.value.firstOrNull { it.identifierTag == selectedDTag },
)
}
fun DeletionEvent.hasDeletedAnyPeopleList() = deleteAddressesWithKind(PeopleListEvent.KIND) || deletesAnyEventIn(peopleListsEventIds.value) fun DeletionEvent.hasDeletedAnyPeopleList() = deleteAddressesWithKind(PeopleListEvent.KIND) || deletesAnyEventIn(peopleListsEventIds.value)
@@ -183,49 +189,48 @@ class PeopleListsState(
suspend fun addFollowList( suspend fun addFollowList(
listName: String, listName: String,
listDescription: String?, listDescription: String?,
listImage: String?,
member: User? = null, member: User? = null,
isPrivate: Boolean = false, isPrivate: Boolean = false,
account: Account, account: Account,
) { ): String {
val newList = val dTag = UUID.randomUUID().toString()
PeopleListEvent.createListWithDescription( val newListTemplate =
dTag = UUID.randomUUID().toString(), PeopleListEvent.build(
title = listName, dTag = dTag,
description = listDescription, name = listName,
publicMembers = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), publicMembers = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
privateMembers = if (isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), privateMembers = if (isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
signer = account.signer, signer = account.signer,
) ) {
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
val newList = signer.sign(newListTemplate)
account.sendMyPublicAndPrivateOutbox(newList) account.sendMyPublicAndPrivateOutbox(newList)
return dTag
} }
suspend fun renameFollowList( suspend fun updateMetadata(
newName: String, listName: String?,
listDescription: String?,
listImage: String?,
peopleList: PeopleList, peopleList: PeopleList,
account: Account, account: Account,
) { ) {
val listEvent = getPeopleList(peopleList.identifierTag) val listEvent = getPeopleList(peopleList.identifierTag)
val newList =
PeopleListEvent.modifyListName(
earlierVersion = listEvent,
newName = newName,
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun modifyFollowSetDescription( val template =
newDescription: String?, listEvent.update {
peopleList: PeopleList, if (listName != null) name(listName)
account: Account, if (listDescription != null) description(listDescription)
) { if (listImage != null) image(listImage)
val listEvent = getPeopleList(peopleList.identifierTag) }
val newList =
PeopleListEvent.modifyDescription( val newList = signer.sign(template)
earlierVersion = listEvent,
newDescription = newDescription,
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(newList) account.sendMyPublicAndPrivateOutbox(newList)
} }
@@ -82,6 +82,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
@@ -132,6 +134,9 @@ fun AppNavigation(
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) } composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.PeopleListManagement> { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) } composableFromBottomArgs<Route.PeopleListManagement> { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) }
composableFromBottomArgs<Route.PeopleListMetadataEdit> { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.FollowPackMetadataEdit> { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) } composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) } composable<Route.Search> { SearchScreen(accountViewModel, nav) }
@@ -63,6 +63,14 @@ sealed class Route {
val dTag: String, val dTag: String,
) : Route() ) : Route()
@Serializable data class PeopleListMetadataEdit(
val dTag: String? = null,
) : Route()
@Serializable data class FollowPackMetadataEdit(
val dTag: String? = null,
) : Route()
@Serializable data class PeopleListManagement( @Serializable data class PeopleListManagement(
val userToAdd: HexKey, val userToAdd: HexKey,
) : Route() ) : Route()
@@ -294,6 +302,15 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
dest.hasRoute<Route.GenericCommentPost>() -> entry.toRoute<Route.GenericCommentPost>() dest.hasRoute<Route.GenericCommentPost>() -> entry.toRoute<Route.GenericCommentPost>()
dest.hasRoute<Route.NewPublicMessage>() -> entry.toRoute<Route.NewPublicMessage>() dest.hasRoute<Route.NewPublicMessage>() -> entry.toRoute<Route.NewPublicMessage>()
dest.hasRoute<Route.Lists>() -> entry.toRoute<Route.Lists>()
dest.hasRoute<Route.MyPeopleListView>() -> entry.toRoute<Route.MyPeopleListView>()
dest.hasRoute<Route.MyFollowPackView>() -> entry.toRoute<Route.MyFollowPackView>()
dest.hasRoute<Route.PeopleListMetadataEdit>() -> entry.toRoute<Route.PeopleListMetadataEdit>()
dest.hasRoute<Route.FollowPackMetadataEdit>() -> entry.toRoute<Route.FollowPackMetadataEdit>()
dest.hasRoute<Route.PeopleListManagement>() -> entry.toRoute<Route.PeopleListManagement>()
dest.hasRoute<Route.NewGroupDM>() -> entry.toRoute<Route.NewGroupDM>()
dest.hasRoute<Route.UserSettings>() -> entry.toRoute<Route.UserSettings>()
else -> { else -> {
null null
} }
@@ -1,104 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.StdPadding
@Composable
fun ListActionsMenuButton(
onBroadcastList: () -> Unit,
onDeleteList: () -> Unit,
) {
val isActionListOpen = remember { mutableStateOf(false) }
ClickableBox(
modifier =
StdPadding
.size(30.dp)
.border(
width = Dp.Hairline,
color = ButtonDefaults.filledTonalButtonColors().containerColor,
shape = ButtonBorder,
).background(
color = ButtonDefaults.filledTonalButtonColors().containerColor,
shape = ButtonBorder,
),
onClick = { isActionListOpen.value = true },
) {
VerticalDotsIcon()
ListActionsMenu(
onCloseMenu = { isActionListOpen.value = false },
isOpen = isActionListOpen.value,
onBroadcastList = onBroadcastList,
onDeleteList = onDeleteList,
)
}
}
@Composable
fun ListActionsMenu(
onCloseMenu: () -> Unit,
isOpen: Boolean,
onBroadcastList: () -> Unit,
onDeleteList: () -> Unit,
) {
DropdownMenu(
expanded = isOpen,
onDismissRequest = onCloseMenu,
) {
DropdownMenuItem(
text = {
Text("Broadcast List")
},
onClick = {
onBroadcastList()
onCloseMenu()
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text("Delete List")
},
onClick = {
onDeleteList()
onCloseMenu()
},
)
}
}
@@ -20,6 +20,9 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -29,13 +32,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation import androidx.compose.material3.CardDefaults.cardElevation
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -55,29 +62,38 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.ListActionsMenuButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListView import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.RenderAddUserFieldAndSuggestions import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.RenderAddUserFieldAndSuggestions
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -354,6 +370,10 @@ private fun ListActionsMenuButton(
nav: INav, nav: INav,
) { ) {
ListActionsMenuButton( ListActionsMenuButton(
note = viewModel::selectedNote,
onEditList = {
nav.nav { Route.PeopleListMetadataEdit(viewModel.selectedDTag.value) }
},
onBroadcastList = { onBroadcastList = {
accountViewModel.launchSigner { accountViewModel.launchSigner {
viewModel.loadNote()?.let { updatedSetNote -> viewModel.loadNote()?.let { updatedSetNote ->
@@ -369,3 +389,90 @@ private fun ListActionsMenuButton(
}, },
) )
} }
@Composable
private fun ListActionsMenuButton(
note: () -> AddressableNote,
onEditList: () -> Unit,
onBroadcastList: () -> Unit,
onDeleteList: () -> Unit,
) {
val isActionListOpen = remember { mutableStateOf(false) }
ClickableBox(
modifier =
StdPadding
.size(30.dp)
.border(
width = Dp.Hairline,
color = ButtonDefaults.filledTonalButtonColors().containerColor,
shape = ButtonBorder,
).background(
color = ButtonDefaults.filledTonalButtonColors().containerColor,
shape = ButtonBorder,
),
onClick = { isActionListOpen.value = true },
) {
VerticalDotsIcon()
DropdownMenu(
expanded = isActionListOpen.value,
onDismissRequest = { isActionListOpen.value = false },
) {
val context = LocalContext.current
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note()),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(context, R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))
ContextCompat.startActivity(context, shareIntent, null)
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_edit_list_metadata))
},
onClick = {
onEditList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_broadcast))
},
onClick = {
onBroadcastList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_delete))
},
onClick = {
onDeleteList()
isActionListOpen.value = false
},
)
}
}
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -60,6 +61,10 @@ class PeopleListViewModel : ViewModel() {
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun selectedAddress() = PeopleListEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value)
fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress())
fun init( fun init(
account: Account, account: Account,
selectedDTag: String, selectedDTag: String,
@@ -20,6 +20,9 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -29,10 +32,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation import androidx.compose.material3.CardDefaults.cardElevation
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -48,29 +55,38 @@ 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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.DrawUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.ListActionsMenuButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListView import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.RenderAddUserFieldAndSuggestions import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.RenderAddUserFieldAndSuggestions
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -211,6 +227,10 @@ private fun ListActionsMenuButton(
nav: INav, nav: INav,
) { ) {
ListActionsMenuButton( ListActionsMenuButton(
note = viewModel::selectedNote,
onEditList = {
nav.nav { Route.FollowPackMetadataEdit(viewModel.selectedDTag.value) }
},
onBroadcastList = { onBroadcastList = {
accountViewModel.launchSigner { accountViewModel.launchSigner {
viewModel.loadNote()?.let { updatedSetNote -> viewModel.loadNote()?.let { updatedSetNote ->
@@ -227,6 +247,93 @@ private fun ListActionsMenuButton(
) )
} }
@Composable
private fun ListActionsMenuButton(
note: () -> AddressableNote,
onEditList: () -> Unit,
onBroadcastList: () -> Unit,
onDeleteList: () -> Unit,
) {
val isActionListOpen = remember { mutableStateOf(false) }
ClickableBox(
modifier =
StdPadding
.size(30.dp)
.border(
width = Dp.Hairline,
color = ButtonDefaults.filledTonalButtonColors().containerColor,
shape = ButtonBorder,
).background(
color = ButtonDefaults.filledTonalButtonColors().containerColor,
shape = ButtonBorder,
),
onClick = { isActionListOpen.value = true },
) {
VerticalDotsIcon()
DropdownMenu(
expanded = isActionListOpen.value,
onDismissRequest = { isActionListOpen.value = false },
) {
val context = LocalContext.current
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note()),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(context, R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))
ContextCompat.startActivity(context, shareIntent, null)
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_edit_list_metadata))
},
onClick = {
onEditList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_broadcast))
},
onClick = {
onBroadcastList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_delete))
},
onClick = {
onDeleteList()
isActionListOpen.value = false
},
)
}
}
}
@Composable @Composable
@Preview(device = "spec:width=2160px,height=2940px,dpi=440") @Preview(device = "spec:width=2160px,height=2940px,dpi=440")
fun FollowPackViewPreview() { fun FollowPackViewPreview() {
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -60,6 +61,10 @@ class FollowPackViewModel : ViewModel() {
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun selectedAddress() = FollowListEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value)
fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress())
fun init( fun init(
account: Account, account: Account,
selectedDTag: String, selectedDTag: String,
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Stable @Stable
@@ -36,47 +35,6 @@ class FollowPackViewModel : ViewModel() {
fun listFlow() = accountViewModel.account.followLists.uiListFlow fun listFlow() = accountViewModel.account.followLists.uiListFlow
fun addItem(
title: String,
description: String?,
) {
accountViewModel.launchSigner {
accountViewModel.account.followLists.addFollowList(
name = title,
desc = description,
account = accountViewModel.account,
)
}
}
fun openItem(dTag: String) = Route.MyFollowPackView(dTag)
fun renameItem(
followSet: PeopleList,
newValue: String,
) {
accountViewModel.launchSigner {
accountViewModel.account.followLists.renameFollowList(
newName = newValue,
followPack = followSet,
account = accountViewModel.account,
)
}
}
fun changeItemDescription(
followSet: PeopleList,
newDescription: String,
) {
accountViewModel.launchSigner {
accountViewModel.account.followLists.modifyFollowSetDescription(
newDescription = newDescription,
followPack = followSet,
account = accountViewModel.account,
)
}
}
fun cloneItem( fun cloneItem(
followSet: PeopleList, followSet: PeopleList,
customName: String?, customName: String?,
@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -86,19 +87,22 @@ fun AllPeopleListFeedView(
color = MaterialTheme.colorScheme.grayText, color = MaterialTheme.colorScheme.grayText,
) )
} }
PeopleListFabsAndMenu( NewListButton(
title = R.string.follow_set_creation_dialog_title, onClick = {
onAddSet = peopleListModel::addItem, nav.nav(Route.PeopleListMetadataEdit())
},
) )
} }
} }
itemsIndexed(peopleListFeedState, key = { _, item -> item.identifierTag }) { _, followSet -> itemsIndexed(peopleListFeedState, key = { _, item -> item.identifierTag }) { _, followSet ->
PeopleListItem( PeopleListItem(
modifier = Modifier.fillMaxSize().animateItem(), modifier =
Modifier
.fillMaxSize()
.animateItem(),
peopleList = followSet, peopleList = followSet,
onClick = { nav.nav(peopleListModel.openItem(followSet.identifierTag)) }, onClick = { nav.nav(Route.MyPeopleListView(followSet.identifierTag)) },
onRename = { peopleListModel.renameItem(followSet, it) }, onEditMetadata = { nav.nav(Route.PeopleListMetadataEdit(followSet.identifierTag)) },
onDescriptionChange = { newDescription -> peopleListModel.changeItemDescription(followSet, newDescription) },
onClone = { cloneName, cloneDescription -> peopleListModel.cloneItem(followSet, cloneName, cloneDescription) }, onClone = { cloneName, cloneDescription -> peopleListModel.cloneItem(followSet, cloneName, cloneDescription) },
onDelete = { peopleListModel.deleteItem(followSet) }, onDelete = { peopleListModel.deleteItem(followSet) },
) )
@@ -106,7 +110,10 @@ fun AllPeopleListFeedView(
} }
stickyHeader { stickyHeader {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp), modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp, horizontalArrangement = SpacedBy5dp,
) { ) {
@@ -122,19 +129,22 @@ fun AllPeopleListFeedView(
color = MaterialTheme.colorScheme.grayText, color = MaterialTheme.colorScheme.grayText,
) )
} }
PeopleListFabsAndMenu( NewListButton(
title = R.string.follow_pack_creation_dialog_title, onClick = {
onAddSet = followPackModel::addItem, nav.nav(Route.FollowPackMetadataEdit())
},
) )
} }
} }
itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, followSet -> itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, followSet ->
PeopleListItem( PeopleListItem(
modifier = Modifier.fillMaxSize().animateItem(), modifier =
Modifier
.fillMaxSize()
.animateItem(),
peopleList = followSet, peopleList = followSet,
onClick = { nav.nav(followPackModel.openItem(followSet.identifierTag)) }, onClick = { nav.nav(Route.MyFollowPackView(followSet.identifierTag)) },
onRename = { followPackModel.renameItem(followSet, it) }, onEditMetadata = { nav.nav(Route.FollowPackMetadataEdit(followSet.identifierTag)) },
onDescriptionChange = { newDescription -> followPackModel.changeItemDescription(followSet, newDescription) },
onClone = { cloneName, cloneDescription -> followPackModel.cloneItem(followSet, cloneName, cloneDescription) }, onClone = { cloneName, cloneDescription -> followPackModel.cloneItem(followSet, cloneName, cloneDescription) },
onDelete = { followPackModel.deleteItem(followSet) }, onDelete = { followPackModel.deleteItem(followSet) },
) )
@@ -147,7 +157,9 @@ fun AllPeopleListFeedView(
@Composable @Composable
fun AllPeopleListFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) { fun AllPeopleListFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) {
Column( Column(
Modifier.fillMaxSize().padding(horizontal = Size40dp), Modifier
.fillMaxSize()
.padding(horizontal = Size40dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
@@ -31,8 +31,6 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -81,17 +79,8 @@ fun ListOfPeopleListsScreen(
} }
@Composable @Composable
fun PeopleListFabsAndMenu( fun NewListButton(onClick: () -> Unit) {
title: Int = R.string.follow_set_creation_dialog_title, OutlinedButton(onClick = onClick) {
onAddSet: (name: String, description: String?) -> Unit,
) {
val isSetAdditionDialogOpen = remember { mutableStateOf(false) }
OutlinedButton(
onClick = {
isSetAdditionDialogOpen.value = true
},
) {
Row(horizontalArrangement = SpacedBy5dp, verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = SpacedBy5dp, verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, imageVector = Icons.AutoMirrored.Filled.PlaylistAdd,
@@ -100,16 +89,4 @@ fun PeopleListFabsAndMenu(
Text(stringRes(R.string.follow_set_create_btn_label)) Text(stringRes(R.string.follow_set_create_btn_label))
} }
} }
if (isSetAdditionDialogOpen.value) {
NewPeopleListCreationDialog(
title = title,
onDismiss = {
isSetAdditionDialogOpen.value = false
},
onCreateList = { name, description ->
onAddSet(name, description)
},
)
}
} }
@@ -83,6 +83,7 @@ private fun PeopleListItemPreview() {
identifierTag = "00001-2222", identifierTag = "00001-2222",
title = "Sample List Title, Very long title, very very very long", title = "Sample List Title, Very long title, very very very long",
description = "Sample List Description", description = "Sample List Description",
image = "http://some.com/image.png",
emptySet(), emptySet(),
emptySet(), emptySet(),
) )
@@ -92,6 +93,7 @@ private fun PeopleListItemPreview() {
identifierTag = "00001-2223", identifierTag = "00001-2223",
title = "Sample List Title", title = "Sample List Title",
description = "Sample List Description", description = "Sample List Description",
image = "http://some.com/image.png",
setOf(user1, user3), setOf(user1, user3),
emptySet(), emptySet(),
) )
@@ -101,6 +103,7 @@ private fun PeopleListItemPreview() {
identifierTag = "00001-2224", identifierTag = "00001-2224",
title = "Sample List Title", title = "Sample List Title",
description = "Sample List Description", description = "Sample List Description",
image = "http://some.com/image.png",
emptySet(), emptySet(),
setOf(user1, user3), setOf(user1, user3),
) )
@@ -110,6 +113,7 @@ private fun PeopleListItemPreview() {
identifierTag = "00001-2225", identifierTag = "00001-2225",
title = "Sample List Title", title = "Sample List Title",
description = "Sample List Description", description = "Sample List Description",
image = "http://some.com/image.png",
setOf(user3), setOf(user3),
setOf(user1, user2, user3), setOf(user1, user2, user3),
) )
@@ -120,8 +124,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier, modifier = Modifier,
peopleList = samplePeopleList1, peopleList = samplePeopleList1,
onClick = {}, onClick = {},
onRename = {}, onEditMetadata = {},
onDescriptionChange = { },
onClone = { newName, newDesc -> }, onClone = { newName, newDesc -> },
onDelete = {}, onDelete = {},
) )
@@ -129,8 +132,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier, modifier = Modifier,
peopleList = samplePeopleList2, peopleList = samplePeopleList2,
onClick = {}, onClick = {},
onRename = {}, onEditMetadata = {},
onDescriptionChange = { },
onClone = { newName, newDesc -> }, onClone = { newName, newDesc -> },
onDelete = {}, onDelete = {},
) )
@@ -138,8 +140,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier, modifier = Modifier,
peopleList = samplePeopleList3, peopleList = samplePeopleList3,
onClick = {}, onClick = {},
onRename = {}, onEditMetadata = {},
onDescriptionChange = { },
onClone = { newName, newDesc -> }, onClone = { newName, newDesc -> },
onDelete = {}, onDelete = {},
) )
@@ -147,8 +148,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier, modifier = Modifier,
peopleList = samplePeopleList4, peopleList = samplePeopleList4,
onClick = {}, onClick = {},
onRename = {}, onEditMetadata = {},
onDescriptionChange = { },
onClone = { newName, newDesc -> }, onClone = { newName, newDesc -> },
onDelete = {}, onDelete = {},
) )
@@ -161,8 +161,7 @@ fun PeopleListItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
peopleList: PeopleList, peopleList: PeopleList,
onClick: () -> Unit, onClick: () -> Unit,
onRename: (String) -> Unit, onEditMetadata: () -> Unit,
onDescriptionChange: (String) -> Unit,
onClone: (customName: String?, customDescription: String?) -> Unit, onClone: (customName: String?, customDescription: String?) -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
@@ -186,10 +185,7 @@ fun PeopleListItem(
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
) { ) {
PeopleListOptionsButton( PeopleListOptionsButton(
peopleListName = peopleList.title, onListEditMetadata = onEditMetadata,
peopleListDescription = peopleList.description,
onListRename = onRename,
onListDescriptionChange = onDescriptionChange,
onListCloneCreate = onClone, onListCloneCreate = onClone,
onListDelete = onDelete, onListDelete = onDelete,
) )
@@ -276,10 +272,7 @@ fun DisplayParticipantNumberAndStatus(
@Composable @Composable
private fun PeopleListOptionsButton( private fun PeopleListOptionsButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
peopleListName: String, onListEditMetadata: () -> Unit,
peopleListDescription: String?,
onListRename: (String) -> Unit,
onListDescriptionChange: (String) -> Unit,
onListCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit, onListCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit,
onListDelete: () -> Unit, onListDelete: () -> Unit,
) { ) {
@@ -291,35 +284,23 @@ private fun PeopleListOptionsButton(
VerticalDotsIcon() VerticalDotsIcon()
ListOptionsMenu( ListOptionsMenu(
setName = peopleListName,
setDescription = peopleListDescription,
isExpanded = isMenuOpen.value, isExpanded = isMenuOpen.value,
onDismiss = { isMenuOpen.value = false }, onListEditMetadata = onListEditMetadata,
onListRename = onListRename,
onListDescriptionChange = onListDescriptionChange,
onListClone = onListCloneCreate, onListClone = onListCloneCreate,
onDelete = onListDelete, onDelete = onListDelete,
onDismiss = { isMenuOpen.value = false },
) )
} }
} }
@Composable @Composable
private fun ListOptionsMenu( private fun ListOptionsMenu(
modifier: Modifier = Modifier,
isExpanded: Boolean, isExpanded: Boolean,
setName: String, onListEditMetadata: () -> Unit,
setDescription: String?,
onListRename: (String) -> Unit,
onListDescriptionChange: (String) -> Unit,
onListClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit, onListClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val isRenameDialogOpen = remember { mutableStateOf(false) }
val renameString = remember { mutableStateOf("") }
val isDescriptionModDialogOpen = remember { mutableStateOf(false) }
val isCopyDialogOpen = remember { mutableStateOf(false) } val isCopyDialogOpen = remember { mutableStateOf(false) }
val optionalCloneName = remember { mutableStateOf<String?>(null) } val optionalCloneName = remember { mutableStateOf<String?>(null) }
val optionalCloneDescription = remember { mutableStateOf<String?>(null) } val optionalCloneDescription = remember { mutableStateOf<String?>(null) }
@@ -330,19 +311,10 @@ private fun ListOptionsMenu(
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text(text = stringRes(R.string.follow_set_rename_btn_label)) Text(text = stringRes(R.string.follow_set_edit_list_metadata))
}, },
onClick = { onClick = {
isRenameDialogOpen.value = true onListEditMetadata()
onDismiss()
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_desc_modify_label))
},
onClick = {
isDescriptionModDialogOpen.value = true
onDismiss() onDismiss()
}, },
) )
@@ -365,28 +337,6 @@ private fun ListOptionsMenu(
) )
} }
if (isRenameDialogOpen.value) {
ListRenameDialog(
currentName = setName,
newName = renameString.value,
onStringRenameChange = {
renameString.value = it
},
onDismissDialog = { isRenameDialogOpen.value = false },
onListRename = {
onListRename(renameString.value)
},
)
}
if (isDescriptionModDialogOpen.value) {
ListModifyDescriptionDialog(
currentDescription = setDescription,
onDismissDialog = { isDescriptionModDialogOpen.value = false },
onModifyDescription = onListDescriptionChange,
)
}
if (isCopyDialogOpen.value) { if (isCopyDialogOpen.value) {
ListCloneDialog( ListCloneDialog(
optionalNewName = optionalCloneName.value, optionalNewName = optionalCloneName.value,
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Stable @Stable
@@ -36,47 +35,6 @@ class PeopleListViewModel : ViewModel() {
fun listFlow() = accountViewModel.account.peopleLists.uiListFlow fun listFlow() = accountViewModel.account.peopleLists.uiListFlow
fun addItem(
title: String,
description: String?,
) {
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.addFollowList(
listName = title,
listDescription = description,
account = accountViewModel.account,
)
}
}
fun openItem(dTag: String) = Route.MyPeopleListView(dTag)
fun renameItem(
followSet: PeopleList,
newValue: String,
) {
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.renameFollowList(
newName = newValue,
peopleList = followSet,
account = accountViewModel.account,
)
}
}
fun changeItemDescription(
followSet: PeopleList,
newDescription: String?,
) {
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.modifyFollowSetDescription(
newDescription = newDescription,
peopleList = followSet,
account = accountViewModel.account,
)
}
}
fun cloneItem( fun cloneItem(
followSet: PeopleList, followSet: PeopleList,
customName: String?, customName: String?,
@@ -0,0 +1,271 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@Composable
fun FollowPackMetadataScreen(
selectedDTag: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val postViewModel: FollowPackMetadataViewModel = viewModel()
postViewModel.init(accountViewModel)
if (selectedDTag != null) {
LaunchedEffect(postViewModel) {
postViewModel.load(selectedDTag)
}
} else {
LaunchedEffect(postViewModel) {
postViewModel.new()
}
}
FollowPackMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
private fun DialogContentPreview() {
val accountViewModel = mockAccountViewModel()
val postViewModel: FollowPackMetadataViewModel = viewModel()
postViewModel.init(accountViewModel)
ThemeComparisonRow {
FollowPackMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FollowPackMetadataScaffold(
postViewModel: FollowPackMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
FollowPackMetadataTopBar(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
},
) { pad ->
LazyColumn(
Modifier
.fillMaxSize()
.padding(
start = 10.dp,
end = 10.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding(),
) {
item {
SettingsCategory(
R.string.follow_pack_title,
R.string.follow_pack_explainer,
SettingsCategoryFirstModifier,
)
ListName(postViewModel)
Spacer(modifier = DoubleVertSpacer)
Picture(postViewModel, accountViewModel)
Spacer(modifier = DoubleVertSpacer)
Description(postViewModel)
}
}
}
}
@Composable
fun FollowPackMetadataTopBar(
postViewModel: FollowPackMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (postViewModel.isNewPack) {
CreatingTopBar(
titleRes = R.string.follow_pack_creation_dialog_title,
isActive = postViewModel::canPost,
onCancel = {
postViewModel.clear()
nav.popBack()
},
onPost = {
try {
postViewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
} else {
SavingTopBar(
titleRes = R.string.follow_pack_edit_list_metadata,
isActive = postViewModel::canPost,
onCancel = {
postViewModel.clear()
nav.popBack()
},
onPost = {
try {
postViewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
}
}
@Composable
private fun Description(postViewModel: FollowPackMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.follow_pack_creation_desc_label)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.description.value,
onValueChange = { postViewModel.description.value = it },
placeholder = {
Text(
text = stringRes(R.string.about_us),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
minLines = 3,
)
}
@Composable
private fun Picture(
postViewModel: FollowPackMetadataViewModel,
accountViewModel: AccountViewModel,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.picture_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = {
val context = LocalContext.current
SelectSingleFromGallery(
isUploading = postViewModel.isUploadingImageForPicture,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 2.dp),
) {
postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast)
}
},
)
}
@Composable
private fun ListName(postViewModel: FollowPackMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.follow_pack_creation_name_label)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.name.value,
onValueChange = { postViewModel.name.value = it },
placeholder = {
Text(
text = stringRes(R.string.follow_pack_copy_name_label),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@@ -0,0 +1,187 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class FollowPackMetadataViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
var peopleList by mutableStateOf<PeopleList?>(null)
val isNewPack by derivedStateOf { peopleList == null }
val name = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue())
var isUploadingImageForPicture by mutableStateOf(false)
val canPost by derivedStateOf {
name.value.text.isNotBlank()
}
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun new() {
peopleList = null
clear()
}
fun load(dTag: String) {
peopleList = account.followLists.selectList(dTag)
name.value = TextFieldValue(peopleList?.title ?: "")
picture.value = TextFieldValue(peopleList?.image ?: "")
description.value = TextFieldValue(peopleList?.description ?: "")
}
fun createOrUpdate() {
accountViewModel.launchSigner {
val peopleList = peopleList
if (peopleList == null) {
val newListIdentifier =
accountViewModel.account.followLists.addFollowList(
name = name.value.text,
desc = description.value.text,
image = picture.value.text,
account = accountViewModel.account,
)
} else {
accountViewModel.account.followLists.updateMetadata(
name = name.value.text,
desc = description.value.text,
image = picture.value.text,
peopleList = peopleList,
account = accountViewModel.account,
)
}
clear()
}
}
fun clear() {
name.value = TextFieldValue()
picture.value = TextFieldValue()
description.value = TextFieldValue()
}
fun uploadForPicture(
uri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = { isUploadingImageForPicture = it },
onUploaded = { picture.value = TextFieldValue(it) },
onError = onError,
)
}
}
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
}
} catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload))
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
@@ -0,0 +1,271 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@Composable
fun PeopleListMetadataScreen(
selectedDTag: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val postViewModel: PeopleListMetadataViewModel = viewModel()
postViewModel.init(accountViewModel)
if (selectedDTag != null) {
LaunchedEffect(postViewModel) {
postViewModel.load(selectedDTag)
}
} else {
LaunchedEffect(postViewModel) {
postViewModel.new()
}
}
PeopleListMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
private fun DialogContentPreview() {
val accountViewModel = mockAccountViewModel()
val postViewModel: PeopleListMetadataViewModel = viewModel()
postViewModel.init(accountViewModel)
ThemeComparisonRow {
PeopleListMetadataScaffold(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PeopleListMetadataScaffold(
postViewModel: PeopleListMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
PeopleListMetadataTopBar(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
},
) { pad ->
LazyColumn(
Modifier
.fillMaxSize()
.padding(
start = 10.dp,
end = 10.dp,
top = pad.calculateTopPadding(),
bottom = pad.calculateBottomPadding(),
).consumeWindowInsets(pad)
.imePadding(),
) {
item {
SettingsCategory(
R.string.people_list_title,
R.string.people_list_explainer,
SettingsCategoryFirstModifier,
)
ListName(postViewModel)
Spacer(modifier = DoubleVertSpacer)
Picture(postViewModel, accountViewModel)
Spacer(modifier = DoubleVertSpacer)
Description(postViewModel)
}
}
}
}
@Composable
fun PeopleListMetadataTopBar(
postViewModel: PeopleListMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (postViewModel.isNewList) {
CreatingTopBar(
titleRes = R.string.follow_set_creation_dialog_title,
isActive = postViewModel::canPost,
onCancel = {
postViewModel.clear()
nav.popBack()
},
onPost = {
try {
postViewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
} else {
SavingTopBar(
titleRes = R.string.follow_set_edit_list_metadata,
isActive = postViewModel::canPost,
onCancel = {
postViewModel.clear()
nav.popBack()
},
onPost = {
try {
postViewModel.createOrUpdate()
nav.popBack()
} catch (e: SignerExceptions.ReadOnlyException) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_sign_events,
)
}
},
)
}
}
@Composable
private fun Description(postViewModel: PeopleListMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.follow_set_creation_desc_label)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.description.value,
onValueChange = { postViewModel.description.value = it },
placeholder = {
Text(
text = stringRes(R.string.about_us),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
minLines = 3,
)
}
@Composable
private fun Picture(
postViewModel: PeopleListMetadataViewModel,
accountViewModel: AccountViewModel,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.picture_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = {
val context = LocalContext.current
SelectSingleFromGallery(
isUploading = postViewModel.isUploadingImageForPicture,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 2.dp),
) {
postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast)
}
},
)
}
@Composable
private fun ListName(postViewModel: PeopleListMetadataViewModel) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.follow_set_creation_name_label)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.name.value,
onValueChange = { postViewModel.name.value = it },
placeholder = {
Text(
text = stringRes(R.string.follow_set_copy_name_label),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@@ -0,0 +1,188 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class PeopleListMetadataViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
var peopleList by mutableStateOf<PeopleList?>(null)
val isNewList by derivedStateOf { peopleList == null }
val name = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue())
var isUploadingImageForPicture by mutableStateOf(false)
val canPost by derivedStateOf {
name.value.text.isNotBlank()
}
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun new() {
peopleList = null
clear()
}
fun load(dTag: String) {
peopleList = account.peopleLists.selectList(dTag)
name.value = TextFieldValue(peopleList?.title ?: "")
picture.value = TextFieldValue(peopleList?.image ?: "")
description.value = TextFieldValue(peopleList?.description ?: "")
}
fun isNewChannel() = peopleList == null
fun createOrUpdate() {
accountViewModel.launchSigner {
val peopleList = peopleList
if (peopleList == null) {
accountViewModel.account.peopleLists.addFollowList(
listName = name.value.text,
listDescription = description.value.text,
listImage = picture.value.text,
account = accountViewModel.account,
)
} else {
accountViewModel.account.peopleLists.updateMetadata(
listName = name.value.text,
listDescription = description.value.text,
listImage = picture.value.text,
peopleList = peopleList,
account = accountViewModel.account,
)
}
clear()
}
}
fun clear() {
name.value = TextFieldValue()
picture.value = TextFieldValue()
description.value = TextFieldValue()
}
fun uploadForPicture(
uri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = { isUploadingImageForPicture = it },
onUploaded = { picture.value = TextFieldValue(it) },
onError = onError,
)
}
}
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
}
} catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload))
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
}
@@ -42,8 +42,9 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.PeopleListFabsAndMenu import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
@@ -95,17 +96,8 @@ fun FollowListAndPackAndUserView(
color = MaterialTheme.colorScheme.grayText, color = MaterialTheme.colorScheme.grayText,
) )
} }
PeopleListFabsAndMenu( NewListButton(
title = R.string.follow_set_creation_dialog_title, onClick = { nav.nav(Route.PeopleListMetadataEdit()) },
onAddSet = { title, description ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.addFollowList(
listName = title,
listDescription = description,
account = accountViewModel.account,
)
}
},
) )
} }
} }
@@ -162,17 +154,8 @@ fun FollowListAndPackAndUserView(
color = MaterialTheme.colorScheme.grayText, color = MaterialTheme.colorScheme.grayText,
) )
} }
PeopleListFabsAndMenu( NewListButton(
title = R.string.follow_pack_creation_dialog_title, onClick = { nav.nav(Route.FollowPackMetadataEdit()) },
onAddSet = { title, description ->
accountViewModel.launchSigner {
accountViewModel.account.followLists.addFollowList(
name = title,
desc = description,
account = accountViewModel.account,
)
}
},
) )
} }
} }
+19
View File
@@ -562,6 +562,7 @@
<string name="follow_set_creation_action_btn_label">Create list</string> <string name="follow_set_creation_action_btn_label">Create list</string>
<string name="follow_set_copy_action_btn_label">Copy/Clone list</string> <string name="follow_set_copy_action_btn_label">Copy/Clone list</string>
<string name="follow_set_rename_btn_label">Rename list</string> <string name="follow_set_rename_btn_label">Rename list</string>
<string name="follow_set_edit_list_metadata">Edit List</string>
<string name="follow_set_desc_modify_btn_label">Modify</string> <string name="follow_set_desc_modify_btn_label">Modify</string>
<string name="follow_set_rename_dialog_indicator_first_part">You are renaming from </string> <string name="follow_set_rename_dialog_indicator_first_part">You are renaming from </string>
<string name="follow_set_rename_dialog_indicator_second_part"> to..</string> <string name="follow_set_rename_dialog_indicator_second_part"> to..</string>
@@ -1356,4 +1357,22 @@
<string name="remove_user_from_the_list">Remove user from the list</string> <string name="remove_user_from_the_list">Remove user from the list</string>
<string name="follow_list_item_label">Follow Pack</string> <string name="follow_list_item_label">Follow Pack</string>
<string name="members">Members</string> <string name="members">Members</string>
<string name="people_list_title">Follow List Metadata</string>
<string name="people_list_explainer">Follow lists metadata can be seen by anyone on Nostr. Only your private members are encrypted.</string>
<string name="follow_pack_title">Follow Pack Metadata</string>
<string name="follow_pack_explainer">Follow pack metadata can be seen by anyone on Nostr and is frequently published in many websites as startup kits for new users.</string>
<string name="follow_pack_edit_list_metadata">Edit Follow Pack</string>
<string name="follow_pack_creation_name_label">Pack name</string>
<string name="follow_pack_copy_name_label">New Pack name</string>
<string name="follow_pack_creation_desc_label">Pack description</string>
<string name="follow_pack_copy_desc_label">New Pack description</string>
<string name="follow_set_broadcast">Broadcast List</string>
<string name="follow_pack_broadcast">Broadcast Pack</string>
<string name="follow_set_delete">Delete List</string>
<string name="follow_pack_delete">Delete Pack</string>
</resources> </resources>
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip51Lists.followList package com.vitorpamplona.quartz.nip51Lists.followList
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
@@ -70,6 +71,16 @@ class FollowListEvent(
const val KIND = 39089 const val KIND = 39089
const val ALT = "List of people to follow" const val ALT = "List of people to follow"
fun createAddress(
pubKey: HexKey,
dTag: String,
) = Address(KIND, pubKey, dTag)
fun listFor(
pubKey: HexKey,
dTag: String,
): String = Address.assemble(KIND, pubKey, dTag)
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
suspend fun create( suspend fun create(
name: String, name: String,
@@ -40,8 +40,8 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.replaceAll
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
@@ -70,6 +70,8 @@ class PeopleListEvent(
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun users() = tags.users() fun users() = tags.users()
fun countMutes() = tags.count(MuteTag::isTagged) fun countMutes() = tags.count(MuteTag::isTagged)
@@ -87,7 +89,17 @@ class PeopleListEvent(
fun createBlockAddress(pubKey: HexKey) = Address(KIND, pubKey, BLOCK_LIST_D_TAG) fun createBlockAddress(pubKey: HexKey) = Address(KIND, pubKey, BLOCK_LIST_D_TAG)
fun blockListFor(pubKeyHex: HexKey): String = "30000:$pubKeyHex:$BLOCK_LIST_D_TAG" fun blockListFor(pubKeyHex: HexKey): String = Address.assemble(KIND, pubKeyHex, BLOCK_LIST_D_TAG)
fun createAddress(
pubKey: HexKey,
dTag: String,
) = Address(KIND, pubKey, dTag)
fun listFor(
pubKey: HexKey,
dTag: String,
): String = Address.assemble(KIND, pubKey, dTag)
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
suspend fun create( suspend fun create(
@@ -378,69 +390,5 @@ class PeopleListEvent(
signer = signer, signer = signer,
createdAt = createdAt, createdAt = createdAt,
) )
suspend fun modifyListName(
earlierVersion: PeopleListEvent,
newName: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): PeopleListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val currentTitle = earlierVersion.tags.first { it[0] == NameTag.TAG_NAME || it[0] == TitleTag.TAG_NAME }
val newTitleTag =
if (currentTitle[0] == NameTag.TAG_NAME) {
NameTag.assemble(newName)
} else {
TitleTag.assemble(newName)
}
return resign(
publicTags = earlierVersion.tags.replaceAll(currentTitle, newTitleTag),
privateTags = privateTags.replaceAll(currentTitle, newTitleTag),
signer = signer,
createdAt = createdAt,
)
}
suspend fun modifyDescription(
earlierVersion: PeopleListEvent,
newDescription: String?,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): PeopleListEvent? {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val currentDescriptionTag = earlierVersion.tags.firstOrNull { it[0] == DescriptionTag.TAG_NAME }
val currentDescription = currentDescriptionTag?.get(1)
if (currentDescription.equals(newDescription)) {
// Do nothing
return null
} else {
if (newDescription == null || newDescription.isEmpty()) {
return resign(
publicTags = earlierVersion.tags.remove { it[0] == DescriptionTag.TAG_NAME },
privateTags = privateTags.remove { it[0] == DescriptionTag.TAG_NAME },
signer = signer,
createdAt = createdAt,
)
} else {
val newDescriptionTag = DescriptionTag.assemble(newDescription)
return if (currentDescriptionTag == null) {
resign(
publicTags = earlierVersion.tags.plusElement(newDescriptionTag),
privateTags = privateTags,
signer = signer,
createdAt = createdAt,
)
} else {
resign(
publicTags = earlierVersion.tags.replaceAll(currentDescriptionTag, newDescriptionTag),
privateTags = privateTags.replaceAll(currentDescriptionTag, newDescriptionTag),
signer = signer,
createdAt = createdAt,
)
}
}
}
}
} }
} }
@@ -22,8 +22,14 @@ package com.vitorpamplona.quartz.nip51Lists.peopleList
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
fun TagArrayBuilder<PeopleListEvent>.name(name: String) = addUnique(NameTag.assemble(name)) fun TagArrayBuilder<PeopleListEvent>.name(name: String) = addUnique(NameTag.assemble(name))
fun TagArrayBuilder<PeopleListEvent>.description(desc: String) = addUnique(DescriptionTag.assemble(desc))
fun TagArrayBuilder<PeopleListEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
fun TagArrayBuilder<PeopleListEvent>.peoples(peoples: List<UserTag>) = addAll(peoples.map { it.toTagArray() }) fun TagArrayBuilder<PeopleListEvent>.peoples(peoples: List<UserTag>) = addAll(peoples.map { it.toTagArray() })