diff --git a/amethyst/build.gradle b/amethyst/build.gradle index d1c269684..4dcd2040b 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -151,6 +151,7 @@ android { signingConfig = signingConfigs.debug } } + // TODO: remove this when lightcompressor uses one MP4 parser only packaging { resources { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSet.kt index 59f5ed34c..0ec59bfd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSet.kt @@ -31,9 +31,10 @@ data class FollowSet( val identifierTag: String, val title: String, val description: String?, - val visibility: SetVisibility, - val profiles: Set, -) : NostrSet(setVisibility = visibility, content = profiles) { + val visibility: SetVisibility = SetVisibility.Mixed, + val privateProfiles: Set = emptySet(), + val publicProfiles: Set = emptySet(), +) : NostrSet(setVisibility = visibility, privateContent = privateProfiles, publicContent = publicProfiles) { companion object { fun mapEventToSet( event: PeopleListEvent, @@ -53,26 +54,22 @@ data class FollowSet( identifierTag = dTag, title = listTitle, description = listDescription, - visibility = SetVisibility.Private, - profiles = privateFollows.toSet(), + privateProfiles = privateFollows.toSet(), ) } else if (publicFollows.isNotEmpty() && privateFollows.isEmpty()) { FollowSet( identifierTag = dTag, title = listTitle, description = listDescription, - visibility = SetVisibility.Public, - profiles = publicFollows.toSet(), + publicProfiles = publicFollows.toSet(), ) } else { - // Follow set is empty, so assume public. Why? Nostr limitation. - // TODO: Could this be fixed at protocol level? FollowSet( identifierTag = dTag, title = listTitle, description = listDescription, - visibility = SetVisibility.Public, - profiles = publicFollows.toSet(), + privateProfiles = privateFollows.toSet(), + publicProfiles = publicFollows.toSet(), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSetState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSetState.kt index 120c651a1..0d1326c81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSetState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSetState.kt @@ -25,20 +25,16 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent -import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class FollowSetState( @@ -50,7 +46,7 @@ class FollowSetState( private val isActive = MutableStateFlow(false) suspend fun getFollowSetNotes() = - withContext(Dispatchers.Default) { + withContext(Dispatchers.IO) { val followSetNotes = LocalCache.getFollowSetNotesFor(user) return@withContext followSetNotes } @@ -63,12 +59,12 @@ class FollowSetState( emit(followSets) delay(2000) } - }.flowOn(Dispatchers.Default) + }.flowOn(Dispatchers.IO) val profilesFlow = getFollowSetNotesFlow() .map { it -> - it.flatMapTo(mutableSetOf()) { it.profiles }.toSet() + it.flatMapTo(mutableSetOf()) { it.privateProfiles + it.publicProfiles }.toSet() }.stateIn(scope, SharingStarted.Eagerly, emptySet()) fun mapNoteToFollowSet(note: Note): FollowSet = @@ -82,14 +78,5 @@ class FollowSetState( init { isActive.update { true } - scope.launch(Dispatchers.Default) { - getFollowSetNotesFlow() - .onCompletion { - isActive.update { false } - }.catch { - Log.e(this@FollowSetState.javaClass.simpleName, "Error on flow collection: ${it.message}") - isActive.update { false } - }.collect {} - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/NostrSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/NostrSet.kt index aadae3380..13295f7c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/NostrSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/NostrSet.kt @@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.model.nip51Lists.followSets sealed class NostrSet( val setVisibility: SetVisibility, - val content: Collection, + val privateContent: Collection, + val publicContent: Collection, ) class CuratedBookmarkSet( val name: String, val visibility: SetVisibility, - val setItems: List, -) : NostrSet(visibility, setItems) + val privateSetItems: List, + val publicSetItems: List, +) : NostrSet(visibility, privateSetItems, publicSetItems) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index 2369431f7..73fd68267 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -257,16 +257,9 @@ object VideoCompressionHelper { "Compressed [$size] ($reductionPercent% reduction)", ) - // Attempt to correct the path: if it contains "_temp" then remove it - val correctedPath = - if (path.contains("_temp")) { - path.replace("_temp", "") - } else { - path - } if (continuation.isActive) { continuation.resume( - MediaCompressorResult(Uri.fromFile(File(correctedPath)), contentType, size), + MediaCompressorResult(Uri.fromFile(File(path)), contentType, size), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomListsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomListsScreen.kt index 9a600f9e7..b5739ef97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomListsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomListsScreen.kt @@ -30,9 +30,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Tab @@ -48,8 +50,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -119,12 +119,11 @@ fun ListsAndSetsScreen( refresh = { followSetsViewModel.invalidateData() }, - addItem = { title: String, description: String?, listType: SetVisibility -> - val isSetPrivate = listType == SetVisibility.Private + addItem = { title: String, description: String? -> + followSetsViewModel.addFollowSet( setName = title, setDescription = description, - isListPrivate = isSetPrivate, account = accountViewModel.account, ) }, @@ -138,6 +137,21 @@ fun ListsAndSetsScreen( account = accountViewModel.account, ) }, + changeItemDescription = { followSet, newDescription -> + followSetsViewModel.modifyFollowSetDescription( + newDescription = newDescription, + followSet = followSet, + account = accountViewModel.account, + ) + }, + cloneItem = { followSet, customName, customDescription -> + followSetsViewModel.cloneFollowSet( + currentFollowSet = followSet, + customCloneName = customName, + customCloneDescription = customDescription, + account = accountViewModel.account, + ) + }, deleteItem = { followSet -> followSetsViewModel.deleteFollowSet( followSet = followSet, @@ -153,14 +167,16 @@ fun ListsAndSetsScreen( fun CustomListsScreen( followSetFeedState: FollowSetFeedState, refresh: () -> Unit, - addItem: (title: String, description: String?, listType: SetVisibility) -> Unit, + addItem: (title: String, description: String?) -> Unit, openItem: (identifier: String) -> Unit, renameItem: (followSet: FollowSet, newName: String) -> Unit, + changeItemDescription: (followSet: FollowSet, newDescription: String?) -> Unit, + cloneItem: (followSet: FollowSet, customName: String?, customDesc: String?) -> Unit, deleteItem: (followSet: FollowSet) -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { - val pagerState = rememberPagerState { 3 } + val pagerState = rememberPagerState { 2 } val coroutineScope = rememberCoroutineScope() DisappearingScaffold( @@ -185,22 +201,14 @@ fun CustomListsScreen( onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, text = { Text(text = stringRes(R.string.labeled_bookmarks), overflow = TextOverflow.Visible) }, ) - Tab( - selected = pagerState.currentPage == 2, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, - text = { Text(text = stringRes(R.string.general_bookmarks), overflow = TextOverflow.Visible) }, - ) } } }, floatingButton = { // TODO: Show components based on current tab FollowSetFabsAndMenu( - onAddPrivateSet = { name: String, description: String? -> - addItem(name, description, SetVisibility.Private) - }, - onAddPublicSet = { name: String, description: String? -> - addItem(name, description, SetVisibility.Public) + onAddSet = { name: String, description: String? -> + addItem(name, description) }, ) }, @@ -222,11 +230,13 @@ fun CustomListsScreen( onRefresh = refresh, onOpenItem = openItem, onRenameItem = renameItem, + onItemDescriptionChange = changeItemDescription, + onItemClone = cloneItem, onDeleteItem = deleteItem, ) 1 -> LabeledBookmarksFeedView() - 2 -> GeneralBookmarksFeedView() +// 2 -> GeneralBookmarksFeedView() } } } @@ -236,57 +246,34 @@ fun CustomListsScreen( @Composable private fun FollowSetFabsAndMenu( modifier: Modifier = Modifier, - onAddPrivateSet: (name: String, description: String?) -> Unit, - onAddPublicSet: (name: String, description: String?) -> Unit, + onAddSet: (name: String, description: String?) -> Unit, ) { val isSetAdditionDialogOpen = remember { mutableStateOf(false) } - val isPrivateOptionTapped = remember { mutableStateOf(false) } - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - FloatingActionButton( - onClick = { - isPrivateOptionTapped.value = true - isSetAdditionDialogOpen.value = true - }, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { + ExtendedFloatingActionButton( + text = { + Text(text = stringRes(R.string.follow_set_create_btn_label)) + }, + icon = { Icon( - painter = painterResource(R.drawable.lock_plus), + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null, - tint = Color.White, ) - } - FloatingActionButton( - onClick = { - isSetAdditionDialogOpen.value = true - }, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - painter = painterResource(R.drawable.earth_plus), - contentDescription = null, - tint = Color.White, - ) - } - } + }, + onClick = { + isSetAdditionDialogOpen.value = true + }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) if (isSetAdditionDialogOpen.value) { NewSetCreationDialog( onDismiss = { isSetAdditionDialogOpen.value = false - isPrivateOptionTapped.value = false }, - shouldBePrivate = isPrivateOptionTapped.value, onCreateList = { name, description -> - if (isPrivateOptionTapped.value) { - onAddPrivateSet(name, description) - } else { - onAddPublicSet(name, description) - } + onAddSet(name, description) }, ) } @@ -296,27 +283,10 @@ private fun FollowSetFabsAndMenu( fun NewSetCreationDialog( modifier: Modifier = Modifier, onDismiss: () -> Unit, - shouldBePrivate: Boolean, onCreateList: (name: String, description: String?) -> Unit, ) { val newListName = remember { mutableStateOf("") } val newListDescription = remember { mutableStateOf(null) } - val context = LocalContext.current - - val listTypeText = - stringRes( - context, - when (shouldBePrivate) { - true -> R.string.follow_set_type_private - false -> R.string.follow_set_type_public - }, - ) - - val listTypeIcon = - when (shouldBePrivate) { - true -> R.drawable.lock - false -> R.drawable.ic_public - } AlertDialog( onDismissRequest = onDismiss, @@ -325,12 +295,8 @@ fun NewSetCreationDialog( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon( - painter = painterResource(listTypeIcon), - contentDescription = null, - ) Text( - text = stringRes(R.string.follow_set_creation_dialog_title, listTypeText), + text = stringRes(R.string.follow_set_creation_dialog_title), ) } }, @@ -347,7 +313,7 @@ fun NewSetCreationDialog( }, ) Spacer(modifier = DoubleVertSpacer) - // For the list description + // For the set description TextField( value = ( @@ -425,6 +391,12 @@ private fun SetItemPreview() { onFollowSetRename = { println("Follow set new name: $it") }, + onFollowSetDescriptionChange = { description -> + println("The follow set's description has been changed to $description") + }, + onFollowSetClone = { newName, newDesc -> + println("The follow set has been cloned, and has custom name: $newName, Desc: $newDesc") + }, onFollowSetDelete = { println(" The follow set ${sampleFollowSet.title} has been deleted.") }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomSetItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomSetItem.kt index 4d75c61db..89dec3b22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomSetItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomSetItem.kt @@ -37,13 +37,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle @@ -56,15 +53,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet -import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import kotlin.let @Composable fun CustomSetItem( @@ -72,6 +68,8 @@ fun CustomSetItem( followSet: FollowSet, onFollowSetClick: () -> Unit, onFollowSetRename: (String) -> Unit, + onFollowSetDescriptionChange: (String?) -> Unit, + onFollowSetClone: (customName: String?, customDescription: String?) -> Unit, onFollowSetDelete: () -> Unit, ) { val context = LocalContext.current @@ -96,20 +94,75 @@ fun CustomSetItem( ) { Text(followSet.title, fontWeight = FontWeight.Bold) Spacer(modifier = StdHorzSpacer) - FilterChip( - selected = true, - onClick = {}, - label = { - Text(text = "${followSet.profiles.size}") - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.People, - contentDescription = null, + if (followSet.publicProfiles.isEmpty() && followSet.privateProfiles.isEmpty()) { + FilterChip( + selected = true, + onClick = {}, + label = { + Text(text = stringRes(R.string.follow_set_empty_label)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.People, + contentDescription = null, + ) + }, + shape = ButtonBorder, + ) + } + if (followSet.publicProfiles.isNotEmpty()) { + val publicMemberSize = followSet.publicProfiles.size + val membersLabel = + stringRes( + context, + if (publicMemberSize == 1) { + R.string.follow_set_single_member_label + } else { + R.string.follow_set_multiple_member_label + }, ) - }, - shape = ButtonBorder, - ) + FilterChip( + selected = true, + onClick = {}, + label = { + Text(text = "$publicMemberSize") + }, + leadingIcon = { + Icon( + painterResource(R.drawable.ic_public), + contentDescription = null, + ) + }, + shape = ButtonBorder, + ) + Spacer(modifier = StdHorzSpacer) + } + if (followSet.privateProfiles.isNotEmpty()) { + val privateMemberSize = followSet.privateProfiles.size + val membersLabel = + stringRes( + context, + if (privateMemberSize == 1) { + R.string.follow_set_single_member_label + } else { + R.string.follow_set_multiple_member_label + }, + ) + FilterChip( + selected = true, + onClick = {}, + label = { + Text(text = "$privateMemberSize") + }, + leadingIcon = { + Icon( + painterResource(R.drawable.lock), + contentDescription = null, + ) + }, + shape = ButtonBorder, + ) + } } Spacer(modifier = StdVertSpacer) Text( @@ -119,34 +172,6 @@ fun CustomSetItem( maxLines = 2, ) } - - followSet.visibility.let { - val text by derivedStateOf { - when (it) { - SetVisibility.Public -> stringRes(context, R.string.follow_set_type_public) - SetVisibility.Private -> stringRes(context, R.string.follow_set_type_private) - SetVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed) - } - } - Column( - modifier = Modifier.padding(top = 15.dp), - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - painter = - painterResource( - when (it) { - SetVisibility.Public -> R.drawable.ic_public - SetVisibility.Private -> R.drawable.lock - SetVisibility.Mixed -> R.drawable.format_list_bulleted_type - }, - ), - contentDescription = stringRes(R.string.follow_set_type_description, text), - ) - Text(text, color = Color.Gray, fontWeight = FontWeight.Light) - } - } } Column( @@ -159,7 +184,10 @@ fun CustomSetItem( ) { SetOptionsButton( followSetName = followSet.title, - onListRename = onFollowSetRename, + followSetDescription = followSet.description, + onSetRename = onFollowSetRename, + onSetDescriptionChange = onFollowSetDescriptionChange, + onSetCloneCreate = onFollowSetClone, onListDelete = onFollowSetDelete, ) } @@ -167,10 +195,13 @@ fun CustomSetItem( } @Composable -fun SetOptionsButton( +private fun SetOptionsButton( modifier: Modifier = Modifier, followSetName: String, - onListRename: (String) -> Unit, + followSetDescription: String?, + onSetRename: (String) -> Unit, + onSetDescriptionChange: (String?) -> Unit, + onSetCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit, onListDelete: () -> Unit, ) { val isMenuOpen = remember { mutableStateOf(false) } @@ -181,10 +212,13 @@ fun SetOptionsButton( VerticalDotsIcon() SetOptionsMenu( - listName = followSetName, + setName = followSetName, + setDescription = followSetDescription, isExpanded = isMenuOpen.value, onDismiss = { isMenuOpen.value = false }, - onListRename = onListRename, + onSetRename = onSetRename, + onSetDescriptionChange = onSetDescriptionChange, + onSetClone = onSetCloneCreate, onDelete = onListDelete, ) } @@ -194,26 +228,27 @@ fun SetOptionsButton( private fun SetOptionsMenu( modifier: Modifier = Modifier, isExpanded: Boolean, - listName: String, - onListRename: (String) -> Unit, + setName: String, + setDescription: String?, + onSetRename: (String) -> Unit, + onSetDescriptionChange: (String?) -> Unit, + onSetClone: (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) } + DropdownMenu( expanded = isExpanded, onDismissRequest = onDismiss, ) { - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.quick_action_delete)) - }, - onClick = { - onDelete() - }, - ) DropdownMenuItem( text = { Text(text = stringRes(R.string.follow_set_rename_btn_label)) @@ -223,25 +258,76 @@ private fun SetOptionsMenu( onDismiss() }, ) + DropdownMenuItem( + text = { + Text(text = stringRes(R.string.follow_set_desc_modify_label)) + }, + onClick = { + isDescriptionModDialogOpen.value = true + onDismiss() + }, + ) + DropdownMenuItem( + text = { + Text(text = stringRes(R.string.follow_set_copy_action_btn_label)) + }, + onClick = { + isCopyDialogOpen.value = true + onDismiss() + }, + ) + DropdownMenuItem( + text = { + Text(text = stringRes(R.string.quick_action_delete)) + }, + onClick = { + onDelete() + }, + ) } if (isRenameDialogOpen.value) { - RenameDialog( - currentName = listName, + SetRenameDialog( + currentName = setName, newName = renameString.value, onStringRenameChange = { renameString.value = it }, onDismissDialog = { isRenameDialogOpen.value = false }, onListRename = { - onListRename(renameString.value) + onSetRename(renameString.value) }, ) } + + if (isDescriptionModDialogOpen.value) { + SetModifyDescriptionDialog( + currentDescription = setDescription, + onDismissDialog = { isDescriptionModDialogOpen.value = false }, + onModifyDescription = onSetDescriptionChange, + ) + } + + if (isCopyDialogOpen.value) { + SetCloneDialog( + optionalNewName = optionalCloneName.value, + optionalNewDesc = optionalCloneDescription.value, + onCloneNameChange = { + optionalCloneName.value = it + }, + onCloneDescChange = { + optionalCloneDescription.value = it + }, + onCloneCreate = { name, description -> + onSetClone(optionalCloneName.value, optionalCloneDescription.value) + }, + onDismiss = { isCopyDialogOpen.value = false }, + ) + } } @Composable -private fun RenameDialog( +private fun SetRenameDialog( modifier: Modifier = Modifier, currentName: String, newName: String, @@ -299,3 +385,137 @@ private fun RenameDialog( }, ) } + +@Composable +private fun SetModifyDescriptionDialog( + modifier: Modifier = Modifier, + currentDescription: String?, + onDismissDialog: () -> Unit, + onModifyDescription: (String?) -> Unit, +) { + val updatedDescription = remember { mutableStateOf(null) } + + val modifyIndicatorLabel = + if (currentDescription == null) { + stringRes(R.string.follow_set_empty_desc_label) + } else { + buildAnnotatedString { + append(stringRes(R.string.follow_set_current_desc_label) + " ") + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Normal, + fontSize = 15.sp, + ), + ) { + append("\"" + currentDescription + "\"") + } + }.text + } + + AlertDialog( + onDismissRequest = onDismissDialog, + title = { + Text(text = stringRes(R.string.follow_set_desc_modify_label)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(Size5dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = modifyIndicatorLabel, + fontSize = 15.sp, + fontWeight = FontWeight.Light, + fontStyle = FontStyle.Italic, + ) + TextField( + value = updatedDescription.value ?: "", + onValueChange = { updatedDescription.value = it }, + ) + } + }, + confirmButton = { + Button( + onClick = { + onModifyDescription(updatedDescription.value) + onDismissDialog() + }, + ) { Text(text = stringRes(R.string.follow_set_desc_modify_btn_label)) } + }, + dismissButton = { + Button(onClick = onDismissDialog) { Text(text = stringRes(R.string.cancel)) } + }, + ) +} + +@Composable +private fun SetCloneDialog( + modifier: Modifier = Modifier, + optionalNewName: String?, + optionalNewDesc: String?, + onCloneNameChange: (String?) -> Unit, + onCloneDescChange: (String?) -> Unit, + onCloneCreate: (customName: String?, customDescription: String?) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringRes(R.string.follow_set_copy_dialog_title), + ) + } + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + Text( + text = stringRes(R.string.follow_set_copy_indicator_description), + fontSize = 15.sp, + fontWeight = FontWeight.Light, + fontStyle = FontStyle.Italic, + ) + // For the set clone name + TextField( + value = optionalNewName ?: "", + onValueChange = onCloneNameChange, + label = { + Text(text = stringRes(R.string.follow_set_copy_name_label)) + }, + ) + Spacer(modifier = DoubleVertSpacer) + // For the set clone description + TextField( + value = optionalNewDesc ?: "", + onValueChange = onCloneDescChange, + label = { + Text(text = stringRes(R.string.follow_set_copy_desc_label)) + }, + ) + } + }, + confirmButton = { + Button( + onClick = { + onCloneCreate(optionalNewName, optionalNewDesc) + onDismiss() + }, + ) { + Text(stringRes(R.string.follow_set_copy_action_btn_label)) + } + }, + dismissButton = { + Button( + onClick = onDismiss, + ) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedView.kt index 1ecaca898..d9bc4d2fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedView.kt @@ -51,6 +51,8 @@ fun FollowSetFeedView( onRefresh: () -> Unit = {}, onOpenItem: (String) -> Unit = {}, onRenameItem: (targetSet: FollowSet, newName: String) -> Unit, + onItemDescriptionChange: (followSet: FollowSet, newDescription: String?) -> Unit, + onItemClone: (followSet: FollowSet, customName: String?, customDesc: String?) -> Unit, onDeleteItem: (followSet: FollowSet) -> Unit, ) { when (followSetFeedState) { @@ -63,6 +65,8 @@ fun FollowSetFeedView( onRefresh = onRefresh, onItemClick = onOpenItem, onItemRename = onRenameItem, + onItemDescriptionChange = onItemDescriptionChange, + onItemClone = onItemClone, onItemDelete = onDeleteItem, ) } @@ -91,6 +95,8 @@ fun FollowSetLoaded( onRefresh: () -> Unit = {}, onItemClick: (itemIdentifier: String) -> Unit = {}, onItemRename: (followSet: FollowSet, newName: String) -> Unit, + onItemDescriptionChange: (followSet: FollowSet, newDescription: String?) -> Unit, + onItemClone: (followSet: FollowSet, customName: String?, customDesc: String?) -> Unit, onItemDelete: (followSet: FollowSet) -> Unit, ) { Log.d("FollowSetComposable", "FollowSetLoaded: Follow Set size: ${loadedFeedState.size}") @@ -113,6 +119,12 @@ fun FollowSetLoaded( onFollowSetRename = { onItemRename(set, it) }, + onFollowSetDescriptionChange = { newDescription -> + onItemDescriptionChange(set, newDescription) + }, + onFollowSetClone = { cloneName, cloneDescription -> + onItemClone(set, cloneName, cloneDescription) + }, onFollowSetDelete = { onItemDelete(set) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedViewModel.kt index c921816cf..f7aae1c45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedViewModel.kt @@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet -import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FollowSetFeedFilter @@ -117,8 +116,8 @@ class FollowSetFeedViewModel( fun addFollowSet( setName: String, setDescription: String?, - isListPrivate: Boolean, optionalFirstMemberHex: String? = null, + firstMemberShouldBePrivate: Boolean = false, account: Account, ) { if (!account.settings.isWriteable()) { @@ -129,8 +128,9 @@ class FollowSetFeedViewModel( dTag = UUID.randomUUID().toString(), title = setName, description = setDescription, - isPrivate = isListPrivate, - firstMemberHex = optionalFirstMemberHex, + isPrivate = firstMemberShouldBePrivate, + firstPublicMembers = if (optionalFirstMemberHex != null) listOf(optionalFirstMemberHex) else emptyList(), + firstPrivateMembers = if (optionalFirstMemberHex != null) listOf(optionalFirstMemberHex) else emptyList(), signer = account.signer, ) { account.sendMyPublicAndPrivateOutbox(it) @@ -160,6 +160,51 @@ class FollowSetFeedViewModel( } } + fun modifyFollowSetDescription( + newDescription: String?, + followSet: FollowSet, + account: Account, + ) { + if (!account.settings.isWriteable()) { + println("You are in read-only mode. Please login to make modifications.") + } else { + viewModelScope.launch(Dispatchers.IO) { + val setEvent = getFollowSetNote(followSet.identifierTag, account)?.event as PeopleListEvent + PeopleListEvent.modifyDescription( + earlierVersion = setEvent, + newDescription = newDescription, + signer = account.signer, + ) { + account.sendMyPublicAndPrivateOutbox(it) + } + } + } + } + + fun cloneFollowSet( + currentFollowSet: FollowSet, + customCloneName: String?, + customCloneDescription: String?, + account: Account, + ) { + if (!account.settings.isWriteable()) { + println("You are in read-only mode. Please login to make modifications.") + } else { + viewModelScope.launch(Dispatchers.IO) { + PeopleListEvent.copy( + dTag = UUID.randomUUID().toString(), + title = customCloneName ?: currentFollowSet.title, + description = customCloneDescription ?: currentFollowSet.description, + firstPublicMembers = currentFollowSet.publicProfiles.toList(), + firstPrivateMembers = currentFollowSet.privateProfiles.toList(), + signer = account.signer, + ) { + account.sendMyPublicAndPrivateOutbox(it) + } + } + } + } + fun deleteFollowSet( followSet: FollowSet, account: Account, @@ -179,6 +224,7 @@ class FollowSetFeedViewModel( fun addUserToSet( userProfileHex: String, followSet: FollowSet, + shouldBePrivateMember: Boolean, account: Account, ) { if (!account.settings.isWriteable()) { @@ -190,7 +236,7 @@ class FollowSetFeedViewModel( PeopleListEvent.addUser( earlierVersion = followSetEvent, pubKeyHex = userProfileHex, - isPrivate = followSet.visibility == SetVisibility.Private, + isPrivate = shouldBePrivateMember, signer = account.signer, ) { account.sendMyPublicAndPrivateOutbox(it) @@ -201,6 +247,7 @@ class FollowSetFeedViewModel( fun removeUserFromSet( userProfileHex: String, + userIsPrivate: Boolean, followSet: FollowSet, account: Account, ) { @@ -213,6 +260,7 @@ class FollowSetFeedViewModel( PeopleListEvent.removeUser( earlierVersion = followSetEvent, pubKeyHex = userProfileHex, + isUserPrivate = userIsPrivate, signer = account.signer, ) { account.sendMyPublicAndPrivateOutbox(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetScreen.kt index d0b930e80..97144ed83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetScreen.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.Row 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.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -68,7 +70,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet -import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserCompose @@ -76,11 +77,13 @@ import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.VertPadding import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -144,7 +147,8 @@ fun FollowSetScreen( when { selectedSetState.value != null -> { val selectedSet = selectedSetState.value - val users = selectedSet!!.profiles.mapToUsers(accountViewModel).filterNotNull() + val publicMembers = selectedSet!!.publicProfiles.mapToUsers(accountViewModel).filterNotNull() + val privateMembers = selectedSet.privateProfiles.mapToUsers(accountViewModel).filterNotNull() Scaffold( topBar = { TopAppBar( @@ -171,6 +175,7 @@ fun FollowSetScreen( selectedSet, accountViewModel.account, ) + navigator.popBack() }, ) }, @@ -190,10 +195,12 @@ fun FollowSetScreen( bottom = padding.calculateBottomPadding(), ).consumeWindowInsets(padding) .imePadding(), - followSetList = users, + publicMemberList = publicMembers, + privateMemberList = privateMembers, onDeleteUser = { followSetViewModel.removeUserFromSet( it, + userIsPrivate = selectedSet.privateProfiles.contains(it), selectedSet, accountViewModel.account, ) @@ -233,14 +240,7 @@ fun TitleAndDescription( ) Spacer(modifier = StdHorzSpacer) Icon( - painter = - painterResource( - when (followSet.setVisibility) { - SetVisibility.Public -> R.drawable.ic_public - SetVisibility.Private -> R.drawable.lock - SetVisibility.Mixed -> R.drawable.format_list_bulleted_type - }, - ), + painter = painterResource(R.drawable.format_list_bulleted_type), contentDescription = null, ) } @@ -260,7 +260,8 @@ fun TitleAndDescription( @Composable private fun FollowSetListView( modifier: Modifier = Modifier, - followSetList: List, + publicMemberList: List, + privateMemberList: List, onDeleteUser: (String) -> Unit, accountViewModel: AccountViewModel, nav: INav, @@ -272,14 +273,61 @@ private fun FollowSetListView( contentPadding = FeedPadding, state = listState, ) { - itemsIndexed(followSetList, key = { _, item -> item.pubkeyHex }) { _, item -> - FollowSetListItem( - modifier = Modifier.animateItem(), - user = item, - accountViewModel = accountViewModel, - nav = nav, - onDeleteUser = onDeleteUser, - ) + if (publicMemberList.isNotEmpty()) { + stickyHeader { + Column( + modifier = VertPadding, + ) { + Text( + text = stringRes(R.string.follow_set_public_members_header_label), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 2.dp, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + itemsIndexed(publicMemberList, key = { _, item -> item.pubkeyHex }) { _, item -> + FollowSetListItem( + modifier = Modifier.animateItem(), + user = item, + accountViewModel = accountViewModel, + nav = nav, + onDeleteUser = onDeleteUser, + ) + } + item { + Spacer(modifier = Modifier.height(30.dp)) + } + } + if (privateMemberList.isNotEmpty()) { + stickyHeader { + Text( + text = stringRes(R.string.follow_set_private_members_header_label), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 2.dp, + color = MaterialTheme.colorScheme.onBackground, + ) + } + itemsIndexed(privateMemberList, key = { _, item -> item.pubkeyHex }) { _, item -> + FollowSetListItem( + modifier = Modifier.animateItem(), + user = item, + accountViewModel = accountViewModel, + nav = nav, + onDeleteUser = onDeleteUser, + ) + } + item { + Spacer(modifier = Modifier.height(30.dp)) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetsManagementDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetsManagementDialog.kt index 2cc2c35f8..06986bee0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetsManagementDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetsManagementDialog.kt @@ -45,6 +45,8 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider @@ -69,6 +71,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -165,16 +168,18 @@ fun FollowSetsManagementDialog( ) { when (followSetsState) { is FollowSetFeedState.Loaded -> { - val lists = (followSetsState as FollowSetFeedState.Loaded).feed + val sets = (followSetsState as FollowSetFeedState.Loaded).feed + + sets.forEachIndexed { index, set -> - lists.forEachIndexed { index, list -> Spacer(StdVertSpacer) FollowSetItem( modifier = Modifier.fillMaxWidth(), - listHeader = list.title, - setVisibility = list.visibility, + listHeader = set.title, + setVisibility = set.visibility, userName = userInfo.toBestDisplayName(), - isUserInList = list.profiles.contains(userHex), + userIsPrivateMember = set.privateProfiles.contains(userHex), + userIsPublicMember = set.publicProfiles.contains(userHex), onRemoveUser = { Log.d( "Amethyst", @@ -182,23 +187,31 @@ fun FollowSetsManagementDialog( ) followSetsViewModel.removeUserFromSet( userHex, - list, + userIsPrivate = set.privateProfiles.contains(userHex), + set, account, ) Log.d( "Amethyst", - "Updated List. New size: ${list.profiles.size}", + "Updated List. Private profiles size: ${set.privateProfiles.size}," + + "Public profiles size: ${set.publicProfiles.size}", ) }, - onAddUser = { + onAddUserToList = { userShouldBePrivate -> Log.d( "Amethyst", "ProfileActions: Adding item to list ...", ) - followSetsViewModel.addUserToSet(userHex, list, account) + followSetsViewModel.addUserToSet( + userHex, + set, + userShouldBePrivate, + account, + ) Log.d( "Amethyst", - "Updated List. New size: ${list.profiles.size}", + "Updated List. Private profiles size: ${set.privateProfiles.size}," + + "Public profiles size: ${set.publicProfiles.size}", ) }, ) @@ -222,11 +235,11 @@ fun FollowSetsManagementDialog( if (followSetsState != FollowSetFeedState.Loading) { FollowSetsCreationMenu( userName = userInfo.toBestDisplayName(), - onSetCreate = { setName, setIsPrivate, description -> + onSetCreate = { setName, memberShouldBePrivate, description -> followSetsViewModel.addFollowSet( setName = setName, setDescription = description, - isListPrivate = setIsPrivate, + firstMemberShouldBePrivate = memberShouldBePrivate, optionalFirstMemberHex = userHex, account = account, ) @@ -306,11 +319,13 @@ fun FollowSetItem( listHeader: String, setVisibility: SetVisibility, userName: String, - isUserInList: Boolean, - onAddUser: () -> Unit, + userIsPrivateMember: Boolean, + userIsPublicMember: Boolean, + onAddUserToList: (shouldBePrivateMember: Boolean) -> Unit, onRemoveUser: () -> Unit, ) { val context = LocalContext.current + val isUserInList = userIsPrivateMember || userIsPublicMember Row( modifier = modifier @@ -330,26 +345,10 @@ fun FollowSetItem( ) { Text(listHeader, fontWeight = FontWeight.Bold) Spacer(modifier = StdHorzSpacer) - setVisibility.let { - val text by derivedStateOf { - when (it) { - SetVisibility.Public -> stringRes(context, R.string.follow_set_type_public) - SetVisibility.Private -> stringRes(context, R.string.follow_set_type_private) - SetVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed) - } - } - Icon( - painter = - painterResource( - when (setVisibility) { - SetVisibility.Public -> R.drawable.ic_public - SetVisibility.Private -> R.drawable.lock - SetVisibility.Mixed -> R.drawable.format_list_bulleted_type - }, - ), - contentDescription = stringRes(R.string.follow_set_type_description, text), - ) - } + Icon( + painter = painterResource(R.drawable.format_list_bulleted_type), + contentDescription = stringRes(R.string.follow_set_icon_description), + ) } Spacer(modifier = StdVertSpacer) @@ -362,7 +361,11 @@ fun FollowSetItem( Text( text = if (isUserInList) { - stringRes(R.string.follow_set_presence_indicator, userName) + if (userIsPublicMember) { + stringRes(R.string.follow_set_public_presence_indicator, userName) + } else { + stringRes(R.string.follow_set_private_presence_indicator, userName) + } } else { stringRes(R.string.follow_set_absence_indicator, userName) }, @@ -389,9 +392,14 @@ fun FollowSetItem( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { + val isUserAddTapped = remember { mutableStateOf(false) } IconButton( onClick = { - if (isUserInList) onRemoveUser() else onAddUser() + if (isUserInList) { + onRemoveUser() + } else { + isUserAddTapped.value = true + } }, modifier = Modifier @@ -423,15 +431,55 @@ fun FollowSetItem( text = stringRes(if (isUserInList) R.string.remove else R.string.add), color = MaterialTheme.colorScheme.onBackground, ) + + UserAdditionOptionsMenu( + isExpanded = isUserAddTapped.value, + onUserAdd = { shouldBePrivateMember -> + onAddUserToList(shouldBePrivateMember) + }, + onDismiss = { isUserAddTapped.value = false }, + ) } } } +@Composable +private fun UserAdditionOptionsMenu( + modifier: Modifier = Modifier, + isExpanded: Boolean, + onUserAdd: (asPrivateMember: Boolean) -> Unit, + onDismiss: () -> Unit, +) { + DropdownMenu( + expanded = isExpanded, + onDismissRequest = onDismiss, + ) { + DropdownMenuItem( + text = { + Text(text = stringRes(R.string.follow_set_public_member_add_label)) + }, + onClick = { + onUserAdd(false) + onDismiss() + }, + ) + DropdownMenuItem( + text = { + Text(text = stringRes(R.string.follow_set_private_member_add_label)) + }, + onClick = { + onUserAdd(true) + onDismiss() + }, + ) + } +} + @Composable fun FollowSetsCreationMenu( modifier: Modifier = Modifier, userName: String, - onSetCreate: (setName: String, setIsPrivate: Boolean, description: String?) -> Unit, + onSetCreate: (setName: String, memberShouldBePrivate: Boolean, description: String?) -> Unit, ) { val isListAdditionDialogOpen = remember { mutableStateOf(false) } val isPrivateOptionTapped = remember { mutableStateOf(false) } @@ -474,7 +522,6 @@ fun FollowSetsCreationMenu( isListAdditionDialogOpen.value = false isPrivateOptionTapped.value = false }, - shouldBePrivate = isPrivateOptionTapped.value, onCreateList = { name, description -> onSetCreate(name, isPrivateOptionTapped.value, description) }, @@ -528,7 +575,11 @@ fun FollowSetCreationItem( } Spacer(modifier = StdVertSpacer) Text( - stringRes(R.string.follow_set_creation_item_description, setTypeLabel, userName), + stringRes( + R.string.follow_set_creation_item_description, + userName, + setTypeLabel.lowercase(Locale.current.platformLocale), + ), fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 2, diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index a8c63c16b..7f78eb705 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -453,8 +453,7 @@ Zdá se, že zatím nemáte žádné sady sledování.\nKlepněte níže pro obnovení nebo použijte tlačítko přidat k vytvoření nové. Přidat autora do sady sledování Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem. - Ikona pro seznam %1$s - "%1$s je v tomto seznamu" + Ikona pro seznam %1$s "%1$s není v tomto seznamu" Vaše sady sledování Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové. diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 1d68921db..fc5948211 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -961,8 +961,7 @@ Zdá se, že zatím nemáte žádné sady sledování.\nKlepněte níže pro obnovení nebo použijte tlačítko přidat k vytvoření nové. Přidat autora do sady sledování Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem. - Ikona pro seznam %1$s - %1$s je v tomto seznamu + Ikona pro seznam %1$s %1$s není v tomto seznamu Vaše sady sledování Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 3aee42a42..08a8ba61b 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -459,8 +459,7 @@ anz der Bedingungen ist erforderlich Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen. Autor zum Folge-Set hinzufügen Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen. - Symbol für %1$s-Liste - "%1$s ist in dieser Liste" + Symbol für %1$s-Liste "%1$s ist nicht in dieser Liste" Deine Folge-Sets Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen. diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index 4d90c98c9..fd7c5ffa8 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -1001,8 +1001,7 @@ anz der Bedingungen ist erforderlich Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen. Autor zum Folge-Set hinzufügen Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen. - Symbol für %1$s-Liste - %1$s ist in dieser Liste + Symbol für %1$s-Liste %1$s ist nicht in dieser Liste Deine Folge-Sets Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen. diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index e1db6d5b0..cf5278fda 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -457,8 +457,7 @@ लेखक जोडें अनुगम्य सूची में प्रयोक्ता को सूचियों में जोडें अथवा हटाएँ अथवा इस प्रयोक्ता के साथ नई सूची बनाएँ। - सूची %1$s के लिए चिह्न - "%1$s इस सूची में है" + सूची %1$s के लिए चिह्न "%1$s इस सूची में नहीं है" आपके अनुगम्य सूचियाँ कोई अनुगम्य सूचियाँ प्राप्त नहीं। अथवा आपका कोई अनुगम्य सूचियाँ हैं नहीं। नवीकरण के लिए नीचे दबाएँ अथवा विकल्पसूची द्वारा एक नया बनाएँ। diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 790806e5d..81e96f8cd 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -457,8 +457,7 @@ Szerző hozzáadása a követési gyűjteményhez Felhasználó hozzáadása vagy eltávolítása a listákból, vagy új lista létrehozása ezzel a felhasználóval. - Ikon a(z) %1$s nevű listához - "A(z) %1$s már a létezik a listában" + Ikon a(z) %1$s nevű listához "A(z) %1$s nincs a listában" Saját követési gyüjtemények Nem találhatók követési gyüjtemények, vagy nincs követési gyüjteménye. Érintse meg az alábbi gombot a frissítéshez vagy használja a menüt egy gyüjtemény létrehozásához. diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index 9ecf8d4b7..6044600cf 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -114,7 +114,7 @@ Sekot saraksts - Ikona %1$s sarakstam + Ikona %1$s sarakstam Izveidot jaunu sarakstu Jaunais %1$s saraksts Kolekcijas nosaukums diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index ede2780e0..c11590812 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -454,8 +454,7 @@ Dodaj autora do zbioru obserwowanych Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem. - Ikona dla listy %1$s - "%1$s jest obecny na liście" + Ikona dla listy %1$s "%1$s nie jest na liście" Twój zbiór obserwowanych Nie znaleziono zbiorów obserwowanych lub nie masz żadnych zbiorów obserwowanych. Dotknij poniżej, aby odświeżyć lub użyj menu, aby go utworzyć. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 439c76e55..2c0879035 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -453,8 +453,7 @@ Parece que você ainda não tem conjuntos de seguimento.\nToque abaixo para atualizar ou use o botão de adicionar para criar um novo. Adicionar autor ao conjunto de seguimento Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário. - Ícone da lista %1$s - "%1$s está presente nesta lista" + Ícone da lista %1$s "%1$s não está nesta lista" Seus conjuntos de seguimento Nenhum conjunto de seguimento foi encontrado ou você não possui nenhum. Toque abaixo para atualizar ou use o menu para criar um. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 4eb00d879..32e93ab55 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -453,8 +453,7 @@ Det verkar som att du inte har några följ-set ännu.\nTryck nedan för att uppdatera eller använd plusknappen för att skapa ett nytt. Lägg till författare i följ-set Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare. - Ikon för %1$s-lista - "%1$s finns i denna lista" + Ikon för %1$s-lista "%1$s finns inte i denna lista" Dina följ-set Inga följ-set hittades, eller så har du inga. Tryck nedan för att uppdatera eller använd menyn för att skapa ett. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index c08294305..6cbb7f36c 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -457,8 +457,7 @@ 添加作者到关注集 从列表中添加或删除用户,或用此用户创建一个新列表。 - %1$s 列表的图标 - "此列表中有 %1$s" + %1$s 列表的图标 "此列表中没有 %1$s" 您的关注集 未找到关注集,或者你还没有任何关注集。轻按下方刷新,或使用按钮新建。 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 438dab8e3..81fec61e1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -519,22 +519,40 @@ It seems you do not have any follow sets yet. \nTap below to refresh, or tap the add buttons to create a new one. + New Add author to follow set Add or remove user from lists, or create a new list with this user. - Icon for %1$s List - "%1$s is present in this list" - "%1$s is not in this list" + Icon for follow set + %1$s is a public member + %1$s is a private member + Add as public member + Add as private member + Public Profiles + Private Profiles + member + members + No members + %1$s is not in this list Your Follow Sets No follow sets were found, or you don\'t have any follow sets. Tap below to refresh, or use the menu to create one. There was a problem while fetching: %1$s Make New List - "Create new %1$s list with user - Creates a %1$s follow set, and adds %2$s to it. - New %1$s List + New list with %1$s membership + Creates a new follow set, and adds %1$s as a %2$s member. + New Follow Set + Copy/Clone Follow Set + Modify description + This list doesn\'t have a description + Current description: + You can set a custom name/description for this clone set below. Set name + (Original Set name) Set description(optional) + (Original Set description) Create set + Copy/Clone set Rename set + Modify You are renaming from to.. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f8a20577..f10393594 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ languageId = "17.0.6" lazysodiumAndroid = "5.2.0" lazysodiumJava = "5.2.0" lifecycleRuntimeKtx = "2.9.4" -lightcompressor = "1.5.0" +lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" media3 = "1.8.0" mockk = "1.14.5" @@ -63,7 +63,7 @@ core = "1.7.0" mavenPublish = "0.34.0" [libraries] -abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor" } +abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } accompanist-adaptive = { group = "com.google.accompanist", name = "accompanist-adaptive", version.ref = "accompanistAdaptive" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistAdaptive" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } 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 48ec42f8b..1676c2bc5 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 @@ -155,6 +155,31 @@ class PeopleListEvent( ) } + suspend fun remove( + earlierVersion: PeopleListEvent, + person: UserTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent { + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + publicTags = earlierVersion.tags, + privateTags = privateTags.remove(person.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + return resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(person.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + } + suspend fun resign( publicTags: TagArray, privateTags: TagArray, @@ -223,51 +248,61 @@ class PeopleListEvent( title: String, description: String? = null, isPrivate: Boolean, - firstMemberHex: String? = null, + firstPublicMembers: List = emptyList(), + firstPrivateMembers: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit, ) { - if (description == null) { - val newList = - create( - name = title, - person = UserTag(pubKey = firstMemberHex ?: signer.pubKey), - isPrivate = isPrivate, - signer = signer, - dTag = dTag, - createdAt = createdAt, - ) - onReady(newList) - } else { - if (isPrivate) { - val event = - build( - name = title, - privatePeople = listOf(UserTag(pubKey = firstMemberHex ?: signer.pubKey)), - signer = signer, - dTag = dTag, - createdAt = createdAt, - ) { - addUnique(arrayOf("description", description)) - } - val list = signer.sign(event) - onReady(list) - } else { - val event = - build( - name = title, - publicPeople = listOf(UserTag(pubKey = firstMemberHex ?: signer.pubKey)), - signer = signer, - dTag = dTag, - createdAt = createdAt, - ) { - addUnique(arrayOf("description", description)) - } - val list = signer.sign(event) - onReady(list) + val newListTemplate = + build( + name = title, + publicPeople = + if (!isPrivate && firstPublicMembers.isNotEmpty()) { + firstPublicMembers.map { UserTag(pubKey = it) } + } else { + emptyList() + }, + privatePeople = + if (isPrivate && firstPrivateMembers.isNotEmpty()) { + firstPrivateMembers.map { UserTag(pubKey = it) } + } else { + emptyList() + }, + signer = signer, + dTag = dTag, + createdAt = createdAt, + ) { + if (description != null) addUnique(DescriptionTag.assemble(description)) } - } + val newList = signer.sign(newListTemplate) + onReady(newList) + } + + suspend fun copy( + dTag: String, + title: String, + description: String? = null, + firstPublicMembers: List = emptyList(), + firstPrivateMembers: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + val cloneTemplate = + build( + name = title, + publicPeople = firstPublicMembers.map { UserTag(pubKey = it) }, + privatePeople = firstPrivateMembers.map { UserTag(pubKey = it) }, + signer = signer, + dTag = dTag, + createdAt = createdAt, + ) { + if (description != null) addUnique(DescriptionTag.assemble(description)) + } + + val listClone = signer.sign(cloneTemplate) + onReady(listClone) } suspend fun createListWithUser( @@ -311,6 +346,7 @@ class PeopleListEvent( suspend fun removeUser( earlierVersion: PeopleListEvent, pubKeyHex: String, + isUserPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (PeopleListEvent) -> Unit, @@ -319,6 +355,7 @@ class PeopleListEvent( remove( earlierVersion = earlierVersion, person = UserTag(pubKey = pubKeyHex), + isPrivate = isUserPrivate, signer = signer, createdAt = createdAt, ) @@ -351,5 +388,51 @@ class PeopleListEvent( ) onReady(modified) } + + suspend fun modifyDescription( + earlierVersion: PeopleListEvent, + newDescription: String?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit = {}, + ) { + 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 + } else { + if (newDescription == null || newDescription.isEmpty()) { + val modified = + resign( + publicTags = earlierVersion.tags.remove { it[0] == DescriptionTag.TAG_NAME }, + privateTags = privateTags.remove { it[0] == DescriptionTag.TAG_NAME }, + signer = signer, + createdAt = createdAt, + ) + onReady(modified) + } else { + val newDescriptionTag = DescriptionTag.assemble(newDescription) + val modified = + 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, + ) + } + onReady(modified) + } + } + } } }