diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt index 8c17c7b79..983e623a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -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.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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt index e1da41772..c0f1af20a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt @@ -30,6 +30,7 @@ data class PeopleList( val identifierTag: String, val title: String, val description: String?, + val image: String?, val privateMembers: Set = emptySet(), val publicMembers: Set = emptySet(), ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt index fe4e4263e..7dfd32898 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt @@ -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.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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c4ee7eaba..b3d2671ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { FollowPackScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) } + composableFromBottomArgs { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f0f31c890..778073e83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() + else -> { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt deleted file mode 100644 index 9912ac0e0..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/ListActionsMenuButton.kt +++ /dev/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() - }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt index 707c33f80..ff72e8bd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt @@ -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 + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt index 73745a9ba..168f8484b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt index f8c44b093..743fdf8c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt @@ -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() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt index c7289a338..c51741b07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt index 5fa98ee4f..83fe0d8c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt @@ -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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt index 448dae60d..dd66d079d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt @@ -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, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt index 7b63414e4..a56ebdfaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt @@ -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) - }, - ) - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index 87e71d986..5c9b84e41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -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(null) } val optionalCloneDescription = remember { mutableStateOf(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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt index fd036ed02..184a8a926 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt @@ -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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt new file mode 100644 index 000000000..47e33051d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataScreen.kt @@ -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), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt new file mode 100644 index 000000000..2cb4ee064 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt @@ -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(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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt new file mode 100644 index 000000000..fa6e1925e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataScreen.kt @@ -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), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt new file mode 100644 index 000000000..5063e6e36 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt @@ -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(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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt index 74a4ff24b..e6d7391c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt @@ -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()) }, ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 08bee6659..86da1355d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -562,6 +562,7 @@ Create list Copy/Clone list Rename list + Edit List Modify You are renaming from to.. @@ -1356,4 +1357,22 @@ Remove user from the list Follow Pack Members + + Follow List Metadata + Follow lists metadata can be seen by anyone on Nostr. Only your private members are encrypted. + + Follow Pack Metadata + Follow pack metadata can be seen by anyone on Nostr and is frequently published in many websites as startup kits for new users. + + Edit Follow Pack + Pack name + New Pack name + Pack description + New Pack description + + Broadcast List + Broadcast Pack + + Delete List + Delete Pack diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt index 9d849eec9..4ab945171 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip51Lists.followList 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.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -70,6 +71,16 @@ class FollowListEvent( const val KIND = 39089 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) suspend fun create( name: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt index 3af970d2a..9e6aa6041 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt @@ -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.UserTag 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.ImageTag import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi @@ -70,6 +70,8 @@ class PeopleListEvent( fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + fun users() = tags.users() fun countMutes() = tags.count(MuteTag::isTagged) @@ -87,7 +89,17 @@ class PeopleListEvent( 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) suspend fun create( @@ -378,69 +390,5 @@ class PeopleListEvent( signer = signer, 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, - ) - } - } - } - } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt index 2c9053ab2..b3e67ad42 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt @@ -22,8 +22,14 @@ package com.vitorpamplona.quartz.nip51Lists.peopleList import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder 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 fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) +fun TagArrayBuilder.description(desc: String) = addUnique(DescriptionTag.assemble(desc)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + fun TagArrayBuilder.peoples(peoples: List) = addAll(peoples.map { it.toTagArray() })