Adds creation, edits and member update for Follow Packs

This commit is contained in:
Vitor Pamplona
2025-11-12 13:50:57 -05:00
parent adb484bf56
commit 0ddbb4616b
20 changed files with 1723 additions and 565 deletions
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.model.nip51Lists.peopleList
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -30,8 +32,16 @@ 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.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.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.utils.flattenToSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -46,6 +56,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
import java.util.UUID
/**
* Maintains several stateflows for each step in processing PeopleLists
@@ -59,7 +70,7 @@ class FollowListsState(
) {
val user = cache.getOrCreateUser(signer.pubKey)
fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.Companion.KIND, user.pubkeyHex)
fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.KIND, user.pubkeyHex)
val followListVersions = MutableStateFlow(0)
@@ -83,7 +94,7 @@ class FollowListsState(
.transformLatest { emitAll(it.updateFlow<FollowListEvent>()) }
.onStart { emit(followListNotes.value.events()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList())
.stateIn(scope, SharingStarted.Eagerly, emptyList())
fun List<FollowListEvent>.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet()
@@ -112,6 +123,14 @@ class FollowListsState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyList())
fun selectListFlow(selectedDTag: String) =
uiListFlow
.map { peopleLists ->
peopleLists.firstOrNull { list -> list.identifierTag == selectedDTag }
}.onStart {
emit(uiListFlow.value.firstOrNull { it.identifierTag == selectedDTag })
}
fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex)
fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.Companion.KIND) || deletesAnyEventIn(followListsEventIds.value)
@@ -140,4 +159,154 @@ class FollowListsState(
fun forceRefresh() {
followListVersions.update { it + 1 }
}
// --------------
// Updating Lists
// --------------
fun getPeopleListNote(noteIdentifier: String): AddressableNote? = existingPeopleListNotes().find { it.dTag() == noteIdentifier }
fun getPeopleList(noteIdentifier: String): FollowListEvent = getPeopleListNote(noteIdentifier)?.event as FollowListEvent
fun User.toUserTag() = UserTag(this.pubkeyHex, this.bestRelayHint())
fun Set<User>.toUserTags() = map { it.toUserTag() }
suspend fun addFollowList(
name: String,
desc: String?,
member: User? = null,
isPrivate: Boolean = false,
account: Account,
) {
val newListTemplate =
FollowListEvent.build(
name = name,
people = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(),
dTag = UUID.randomUUID().toString(),
) {
if (desc != null) description(desc)
}
val newList = signer.sign(newListTemplate)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun renameFollowList(
newName: String,
followPack: PeopleList,
account: Account,
) {
val listEvent = getPeopleList(followPack.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)
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun cloneFollowSet(
currentFollowPack: PeopleList,
customCloneName: String?,
customCloneDescription: String?,
account: Account,
) {
val listEvent = getPeopleList(currentFollowPack.identifierTag)
val template =
listEvent.update {
// new list
dTag(UUID.randomUUID().toString())
// updates names
if (customCloneName != null) title(customCloneName)
if (customCloneDescription != null) description(customCloneDescription)
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun deleteFollowSet(
identifierTag: String,
account: Account,
) {
val followListEvent = getPeopleList(identifierTag)
val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent)))
account.sendMyPublicAndPrivateOutbox(deletionEvent)
}
suspend fun addUserToSet(
user: User,
identifierTag: String,
account: Account,
) {
val followListEvent = getPeopleList(identifierTag)
val template =
followListEvent.update {
person(user.pubkeyHex, user.bestRelayHint())
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun addUserFirstToSet(
user: User,
identifierTag: String,
account: Account,
) {
val followListEvent = getPeopleList(identifierTag)
val template =
followListEvent.update {
personFirst(user.pubkeyHex, user.bestRelayHint())
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
suspend fun removeUserFromSet(
user: User,
identifierTag: String,
account: Account,
) {
val followListEvent = getPeopleList(identifierTag)
val template =
followListEvent.update {
removePerson(user.pubkeyHex)
}
val newList = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newList)
}
}
@@ -79,9 +79,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.EditPeopleListScreen
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
@@ -127,8 +128,9 @@ fun AppNavigation(
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.PeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.PeopleListManagement> { EditPeopleListScreen(it.userToAdd, accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.PeopleListManagement> { FollowListAndPackAndUserScreen(it.userToAdd, accountViewModel, nav) }
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.navigation.routes
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.toRoute
import com.vitorpamplona.amethyst.ui.navigation.routes.Route.Room
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
@@ -56,7 +55,11 @@ sealed class Route {
@Serializable object Lists : Route()
@Serializable data class PeopleListView(
@Serializable data class MyPeopleListView(
val dTag: String,
) : Route()
@Serializable data class MyFollowPackView(
val dTag: String,
) : Route()
@@ -0,0 +1,104 @@
/**
* 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()
},
)
}
}
@@ -0,0 +1,281 @@
/**
* 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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.LightRedColor
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
@Composable
fun RenderAddUserFieldAndSuggestions(
userSuggestions: UserSuggestionState,
hasUserFlow: (User) -> Flow<Boolean>,
addUserToSet: (User) -> Unit,
removeUserFromSet: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
UserSearchDataSourceSubscription(userSuggestions, accountViewModel)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
userSuggestions.invalidateData()
}
}
launch(Dispatchers.IO) {
LocalCache.live.deletedEventBundles.collect {
userSuggestions.invalidateData()
}
}
}
Spacer(HalfVertSpacer)
var userName by remember(userSuggestions) { mutableStateOf(TextFieldValue(userSuggestions.currentWord.value)) }
val focusManager = LocalFocusManager.current
OutlinedTextField(
label = { Text(text = stringRes(R.string.search_and_add_a_user)) },
modifier = Modifier.padding(horizontal = Size10dp).fillMaxWidth(),
value = userName,
onValueChange = {
userName = it
userSuggestions.processCurrentWord(it.text)
},
singleLine = true,
trailingIcon = {
IconButton(
onClick = {
userName = TextFieldValue("")
userSuggestions.processCurrentWord("")
focusManager.clearFocus()
},
) {
ClearTextIcon()
}
},
)
ShowUserSuggestions(
userSuggestions = userSuggestions,
hasUserFlow = hasUserFlow,
onSelect = { user ->
addUserToSet(user)
userName =
userName.copy(
selection = TextRange(0, userName.text.length),
)
},
onDelete = { user ->
removeUserFromSet(user)
userName =
userName.copy(
selection = TextRange(0, userName.text.length),
)
},
accountViewModel = accountViewModel,
)
}
@Composable
fun ShowUserSuggestions(
userSuggestions: UserSuggestionState,
hasUserFlow: (User) -> Flow<Boolean>,
onSelect: (User) -> Unit,
onDelete: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
val listState = rememberLazyListState()
AnimateOnNewSearch(userSuggestions, listState)
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
if (suggestions.isNotEmpty()) {
Card(
modifier = Modifier.padding(start = 11.dp, end = 11.dp),
elevation = cardElevation(5.dp),
shape = PopupUpEffect,
) {
LazyColumn(
contentPadding = PaddingValues(top = 10.dp),
modifier =
Modifier
.heightIn(0.dp, 200.dp),
state = listState,
) {
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser ->
DrawUser(baseUser, hasUserFlow, onSelect, onDelete, accountViewModel)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
Spacer(StdVertSpacer)
}
@Composable
fun DrawUser(
baseUser: User,
hasUserFlow: (User) -> Flow<Boolean>,
onSelect: (User) -> Unit,
onDelete: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = { onSelect(baseUser) })
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null)
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(
baseUser,
accountViewModel = accountViewModel,
)
HasUserTag(baseUser, hasUserFlow, onDelete)
}
AboutDisplay(baseUser, accountViewModel)
}
}
}
@Composable
private fun RowScope.HasUserTag(
baseUser: User,
hasUserFlow: (User) -> Flow<Boolean>,
onDelete: (User) -> Unit,
) {
val hasUserState by hasUserFlow(baseUser).collectAsStateWithLifecycle(false)
if (hasUserState) {
Spacer(StdHorzSpacer)
Text(
text = stringRes(id = R.string.in_the_list),
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
remember {
Modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 5.dp)
},
)
Spacer(Modifier.weight(1f))
IconButton(
modifier = Modifier.size(30.dp).padding(start = 10.dp),
onClick = { onDelete(baseUser) },
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = stringRes(id = R.string.remove),
modifier = Modifier.size(15.dp),
tint = LightRedColor,
)
}
}
}
@@ -18,16 +18,10 @@
* 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
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -35,23 +29,15 @@ 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.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -62,60 +48,39 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
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.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
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.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
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.LightRedColor
import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@@ -173,7 +138,7 @@ fun PeopleListScreen(
}
@Composable
fun TopAppTabs(
private fun TopAppTabs(
viewModel: PeopleListViewModel,
pagerState: PagerState,
) {
@@ -212,7 +177,7 @@ fun TopAppTabs(
}
@Composable
fun TitleAndDescription(viewModel: PeopleListViewModel) {
private fun TitleAndDescription(viewModel: PeopleListViewModel) {
val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle()
selectedSetState.value?.let { selectedSet ->
Text(
@@ -255,70 +220,20 @@ private fun RenderAddUserFieldAndSuggestions(
pagerState: PagerState,
accountViewModel: AccountViewModel,
) {
UserSearchDataSourceSubscription(viewModel.userSuggestions, accountViewModel)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
viewModel.userSuggestions.invalidateData()
}
}
launch(Dispatchers.IO) {
LocalCache.live.deletedEventBundles.collect {
viewModel.userSuggestions.invalidateData()
}
}
}
Spacer(HalfVertSpacer)
var userName by remember(viewModel) { mutableStateOf(TextFieldValue(viewModel.userSuggestions.currentWord.value)) }
val focusManager = LocalFocusManager.current
OutlinedTextField(
label = { Text(text = stringRes(R.string.search_and_add_a_user)) },
modifier = Modifier.padding(horizontal = Size10dp).fillMaxWidth(),
value = userName,
onValueChange = {
userName = it
viewModel.userSuggestions.processCurrentWord(it.text)
},
singleLine = true,
trailingIcon = {
IconButton(
onClick = {
userName = TextFieldValue("")
viewModel.userSuggestions.processCurrentWord("")
focusManager.clearFocus()
},
) {
ClearTextIcon()
}
},
)
ShowUserSuggestions(
userSuggestions = viewModel.userSuggestions,
RenderAddUserFieldAndSuggestions(
viewModel.userSuggestions,
hasUserFlow = { user ->
viewModel.hasUserFlow(user, pagerState.currentPage == 1)
},
onSelect = { user ->
addUserToSet = { user ->
accountViewModel.launchSigner {
viewModel.addUserToSet(user, pagerState.currentPage == 1)
}
userName =
userName.copy(
selection = TextRange(0, userName.text.length),
)
},
onDelete = { user ->
removeUserFromSet = { user ->
accountViewModel.launchSigner {
viewModel.removeUserFromSet(user, pagerState.currentPage == 1)
}
userName =
userName.copy(
selection = TextRange(0, userName.text.length),
)
},
accountViewModel = accountViewModel,
)
@@ -365,7 +280,7 @@ private fun PeopleListPager(
@Composable
@Preview(device = "spec:width=2160px,height=2940px,dpi=440")
fun FollowSetListViewPreview() {
private fun PeopleListViewPreview() {
val accountViewModel = mockAccountViewModel()
val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
@@ -433,130 +348,7 @@ fun FollowSetListViewPreview() {
}
@Composable
fun ShowUserSuggestions(
userSuggestions: UserSuggestionState,
hasUserFlow: (User) -> Flow<Boolean>,
onSelect: (User) -> Unit,
onDelete: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
val listState = rememberLazyListState()
AnimateOnNewSearch(userSuggestions, listState)
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
if (suggestions.isNotEmpty()) {
Card(
modifier = Modifier.padding(start = 11.dp, end = 11.dp),
elevation = cardElevation(5.dp),
shape = PopupUpEffect,
) {
LazyColumn(
contentPadding = PaddingValues(top = 10.dp),
modifier =
Modifier
.heightIn(0.dp, 200.dp),
state = listState,
) {
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser ->
DrawUser(baseUser, hasUserFlow, onSelect, onDelete, accountViewModel)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
Spacer(StdVertSpacer)
}
@Composable
private fun DrawUser(
baseUser: User,
hasUserFlow: (User) -> Flow<Boolean>,
onSelect: (User) -> Unit,
onDelete: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = { onSelect(baseUser) })
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null)
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
verticalArrangement = Arrangement.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(
baseUser,
accountViewModel = accountViewModel,
)
HasUserTag(baseUser, hasUserFlow, onDelete)
}
AboutDisplay(baseUser, accountViewModel)
}
}
}
@Composable
fun RowScope.HasUserTag(
baseUser: User,
hasUserFlow: (User) -> Flow<Boolean>,
onDelete: (User) -> Unit,
) {
val hasUserState by hasUserFlow(baseUser).collectAsStateWithLifecycle(false)
if (hasUserState) {
Spacer(StdHorzSpacer)
Text(
text = stringRes(id = R.string.in_the_list),
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
remember {
Modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 5.dp)
},
)
Spacer(Modifier.weight(1f))
IconButton(
modifier = Modifier.size(30.dp).padding(start = 10.dp),
onClick = { onDelete(baseUser) },
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = stringRes(id = R.string.remove),
modifier = Modifier.size(15.dp),
tint = LightRedColor,
)
}
}
}
@Composable
fun ListActionsMenuButton(
private fun ListActionsMenuButton(
viewModel: PeopleListViewModel,
accountViewModel: AccountViewModel,
nav: INav,
@@ -577,67 +369,3 @@ fun ListActionsMenuButton(
},
)
}
@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()
},
)
}
}
@@ -18,7 +18,7 @@
* 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
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
@@ -0,0 +1,297 @@
/**
* 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.packs
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
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.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.ThemeComparisonRow
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FollowPackScreen(
selectedDTag: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: FollowPackViewModel = viewModel()
viewModel.init(accountViewModel.account, selectedDTag)
Scaffold(
topBar = {
Column {
TopAppBar(
title = {
TitleAndDescription(viewModel)
},
navigationIcon = {
IconButton(nav::popBack) {
ArrowBackIcon()
}
},
actions = {
ListActionsMenuButton(viewModel, accountViewModel, nav)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}
},
) { padding ->
ListViewAndEditColumn(
viewModel = viewModel,
modifier =
Modifier
.fillMaxSize()
.padding(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
).consumeWindowInsets(padding)
.imePadding(),
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun TitleAndDescription(viewModel: FollowPackViewModel) {
val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle()
selectedSetState.value?.let { selectedSet ->
Text(
text = selectedSet.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ListViewAndEditColumn(
viewModel: FollowPackViewModel,
modifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(modifier = modifier) {
PeopleListPager(
viewModel = viewModel,
modifier = Modifier.weight(1f),
onDeleteUser = { user ->
accountViewModel.launchSigner {
viewModel.removeUserFromSet(user)
}
},
accountViewModel = accountViewModel,
nav = nav,
)
RenderAddUserFieldAndSuggestions(viewModel, accountViewModel)
}
}
@Composable
private fun RenderAddUserFieldAndSuggestions(
viewModel: FollowPackViewModel,
accountViewModel: AccountViewModel,
) {
RenderAddUserFieldAndSuggestions(
viewModel.userSuggestions,
hasUserFlow = { user ->
viewModel.hasUserFlow(user)
},
addUserToSet = { user ->
accountViewModel.launchSigner {
viewModel.addUserToSet(user)
}
},
removeUserFromSet = { user ->
accountViewModel.launchSigner {
viewModel.removeUserFromSet(user)
}
},
accountViewModel = accountViewModel,
)
}
@Composable
private fun PeopleListPager(
viewModel: FollowPackViewModel,
modifier: Modifier,
onDeleteUser: (User) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle()
selectedSetState.value?.let { selectedSet ->
PeopleListView(
memberList = selectedSet.publicMembersList,
onDeleteUser = onDeleteUser,
modifier = modifier,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun ListActionsMenuButton(
viewModel: FollowPackViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
ListActionsMenuButton(
onBroadcastList = {
accountViewModel.launchSigner {
viewModel.loadNote()?.let { updatedSetNote ->
accountViewModel.broadcast(updatedSetNote)
}
}
},
onDeleteList = {
accountViewModel.launchSigner {
viewModel.deleteFollowSet()
}
nav.popBack()
},
)
}
@Composable
@Preview(device = "spec:width=2160px,height=2940px,dpi=440")
fun FollowPackViewPreview() {
val accountViewModel = mockAccountViewModel()
val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
val user2: User = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b")
val user3: User = LocalCache.getOrCreateUser("7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf")
ThemeComparisonRow {
Column {
PeopleListView(
memberList = persistentListOf(user1, user2, user3),
onDeleteUser = { user -> },
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
Spacer(HalfVertSpacer)
var userName by remember { mutableStateOf("") }
OutlinedTextField(
label = { Text(text = stringRes(R.string.search_and_add_a_user)) },
modifier =
Modifier
.padding(horizontal = Size10dp)
.fillMaxWidth(),
value = userName,
onValueChange = {
userName = it
},
singleLine = true,
trailingIcon = {
IconButton(
onClick = {},
) {
ClearTextIcon()
}
},
)
Card(
modifier = Modifier.padding(horizontal = 10.dp),
elevation = cardElevation(5.dp),
shape = PopupUpEffect,
) {
LazyColumn(
contentPadding = PaddingValues(top = 10.dp),
modifier = Modifier.heightIn(0.dp, 200.dp),
) {
itemsIndexed(persistentListOf(user1, user2, user3), key = { _, item -> item.pubkeyHex }) { _, baseUser ->
DrawUser(
baseUser,
{ MutableStateFlow(false) },
{},
{},
accountViewModel,
)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
}
}
@@ -0,0 +1,97 @@
/**
* 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.packs
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
@Stable
class FollowPackViewModel : ViewModel() {
lateinit var account: Account
lateinit var userSuggestions: UserSuggestionState
var userSuggestionFocus by mutableStateOf<UserSuggestionState?>(null)
val selectedDTag = MutableStateFlow("")
@OptIn(ExperimentalCoroutinesApi::class)
val selectedList =
selectedDTag
.transformLatest {
emitAll(
account.followLists.selectListFlow(it).flowOn(Dispatchers.IO),
)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun init(
account: Account,
selectedDTag: String,
) {
if (!this::account.isInitialized || this.account != account) {
this.account = account
this.userSuggestions = UserSuggestionState(account, false)
}
this.selectedDTag.tryEmit(selectedDTag)
}
suspend fun deleteFollowSet() {
account.followLists.deleteFollowSet(selectedDTag.value, account)
}
fun loadNote(): AddressableNote? = account.followLists.getPeopleListNote(selectedDTag.value)
suspend fun removeUserFromSet(user: User) {
account.followLists.removeUserFromSet(user, selectedDTag.value, account)
}
suspend fun addUserToSet(user: User) {
account.followLists.addUserToSet(user, selectedDTag.value, account)
}
fun hasUserFlow(user: User): Flow<Boolean> =
selectedList.map {
if (it == null) {
false
} else {
user in it.publicMembers
}
}
}
@@ -0,0 +1,103 @@
/**
* 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
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
class FollowPackViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
}
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?,
customDescription: String?,
) {
accountViewModel.launchSigner {
accountViewModel.account.followLists.cloneFollowSet(
currentFollowPack = followSet,
customCloneName = customName,
customCloneDescription = customDescription,
account = accountViewModel.account,
)
}
}
fun deleteItem(followSet: PeopleList) {
accountViewModel.launchSigner {
accountViewModel.account.followLists.deleteFollowSet(
identifierTag = followSet.identifierTag,
account = accountViewModel.account,
)
}
}
}
@@ -22,40 +22,44 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import kotlinx.coroutines.flow.StateFlow
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
fun AllPeopleListFeedView(
listFlow: StateFlow<List<PeopleList>>,
onOpenItem: (String) -> Unit = {},
onRenameItem: (targetSet: PeopleList, newName: String) -> Unit,
onItemDescriptionChange: (peopleList: PeopleList, newDescription: String?) -> Unit,
onItemClone: (peopleList: PeopleList, customName: String?, customDesc: String?) -> Unit,
onDeleteItem: (peopleList: PeopleList) -> Unit,
peopleListModel: PeopleListViewModel,
followPackModel: FollowPackViewModel,
nav: INav,
) {
val followSetFeedState by listFlow.collectAsStateWithLifecycle()
val peopleListFeedState by peopleListModel.listFlow().collectAsStateWithLifecycle()
val followPackFeedState by followPackModel.listFlow().collectAsStateWithLifecycle()
if (followSetFeedState.isEmpty()) {
if (peopleListFeedState.isEmpty() && followPackFeedState.isEmpty()) {
AllPeopleListFeedEmpty(
message = stringRes(R.string.follow_set_empty_feed_msg),
)
@@ -64,15 +68,75 @@ fun AllPeopleListFeedView(
state = rememberLazyListState(),
contentPadding = FeedPadding,
) {
itemsIndexed(followSetFeedState, key = { _, item -> item.identifierTag }) { _, list ->
stickyHeader {
Row(
modifier = MaxWidthWithHorzPadding,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.follow_sets),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.follow_sets_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
PeopleListFabsAndMenu(
title = R.string.follow_set_creation_dialog_title,
onAddSet = peopleListModel::addItem,
)
}
}
itemsIndexed(peopleListFeedState, key = { _, item -> item.identifierTag }) { _, followSet ->
PeopleListItem(
modifier = Modifier.fillMaxSize().animateItem(),
peopleList = list,
onClick = { onOpenItem(list.identifierTag) },
onRename = { onRenameItem(list, it) },
onDescriptionChange = { newDescription -> onItemDescriptionChange(list, newDescription) },
onClone = { cloneName, cloneDescription -> onItemClone(list, cloneName, cloneDescription) },
onDelete = { onDeleteItem(list) },
peopleList = followSet,
onClick = { nav.nav(peopleListModel.openItem(followSet.identifierTag)) },
onRename = { peopleListModel.renameItem(followSet, it) },
onDescriptionChange = { newDescription -> peopleListModel.changeItemDescription(followSet, newDescription) },
onClone = { cloneName, cloneDescription -> peopleListModel.cloneItem(followSet, cloneName, cloneDescription) },
onDelete = { peopleListModel.deleteItem(followSet) },
)
HorizontalDivider(thickness = DividerThickness)
}
stickyHeader {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.discover_follows),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.discover_follows_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
PeopleListFabsAndMenu(
title = R.string.follow_pack_creation_dialog_title,
onAddSet = followPackModel::addItem,
)
}
}
itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, followSet ->
PeopleListItem(
modifier = Modifier.fillMaxSize().animateItem(),
peopleList = followSet,
onClick = { nav.nav(followPackModel.openItem(followSet.identifierTag)) },
onRename = { followPackModel.renameItem(followSet, it) },
onDescriptionChange = { newDescription -> followPackModel.changeItemDescription(followSet, newDescription) },
onClone = { cloneName, cloneDescription -> followPackModel.cloneItem(followSet, cloneName, cloneDescription) },
onDelete = { followPackModel.deleteItem(followSet) },
)
HorizontalDivider(thickness = DividerThickness)
}
@@ -21,108 +21,52 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.flow.StateFlow
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
@Composable
fun ListOfPeopleListsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
ListOfPeopleListsScreen(
listFlow = accountViewModel.account.peopleLists.uiListFlow,
addItem = { title: String, description: String? ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.addFollowList(
listName = title,
listDescription = description,
account = accountViewModel.account,
)
}
},
openItem = {
nav.nav(Route.PeopleListView(it))
},
renameItem = { followSet, newValue ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.renameFollowList(
newName = newValue,
peopleList = followSet,
account = accountViewModel.account,
)
}
},
changeItemDescription = { followSet, newDescription ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.modifyFollowSetDescription(
newDescription = newDescription,
peopleList = followSet,
account = accountViewModel.account,
)
}
},
cloneItem = { followSet, customName, customDescription ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.cloneFollowSet(
currentPeopleList = followSet,
customCloneName = customName,
customCloneDescription = customDescription,
account = accountViewModel.account,
)
}
},
deleteItem = { followSet ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.deleteFollowSet(
identifierTag = followSet.identifierTag,
account = accountViewModel.account,
)
}
},
nav,
)
val list: PeopleListViewModel = viewModel()
list.init(accountViewModel)
val pack: FollowPackViewModel = viewModel()
pack.init(accountViewModel)
ListOfPeopleListsScreen(list, pack, nav)
}
@Composable
fun ListOfPeopleListsScreen(
listFlow: StateFlow<List<PeopleList>>,
addItem: (title: String, description: String?) -> Unit,
openItem: (identifier: String) -> Unit,
renameItem: (peopleList: PeopleList, newName: String) -> Unit,
changeItemDescription: (peopleList: PeopleList, newDescription: String?) -> Unit,
cloneItem: (peopleList: PeopleList, customName: String?, customDesc: String?) -> Unit,
deleteItem: (peopleList: PeopleList) -> Unit,
list: PeopleListViewModel,
pack: FollowPackViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(R.string.my_lists), nav::popBack)
},
floatingActionButton = {
PeopleListFabsAndMenu(
onAddSet = addItem,
)
},
) { paddingValues ->
Column(
Modifier
@@ -131,41 +75,35 @@ fun ListOfPeopleListsScreen(
bottom = paddingValues.calculateBottomPadding(),
).fillMaxHeight(),
) {
AllPeopleListFeedView(
listFlow = listFlow,
onOpenItem = openItem,
onRenameItem = renameItem,
onItemDescriptionChange = changeItemDescription,
onItemClone = cloneItem,
onDeleteItem = deleteItem,
)
AllPeopleListFeedView(list, pack, nav)
}
}
}
@Composable
private fun PeopleListFabsAndMenu(onAddSet: (name: String, description: String?) -> Unit) {
fun PeopleListFabsAndMenu(
title: Int = R.string.follow_set_creation_dialog_title,
onAddSet: (name: String, description: String?) -> Unit,
) {
val isSetAdditionDialogOpen = remember { mutableStateOf(false) }
ExtendedFloatingActionButton(
text = {
Text(text = stringRes(R.string.follow_set_create_btn_label))
OutlinedButton(
onClick = {
isSetAdditionDialogOpen.value = true
},
icon = {
) {
Row(horizontalArrangement = SpacedBy5dp, verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = null,
)
},
onClick = {
isSetAdditionDialogOpen.value = true
},
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
)
Text(stringRes(R.string.follow_set_create_btn_label))
}
}
if (isSetAdditionDialogOpen.value) {
NewPeopleListCreationDialog(
title = title,
onDismiss = {
isSetAdditionDialogOpen.value = false
},
@@ -162,7 +162,7 @@ fun PeopleListItem(
peopleList: PeopleList,
onClick: () -> Unit,
onRename: (String) -> Unit,
onDescriptionChange: (String?) -> Unit,
onDescriptionChange: (String) -> Unit,
onClone: (customName: String?, customDescription: String?) -> Unit,
onDelete: () -> Unit,
) {
@@ -279,7 +279,7 @@ private fun PeopleListOptionsButton(
peopleListName: String,
peopleListDescription: String?,
onListRename: (String) -> Unit,
onListDescriptionChange: (String?) -> Unit,
onListDescriptionChange: (String) -> Unit,
onListCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit,
onListDelete: () -> Unit,
) {
@@ -310,7 +310,7 @@ private fun ListOptionsMenu(
setName: String,
setDescription: String?,
onListRename: (String) -> Unit,
onListDescriptionChange: (String?) -> Unit,
onListDescriptionChange: (String) -> Unit,
onListClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit,
@@ -470,9 +470,9 @@ private fun ListModifyDescriptionDialog(
modifier: Modifier = Modifier,
currentDescription: String?,
onDismissDialog: () -> Unit,
onModifyDescription: (String?) -> Unit,
onModifyDescription: (String) -> Unit,
) {
val updatedDescription = remember { mutableStateOf<String?>(null) }
val updatedDescription = remember { mutableStateOf<String>("") }
val modifyIndicatorLabel =
if (currentDescription == null) {
@@ -509,7 +509,7 @@ private fun ListModifyDescriptionDialog(
fontStyle = FontStyle.Italic,
)
TextField(
value = updatedDescription.value ?: "",
value = updatedDescription.value,
onValueChange = { updatedDescription.value = it },
)
}
@@ -0,0 +1,103 @@
/**
* 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
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
class PeopleListViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
}
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?,
customDescription: String?,
) {
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.cloneFollowSet(
currentPeopleList = followSet,
customCloneName = customName,
customCloneDescription = customDescription,
account = accountViewModel.account,
)
}
}
fun deleteItem(followSet: PeopleList) {
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.deleteFollowSet(
identifierTag = followSet.identifierTag,
account = accountViewModel.account,
)
}
}
}
@@ -26,15 +26,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -49,13 +42,12 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewPeopleListCreationDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditPeopleListScreen(
fun FollowListAndPackAndUserScreen(
userToAddOrRemove: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
@@ -72,22 +64,19 @@ fun EditPeopleListScreen(
}
userBase?.let {
EditPeopleListScreen(it, accountViewModel, nav)
FollowListAndPackAndUserScreen(it, accountViewModel, nav)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditPeopleListScreen(
fun FollowListAndPackAndUserScreen(
userToAddOrRemove: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
modifier = Modifier.fillMaxSize().recalculateWindowInsets(),
floatingActionButton = {
PeopleListAndUserFab(accountViewModel)
},
topBar = {
val userName by observeUserName(userToAddOrRemove, accountViewModel)
TopBarWithBackButton(
@@ -105,45 +94,7 @@ fun EditPeopleListScreen(
).consumeWindowInsets(contentPadding)
.imePadding(),
) {
PeopleListAndUserView(userToAddOrRemove, accountViewModel, nav)
FollowListAndPackAndUserView(userToAddOrRemove, accountViewModel, nav)
}
}
}
@Composable
private fun PeopleListAndUserFab(accountViewModel: AccountViewModel) {
var isOpen by remember { mutableStateOf(false) }
ExtendedFloatingActionButton(
text = {
Text(text = stringRes(R.string.follow_set_create_btn_label))
},
icon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = null,
)
},
onClick = { isOpen = !isOpen },
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
)
if (isOpen) {
NewPeopleListCreationDialog(
onDismiss = {
isOpen = false
},
onCreateList = { name, description ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.addFollowList(
listName = name,
listDescription = description,
account = accountViewModel.account,
)
}
isOpen = false
},
)
}
}
@@ -0,0 +1,209 @@
/**
* 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.memberEdit
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.PeopleListFabsAndMenu
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
fun FollowListAndPackAndUserView(
userToAddOrRemove: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
val followSetsState by accountViewModel.account.peopleLists.uiListFlow
.collectAsStateWithLifecycle()
val followPackFeedState by accountViewModel.account.followLists.uiListFlow
.collectAsStateWithLifecycle()
if (followSetsState.isEmpty() && followPackFeedState.isEmpty()) {
Column(
Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(text = stringRes(R.string.follow_set_empty_dialog_msg))
Spacer(modifier = StdVertSpacer)
}
} else {
val userName by observeUserName(userToAddOrRemove, accountViewModel)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
stickyHeader {
Row(
modifier = MaxWidthWithHorzPadding,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.follow_sets),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.follow_sets_explainer),
style = MaterialTheme.typography.bodyMedium,
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,
)
}
},
)
}
}
itemsIndexed(followSetsState, key = { _, item -> item.identifierTag }) { _, list ->
PeopleListAndUserItem(
modifier = Modifier.fillMaxWidth(),
listHeader = list.title,
userName = userName,
userIsPrivateMember = list.privateMembers.contains(userToAddOrRemove),
userIsPublicMember = list.publicMembers.contains(userToAddOrRemove),
onRemoveUser = {
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.removeUserFromSet(
userToAddOrRemove,
isPrivate = list.privateMembers.contains(userToAddOrRemove),
list.identifierTag,
accountViewModel.account,
)
}
},
privateMemberSize = list.privateMembers.size,
publicMemberSize = list.publicMembers.size,
onAddUserToList = { userShouldBePrivate ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.addUserToSet(
userToAddOrRemove,
list.identifierTag,
userShouldBePrivate,
accountViewModel.account,
)
}
},
)
HorizontalDivider(thickness = DividerThickness)
}
stickyHeader {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = SpacedBy5dp,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.discover_follows),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringRes(R.string.discover_follows_explainer),
style = MaterialTheme.typography.bodyMedium,
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,
)
}
},
)
}
}
itemsIndexed(followPackFeedState, key = { _, item -> item.identifierTag }) { _, list ->
FollowPackAndUserItem(
modifier = Modifier.fillMaxWidth(),
listHeader = list.title,
userName = userName,
isMember = list.publicMembers.contains(userToAddOrRemove),
onRemoveUser = {
accountViewModel.launchSigner {
accountViewModel.account.followLists.removeUserFromSet(
userToAddOrRemove,
list.identifierTag,
accountViewModel.account,
)
}
},
memberSize = list.publicMembers.size,
onAddUserToList = {
accountViewModel.launchSigner {
accountViewModel.account.followLists.addUserToSet(
userToAddOrRemove,
list.identifierTag,
accountViewModel.account,
)
}
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
@@ -0,0 +1,212 @@
/**
* 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.memberEdit
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.RemoveCircleOutline
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.DisplayParticipantNumberAndStatus
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50ModifierOffset10
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Preview
@Composable
fun FollowPackAndUserMemberPreview() {
ThemeComparisonColumn {
FollowPackAndUserItem(
modifier = Modifier.fillMaxWidth(),
listHeader = "list title",
userName = "User",
isMember = true,
memberSize = 2,
onAddUserToList = {},
onRemoveUser = {},
)
}
}
@Preview
@Composable
fun FollowPackAndUserNotMemberPreview() {
ThemeComparisonColumn {
FollowPackAndUserItem(
modifier = Modifier.fillMaxWidth(),
listHeader = "list title",
userName = "User",
isMember = false,
memberSize = 2,
onAddUserToList = {},
onRemoveUser = {},
)
}
}
@Composable
fun FollowPackAndUserItem(
modifier: Modifier = Modifier,
listHeader: String,
userName: String,
isMember: Boolean,
memberSize: Int,
onAddUserToList: () -> Unit,
onRemoveUser: () -> Unit,
) {
ListItem(
modifier = modifier,
headlineContent = {
Text(
text = listHeader,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
UserStatusInList(userName, isMember)
},
leadingContent = {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Outlined.Groups,
contentDescription = stringRes(R.string.follow_set_icon_description),
modifier = Size50ModifierOffset10,
)
DisplayParticipantNumberAndStatus(
modifier = Modifier.align(Alignment.BottomCenter),
privateMembersSize = 0,
publicMembersSize = memberSize,
)
}
},
trailingContent = {
UserAdditionOptions(isMember, onAddUserToList, onRemoveUser)
},
)
}
@Composable
private fun UserStatusInList(
userName: String,
isMember: Boolean,
) {
Row(
modifier = HalfHalfVertPadding,
horizontalArrangement = SpacedBy5dp,
verticalAlignment = Alignment.CenterVertically,
) {
val text =
if (isMember) {
stringRes(R.string.follow_set_public_presence_indicator, userName)
} else {
stringRes(R.string.follow_set_absence_indicator2, userName)
}
val icon =
if (isMember) {
Icons.Outlined.Public
} else {
Icons.Outlined.RemoveCircleOutline
}
Icon(
imageVector = icon,
contentDescription = text,
modifier = Size15Modifier,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = text,
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
}
}
@Composable
private fun UserAdditionOptions(
isUserInList: Boolean,
onAddUserToList: () -> Unit,
onRemoveUser: () -> Unit,
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
IconButton(
onClick = {
if (isUserInList) {
onRemoveUser()
} else {
onAddUserToList()
}
},
modifier =
Modifier
.background(
color =
if (isUserInList) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.primary
},
shape = RoundedCornerShape(percent = 80),
),
) {
if (isUserInList) {
Icon(
imageVector = Icons.Filled.PersonRemove,
contentDescription = stringRes(R.string.remove_user_from_the_list),
tint = MaterialTheme.colorScheme.onErrorContainer,
)
} else {
Icon(
imageVector = Icons.Filled.PersonAdd,
contentDescription = stringRes(R.string.add_user_to_the_list),
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
}
@@ -139,7 +139,7 @@ fun PeopleListAndUserItem(
}
@Composable
fun UserStatusInList(
private fun UserStatusInList(
userName: String,
userIsPrivateMember: Boolean,
userIsPublicMember: Boolean,
@@ -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.memberEdit
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun PeopleListAndUserView(
userToAddOrRemove: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
val followSetsState by accountViewModel.account.peopleLists.uiListFlow
.collectAsStateWithLifecycle()
if (followSetsState.isEmpty()) {
Column(
Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(text = stringRes(R.string.follow_set_empty_dialog_msg))
Spacer(modifier = StdVertSpacer)
}
} else {
val userName by observeUserName(userToAddOrRemove, accountViewModel)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
itemsIndexed(followSetsState, key = { _, item -> item.identifierTag }) { _, list ->
PeopleListAndUserItem(
modifier = Modifier.fillMaxWidth(),
listHeader = list.title,
userName = userName,
userIsPrivateMember = list.privateMembers.contains(userToAddOrRemove),
userIsPublicMember = list.publicMembers.contains(userToAddOrRemove),
onRemoveUser = {
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.removeUserFromSet(
userToAddOrRemove,
isPrivate = list.privateMembers.contains(userToAddOrRemove),
list.identifierTag,
accountViewModel.account,
)
}
},
privateMemberSize = list.privateMembers.size,
publicMemberSize = list.publicMembers.size,
onAddUserToList = { userShouldBePrivate ->
accountViewModel.launchSigner {
accountViewModel.account.peopleLists.addUserToSet(
userToAddOrRemove,
list.identifierTag,
userShouldBePrivate,
accountViewModel.account,
)
}
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
+1
View File
@@ -549,6 +549,7 @@
<string name="follow_set_creation_item_label">New list with %1$s membership</string>
<string name="follow_set_creation_item_description">Creates a new follow set, and adds %1$s as a %2$s member.</string>
<string name="follow_set_creation_dialog_title">New Follow List</string>
<string name="follow_pack_creation_dialog_title">New Follow Pack</string>
<string name="follow_set_copy_dialog_title">Copy/Clone Follow List</string>
<string name="follow_set_desc_modify_label">Modify description</string>
<string name="follow_set_empty_desc_label">This list doesn\'t have a description</string>