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.nip51Lists.followList.FollowListEvent
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.personFirst
import com.vitorpamplona.quartz.nip51Lists.followList.removePerson
import com.vitorpamplona.quartz.nip51Lists.followList.title
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -110,6 +114,7 @@ class FollowListsState(
identifierTag = this.dTag(),
title = this.title() ?: this.dTag(),
description = this.description(),
image = this.image(),
privateMembers = emptySet(),
publicMembers = cache.load(this.followIdSet()),
)
@@ -123,13 +128,17 @@ class FollowListsState(
.flowOn(Dispatchers.IO)
.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
.map { peopleLists ->
peopleLists.firstOrNull { list -> list.identifierTag == selectedDTag }
}.onStart {
emit(uiListFlow.value.firstOrNull { it.identifierTag == selectedDTag })
}
.map { it.select(dTag) }
.onStart { emit(selectList(dTag)) }
fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex)
@@ -175,51 +184,43 @@ class FollowListsState(
suspend fun addFollowList(
name: String,
desc: String?,
image: String?,
member: User? = null,
isPrivate: Boolean = false,
account: Account,
) {
): String {
val dTag = UUID.randomUUID().toString()
val newListTemplate =
FollowListEvent.build(
name = name,
people = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
dTag = UUID.randomUUID().toString(),
dTag = dTag,
) {
if (desc != null) description(desc)
if (image != null) image(image)
}
val newList = signer.sign(newListTemplate)
account.sendMyPublicAndPrivateOutbox(newList)
return dTag
}
suspend fun renameFollowList(
newName: String,
followPack: PeopleList,
suspend fun updateMetadata(
name: String?,
desc: String?,
image: String?,
peopleList: PeopleList,
account: Account,
) {
val listEvent = getPeopleList(followPack.identifierTag)
val listEvent = getPeopleList(peopleList.identifierTag)
val template =
listEvent.update {
title(newName)
}
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)
if (name != null) title(name)
if (desc != null) description(desc)
if (image != null) image(image)
}
val newList = signer.sign(template)
@@ -30,6 +30,7 @@ data class PeopleList(
val identifierTag: String,
val title: String,
val description: String?,
val image: String?,
val privateMembers: 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.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.update
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.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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -116,6 +121,7 @@ class PeopleListsState(
identifierTag = this.dTag(),
title = this.nameOrTitle() ?: this.dTag(),
description = this.description(),
image = this.image(),
privateMembers = cache.load(decryptionCache.privateUserIdSet(this)),
publicMembers = cache.load(this.publicUsersIdSet()),
)
@@ -129,17 +135,17 @@ class PeopleListsState(
.flowOn(Dispatchers.IO)
.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
.map { peopleLists ->
peopleLists.firstOrNull { list ->
list.identifierTag == selectedDTag
}
}.onStart {
emit(
uiListFlow.value.firstOrNull { it.identifierTag == selectedDTag },
)
}
.map { it.select(dTag) }
.onStart { emit(selectList(dTag)) }
fun DeletionEvent.hasDeletedAnyPeopleList() = deleteAddressesWithKind(PeopleListEvent.KIND) || deletesAnyEventIn(peopleListsEventIds.value)
@@ -183,49 +189,48 @@ class PeopleListsState(
suspend fun addFollowList(
listName: String,
listDescription: String?,
listImage: String?,
member: User? = null,
isPrivate: Boolean = false,
account: Account,
) {
val newList =
PeopleListEvent.createListWithDescription(
dTag = UUID.randomUUID().toString(),
title = listName,
description = listDescription,
): String {
val dTag = UUID.randomUUID().toString()
val newListTemplate =
PeopleListEvent.build(
dTag = dTag,
name = listName,
publicMembers = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
privateMembers = if (isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
signer = account.signer,
)
) {
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
val newList = signer.sign(newListTemplate)
account.sendMyPublicAndPrivateOutbox(newList)
return dTag
}
suspend fun renameFollowList(
newName: String,
suspend fun updateMetadata(
listName: String?,
listDescription: String?,
listImage: String?,
peopleList: PeopleList,
account: Account,
) {
val listEvent = getPeopleList(peopleList.identifierTag)
val newList =
PeopleListEvent.modifyListName(
earlierVersion = listEvent,
newName = newName,
signer = account.signer,
)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun modifyFollowSetDescription(
newDescription: String?,
peopleList: PeopleList,
account: Account,
) {
val listEvent = getPeopleList(peopleList.identifierTag)
val newList =
PeopleListEvent.modifyDescription(
earlierVersion = listEvent,
newDescription = newDescription,
signer = account.signer,
)
val template =
listEvent.update {
if (listName != null) name(listName)
if (listDescription != null) description(listDescription)
if (listImage != null) image(listImage)
}
val newList = signer.sign(template)
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.packs.FollowPackScreen
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.notifications.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
@@ -132,6 +134,9 @@ fun AppNavigation(
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, 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) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
@@ -63,6 +63,14 @@ sealed class Route {
val dTag: String,
) : Route()
@Serializable data class PeopleListMetadataEdit(
val dTag: String? = null,
) : Route()
@Serializable data class FollowPackMetadataEdit(
val dTag: String? = null,
) : Route()
@Serializable data class PeopleListManagement(
val userToAdd: HexKey,
) : Route()
@@ -294,6 +302,15 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
dest.hasRoute<Route.GenericCommentPost>() -> entry.toRoute<Route.GenericCommentPost>()
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 -> {
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
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.PaddingValues
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
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.HorizontalDivider
import androidx.compose.material3.IconButton
@@ -55,29 +62,38 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
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.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
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.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
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.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.RenderAddUserFieldAndSuggestions
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
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.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
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.ThemeComparisonRow
import kotlinx.collections.immutable.persistentListOf
@@ -354,6 +370,10 @@ private fun ListActionsMenuButton(
nav: INav,
) {
ListActionsMenuButton(
note = viewModel::selectedNote,
onEditList = {
nav.nav { Route.PeopleListMetadataEdit(viewModel.selectedDTag.value) }
},
onBroadcastList = {
accountViewModel.launchSigner {
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.User
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -60,6 +61,10 @@ class PeopleListViewModel : ViewModel() {
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun selectedAddress() = PeopleListEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value)
fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress())
fun init(
account: Account,
selectedDTag: String,
@@ -20,6 +20,9 @@
*/
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.PaddingValues
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
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.HorizontalDivider
import androidx.compose.material3.IconButton
@@ -48,29 +55,38 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
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.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
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.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
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.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.RenderAddUserFieldAndSuggestions
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
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.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
@@ -211,6 +227,10 @@ private fun ListActionsMenuButton(
nav: INav,
) {
ListActionsMenuButton(
note = viewModel::selectedNote,
onEditList = {
nav.nav { Route.FollowPackMetadataEdit(viewModel.selectedDTag.value) }
},
onBroadcastList = {
accountViewModel.launchSigner {
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
@Preview(device = "spec:width=2160px,height=2940px,dpi=440")
fun FollowPackViewPreview() {
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -60,6 +61,10 @@ class FollowPackViewModel : ViewModel() {
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun selectedAddress() = FollowListEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value)
fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress())
fun init(
account: Account,
selectedDTag: String,
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Stable
@@ -36,47 +35,6 @@ class FollowPackViewModel : ViewModel() {
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(
followSet: PeopleList,
customName: String?,
@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
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.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -86,19 +87,22 @@ fun AllPeopleListFeedView(
color = MaterialTheme.colorScheme.grayText,
)
}
PeopleListFabsAndMenu(
title = R.string.follow_set_creation_dialog_title,
onAddSet = peopleListModel::addItem,
NewListButton(
onClick = {
nav.nav(Route.PeopleListMetadataEdit())
},
)
}
}
itemsIndexed(peopleListFeedState, key = { _, item -> item.identifierTag }) { _, followSet ->
PeopleListItem(
modifier = Modifier.fillMaxSize().animateItem(),
modifier =
Modifier
.fillMaxSize()
.animateItem(),
peopleList = followSet,
onClick = { nav.nav(peopleListModel.openItem(followSet.identifierTag)) },
onRename = { peopleListModel.renameItem(followSet, it) },
onDescriptionChange = { newDescription -> peopleListModel.changeItemDescription(followSet, newDescription) },
onClick = { nav.nav(Route.MyPeopleListView(followSet.identifierTag)) },
onEditMetadata = { nav.nav(Route.PeopleListMetadataEdit(followSet.identifierTag)) },
onClone = { cloneName, cloneDescription -> peopleListModel.cloneItem(followSet, cloneName, cloneDescription) },
onDelete = { peopleListModel.deleteItem(followSet) },
)
@@ -106,7 +110,10 @@ fun AllPeopleListFeedView(
}
stickyHeader {
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,
horizontalArrangement = SpacedBy5dp,
) {
@@ -122,19 +129,22 @@ fun AllPeopleListFeedView(
color = MaterialTheme.colorScheme.grayText,
)
}
PeopleListFabsAndMenu(
title = R.string.follow_pack_creation_dialog_title,
onAddSet = followPackModel::addItem,
NewListButton(
onClick = {
nav.nav(Route.FollowPackMetadataEdit())
},
)
}
}
itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, followSet ->
PeopleListItem(
modifier = Modifier.fillMaxSize().animateItem(),
modifier =
Modifier
.fillMaxSize()
.animateItem(),
peopleList = followSet,
onClick = { nav.nav(followPackModel.openItem(followSet.identifierTag)) },
onRename = { followPackModel.renameItem(followSet, it) },
onDescriptionChange = { newDescription -> followPackModel.changeItemDescription(followSet, newDescription) },
onClick = { nav.nav(Route.MyFollowPackView(followSet.identifierTag)) },
onEditMetadata = { nav.nav(Route.FollowPackMetadataEdit(followSet.identifierTag)) },
onClone = { cloneName, cloneDescription -> followPackModel.cloneItem(followSet, cloneName, cloneDescription) },
onDelete = { followPackModel.deleteItem(followSet) },
)
@@ -147,7 +157,9 @@ fun AllPeopleListFeedView(
@Composable
fun AllPeopleListFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) {
Column(
Modifier.fillMaxSize().padding(horizontal = Size40dp),
Modifier
.fillMaxSize()
.padding(horizontal = Size40dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@@ -31,8 +31,6 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -81,17 +79,8 @@ fun ListOfPeopleListsScreen(
}
@Composable
fun PeopleListFabsAndMenu(
title: Int = R.string.follow_set_creation_dialog_title,
onAddSet: (name: String, description: String?) -> Unit,
) {
val isSetAdditionDialogOpen = remember { mutableStateOf(false) }
OutlinedButton(
onClick = {
isSetAdditionDialogOpen.value = true
},
) {
fun NewListButton(onClick: () -> Unit) {
OutlinedButton(onClick = onClick) {
Row(horizontalArrangement = SpacedBy5dp, verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.AutoMirrored.Filled.PlaylistAdd,
@@ -100,16 +89,4 @@ fun PeopleListFabsAndMenu(
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",
title = "Sample List Title, Very long title, very very very long",
description = "Sample List Description",
image = "http://some.com/image.png",
emptySet(),
emptySet(),
)
@@ -92,6 +93,7 @@ private fun PeopleListItemPreview() {
identifierTag = "00001-2223",
title = "Sample List Title",
description = "Sample List Description",
image = "http://some.com/image.png",
setOf(user1, user3),
emptySet(),
)
@@ -101,6 +103,7 @@ private fun PeopleListItemPreview() {
identifierTag = "00001-2224",
title = "Sample List Title",
description = "Sample List Description",
image = "http://some.com/image.png",
emptySet(),
setOf(user1, user3),
)
@@ -110,6 +113,7 @@ private fun PeopleListItemPreview() {
identifierTag = "00001-2225",
title = "Sample List Title",
description = "Sample List Description",
image = "http://some.com/image.png",
setOf(user3),
setOf(user1, user2, user3),
)
@@ -120,8 +124,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier,
peopleList = samplePeopleList1,
onClick = {},
onRename = {},
onDescriptionChange = { },
onEditMetadata = {},
onClone = { newName, newDesc -> },
onDelete = {},
)
@@ -129,8 +132,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier,
peopleList = samplePeopleList2,
onClick = {},
onRename = {},
onDescriptionChange = { },
onEditMetadata = {},
onClone = { newName, newDesc -> },
onDelete = {},
)
@@ -138,8 +140,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier,
peopleList = samplePeopleList3,
onClick = {},
onRename = {},
onDescriptionChange = { },
onEditMetadata = {},
onClone = { newName, newDesc -> },
onDelete = {},
)
@@ -147,8 +148,7 @@ private fun PeopleListItemPreview() {
modifier = Modifier,
peopleList = samplePeopleList4,
onClick = {},
onRename = {},
onDescriptionChange = { },
onEditMetadata = {},
onClone = { newName, newDesc -> },
onDelete = {},
)
@@ -161,8 +161,7 @@ fun PeopleListItem(
modifier: Modifier = Modifier,
peopleList: PeopleList,
onClick: () -> Unit,
onRename: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onEditMetadata: () -> Unit,
onClone: (customName: String?, customDescription: String?) -> Unit,
onDelete: () -> Unit,
) {
@@ -186,10 +185,7 @@ fun PeopleListItem(
horizontalAlignment = Alignment.End,
) {
PeopleListOptionsButton(
peopleListName = peopleList.title,
peopleListDescription = peopleList.description,
onListRename = onRename,
onListDescriptionChange = onDescriptionChange,
onListEditMetadata = onEditMetadata,
onListCloneCreate = onClone,
onListDelete = onDelete,
)
@@ -276,10 +272,7 @@ fun DisplayParticipantNumberAndStatus(
@Composable
private fun PeopleListOptionsButton(
modifier: Modifier = Modifier,
peopleListName: String,
peopleListDescription: String?,
onListRename: (String) -> Unit,
onListDescriptionChange: (String) -> Unit,
onListEditMetadata: () -> Unit,
onListCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit,
onListDelete: () -> Unit,
) {
@@ -291,35 +284,23 @@ private fun PeopleListOptionsButton(
VerticalDotsIcon()
ListOptionsMenu(
setName = peopleListName,
setDescription = peopleListDescription,
isExpanded = isMenuOpen.value,
onDismiss = { isMenuOpen.value = false },
onListRename = onListRename,
onListDescriptionChange = onListDescriptionChange,
onListEditMetadata = onListEditMetadata,
onListClone = onListCloneCreate,
onDelete = onListDelete,
onDismiss = { isMenuOpen.value = false },
)
}
}
@Composable
private fun ListOptionsMenu(
modifier: Modifier = Modifier,
isExpanded: Boolean,
setName: String,
setDescription: String?,
onListRename: (String) -> Unit,
onListDescriptionChange: (String) -> Unit,
onListEditMetadata: () -> Unit,
onListClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit,
) {
val isRenameDialogOpen = remember { mutableStateOf(false) }
val renameString = remember { mutableStateOf("") }
val isDescriptionModDialogOpen = remember { mutableStateOf(false) }
val isCopyDialogOpen = remember { mutableStateOf(false) }
val optionalCloneName = remember { mutableStateOf<String?>(null) }
val optionalCloneDescription = remember { mutableStateOf<String?>(null) }
@@ -330,19 +311,10 @@ private fun ListOptionsMenu(
) {
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_rename_btn_label))
Text(text = stringRes(R.string.follow_set_edit_list_metadata))
},
onClick = {
isRenameDialogOpen.value = true
onDismiss()
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_desc_modify_label))
},
onClick = {
isDescriptionModDialogOpen.value = true
onListEditMetadata()
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) {
ListCloneDialog(
optionalNewName = optionalCloneName.value,
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Stable
@@ -36,47 +35,6 @@ class PeopleListViewModel : ViewModel() {
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(
followSet: PeopleList,
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.service.relayClient.reqCommand.user.observeUserName
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.lists.list.PeopleListFabsAndMenu
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
@@ -95,17 +96,8 @@ fun FollowListAndPackAndUserView(
color = MaterialTheme.colorScheme.grayText,
)
}
PeopleListFabsAndMenu(
title = R.string.follow_set_creation_dialog_title,
onAddSet = { title, description ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.addFollowList(
listName = title,
listDescription = description,
account = accountViewModel.account,
)
}
},
NewListButton(
onClick = { nav.nav(Route.PeopleListMetadataEdit()) },
)
}
}
@@ -162,17 +154,8 @@ fun FollowListAndPackAndUserView(
color = MaterialTheme.colorScheme.grayText,
)
}
PeopleListFabsAndMenu(
title = R.string.follow_pack_creation_dialog_title,
onAddSet = { title, description ->
accountViewModel.launchSigner {
accountViewModel.account.followLists.addFollowList(
name = title,
desc = description,
account = accountViewModel.account,
)
}
},
NewListButton(
onClick = { nav.nav(Route.FollowPackMetadataEdit()) },
)
}
}
+19
View File
@@ -562,6 +562,7 @@
<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_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_rename_dialog_indicator_first_part">You are renaming from </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="follow_list_item_label">Follow Pack</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>