diff --git a/amethyst/build.gradle b/amethyst/build.gradle index aa3084380..4dcd2040b 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -151,10 +151,13 @@ android { signingConfig = signingConfigs.debug } } + // TODO: remove this when lightcompressor uses one MP4 parser only packaging { resources { resources.pickFirsts.add('builddef.lst') + resources.pickFirsts.add('META-INF/LICENSE.md') + resources.pickFirsts.add('META-INF/LICENSE-notice.md') } } @@ -361,6 +364,8 @@ dependencies { androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.junit.ktx androidTestImplementation libs.androidx.espresso.core + androidTestImplementation libs.androidx.ui.test.junit4 + androidTestImplementation libs.mockk.android debugImplementation platform(libs.androidx.compose.bom) debugImplementation libs.androidx.ui.tooling diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt new file mode 100644 index 000000000..8e33ba4f7 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ParagraphParserTest.kt @@ -0,0 +1,217 @@ +/** + * 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 + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.commons.richtext.ImageSegment +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.richtext.Segment +import com.vitorpamplona.amethyst.ui.components.ParagraphParser +import com.vitorpamplona.amethyst.ui.components.RenderContext +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList +import io.mockk.mockk +import kotlinx.collections.immutable.toImmutableList +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ParagraphParserTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testMixedImageAndVideoRenderedIndividually() { + // Test the bug: when mixed image + video, both should be rendered individually + val text = + "Renfield (2023)\n" + + "https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" + + "https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Extract the image segments from the parsed paragraphs + val imageSegments = mutableListOf() + state.paragraphs.forEach { paragraph -> + paragraph.words.forEach { word -> + if (word is ImageSegment) { + imageSegments.add(word) + } + } + } + + // Should have 2 image segments (image + video URLs) + assertEquals(2, imageSegments.size) + + // Track what gets rendered + val singleWordRenders = mutableListOf() + val galleryRenders = mutableListOf>() + + // Set up the test with mocked dependencies + val context = + RenderContext( + state = state, + backgroundColor = mutableStateOf(Color.White), + quotesLeft = 3, + callbackUri = null, + accountViewModel = mockk(relaxed = true), + nav = mockk(relaxed = true), + ) + + // Execute the actual ParagraphParser method + composeTestRule.setContent { + ParagraphParser().ProcessWordsWithImageGrouping( + words = imageSegments.toImmutableList(), + context = context, + renderSingleWord = { segment, _ -> + singleWordRenders.add(segment) + }, + renderGallery = { images, _ -> + galleryRenders.add(images) + }, + ) + } + + composeTestRule.waitForIdle() + + assertTrue( + "Mixed image/video should be rendered individually (2 renders), not as gallery (0 renders). " + + "Found: $singleWordRenders individual, $galleryRenders gallery", + singleWordRenders.size == 2 && galleryRenders.isEmpty(), + ) + } + + @Test + fun testMultipleImagesRenderedAsGallery() { + // Test that multiple images (no videos) are correctly grouped as gallery + val text = + "Gallery:\n" + + "https://example.com/image1.jpg\n" + + "https://example.com/image2.png" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Extract the image segments + val imageSegments = mutableListOf() + state.paragraphs.forEach { paragraph -> + paragraph.words.forEach { word -> + if (word is ImageSegment) { + imageSegments.add(word) + } + } + } + + assertEquals(2, imageSegments.size) + + // Track renders + val singleWordRenders = mutableListOf() + val galleryRenders = mutableListOf>() + + val context = + RenderContext( + state = state, + backgroundColor = mutableStateOf(Color.White), + quotesLeft = 3, + callbackUri = null, + accountViewModel = mockk(relaxed = true), + nav = mockk(relaxed = true), + ) + + composeTestRule.setContent { + ParagraphParser().ProcessWordsWithImageGrouping( + words = imageSegments.toImmutableList(), + context = context, + renderSingleWord = { segment, _ -> + singleWordRenders.add(segment) + }, + renderGallery = { images, _ -> + galleryRenders.add(images) + }, + ) + } + + composeTestRule.waitForIdle() + + // Should render as gallery (1 gallery with 2 images) + assertEquals("Should render 1 gallery", 1, galleryRenders.size) + assertEquals("Gallery should contain 2 images", 2, galleryRenders[0].size) + assertEquals("Should not render individually", 0, singleWordRenders.size) + } + + @Test + fun testSingleImageRenderedIndividually() { + // Test that a single image is rendered individually, not as gallery + val text = "Single image:\nhttps://example.com/image.jpg" + + val state = RichTextParser().parseText(text, EmptyTagList, null) + + val imageSegments = mutableListOf() + state.paragraphs.forEach { paragraph -> + paragraph.words.forEach { word -> + if (word is ImageSegment) { + imageSegments.add(word) + } + } + } + + assertEquals(1, imageSegments.size) + + val singleWordRenders = mutableListOf() + val galleryRenders = mutableListOf>() + + val context = + RenderContext( + state = state, + backgroundColor = mutableStateOf(Color.White), + quotesLeft = 3, + callbackUri = null, + accountViewModel = mockk(relaxed = true), + nav = mockk(relaxed = true), + ) + + composeTestRule.setContent { + ParagraphParser().ProcessWordsWithImageGrouping( + words = imageSegments.toImmutableList(), + context = context, + renderSingleWord = { segment, _ -> + singleWordRenders.add(segment) + }, + renderGallery = { images, _ -> + galleryRenders.add(images) + }, + ) + } + + composeTestRule.waitForIdle() + + // Should render individually (not as gallery) + assertEquals("Should render 1 individual image", 1, singleWordRenders.size) + assertEquals("Should not render as gallery", 0, galleryRenders.size) + } +} 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/components/ParagraphParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt index a943b37af..e26cefc06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ParagraphParser.kt @@ -197,19 +197,26 @@ class ParagraphParser { j++ } - if (imageSegments.size > 1) { - val imageContents = - imageSegments - .mapNotNull { segment -> - val imageUrl = segment.segmentText - context.state.imagesForPager[imageUrl] as? MediaUrlImage - }.toImmutableList() - - if (imageContents.isNotEmpty()) { - renderGallery(imageContents, context.accountViewModel) - } - } else { + if (imageSegments.size <= 1) { renderSingleWord(imageSegments.firstOrNull() ?: word, context) + } else { + val resolvedImages = + imageSegments.mapNotNull { segment -> + val imageUrl = segment.segmentText + context.state.imagesForPager[imageUrl] as? MediaUrlImage + } + + // Render gallery only if all segments are images + if (resolvedImages.size == imageSegments.size) { + renderGallery( + resolvedImages.toImmutableList(), + context.accountViewModel, + ) + } else { + imageSegments.forEach { segment -> + renderSingleWord(segment, context) + } + } } i = j // jump past processed run 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..472c4e505 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -453,9 +453,8 @@ 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" - "%1$s není 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é. Došlo k problému při načítání: %1$s @@ -1064,4 +1063,24 @@ Odeslat Tato zpráva zmizí za %1$d dní Vybrat podepisovatele + + Nový + %1$s je veřejný člen + %1$s je soukromý člen + Přidat jako veřejného člena + Přidat jako soukromého člena + Veřejné profily + Soukromé profily + člen + členové + Žádní členové + Kopírovat/Klonovat sadu sledování + Upravit popis + Tento seznam nemá žádný popis + Aktuální popis: + Níže můžete nastavit vlastní název/popisek pro tuto klonovanou sadu. + (Původní název sady) + (Původní popis sady) + Kopírovat/Klonovat sadu + Upravit 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..1f0aad6a7 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -459,9 +459,8 @@ 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" - "%1$s ist nicht 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. Beim Abrufen ist ein Problem aufgetreten: %1$s @@ -1069,4 +1068,24 @@ anz der Bedingungen ist erforderlich Senden Diese Nachricht verschwindet in %1$d Tagen Signierer auswählen + + Neu + %1$s ist ein öffentliches Mitglied + %1$s ist ein privates Mitglied + Als öffentliches Mitglied hinzufügen + Als privates Mitglied hinzufügen + Öffentliche Profile + Private Profile + Mitglied + Mitglieder + Keine Mitglieder + Follow-Set kopieren/klonen + Beschreibung bearbeiten + Diese Liste hat keine Beschreibung + Aktuelle Beschreibung: + Unten kannst du einen eigenen Namen/Beschreibung für diesen Klon festlegen. + (Original-Set-Name) + (Original-Set-Beschreibung) + Set kopieren/klonen + Bearbeiten 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-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 78a43c082..205e5287f 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -112,6 +112,7 @@ Publicar Guardar Crear + Cambiar nombre Cancelar No se pudo cargar la imagen Dirección del relé @@ -445,6 +446,28 @@ A mi alrededor Global Lista de silenciados + Conjuntos de seguimiento + Marcadores etiquetados + Marcadores generales + Público + Privado + Mixto + Al parecer, aún no tienes ningún conjunto de seguimiento. + \nPulsa abajo para actualizar o pulsa los botones de añadir para crear uno nuevo. + + Añadir autor a conjunto de seguimiento + Añadir o eliminar usuario de las listas, o crear una nueva lista con este usuario. + %1$s no se encuentra en esta lista + Tus conjuntos de seguimiento + No se han encontrado conjuntos de seguimiento, o no tienes ningún conjunto de seguimiento. Pulsa abajo para actualizar o utiliza el menú para crear uno. + Ha habido un problema al recuperar: %1$s + Crear nueva lista + Nombre del conjunto + Descripción del conjunto (opcional) + Crear conjunto + Cambiar nombre del conjunto + Estás cambiando el nombre de + a.. El puerto predeterminado es 9050 ## Conéctate a través de Tor con Orbot \n\n1. Instala [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android). @@ -832,6 +855,8 @@ Media Alta Sin comprimir + Usa H.265/HEVC Codec + Mejor calidad en tamaños de archivos más pequeños, pero no todos los dispositivos admiten la reproducción H.265. Editar borrador Iniciar sesión con código QR Ruta @@ -1014,6 +1039,8 @@ Descargar Error al abrir el archivo No hay aplicaciones torrent instaladas para abrir y descargar el archivo. + El evento no tiene suficiente información para crear un enlace magnético + Mis listas/conjuntos Selecciona una lista para filtrar el tablón Cerrar sesión al bloquear dispositivo Mensaje privado @@ -1035,4 +1062,5 @@ ¿Te gustaría enviar el informe de error reciente a Amethyst en un mensaje directo? No se compartirá información personal Enviarlo Este mensaje desaparecerá en %1$d días + Seleccionar firmante diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 7a07b168f..64f8bcab9 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -111,6 +111,7 @@ Publicar Guardar Crear + Cambiar nombre Cancelar Error al subir la imagen Dirección del relé @@ -440,6 +441,28 @@ A mi alrededor Global Lista de silenciados + Conjuntos de seguimiento + Marcadores etiquetados + Marcadores generales + Público + Privado + Mixto + Al parecer, aún no tienes ningún conjunto de seguimiento. + \nToca abajo para actualizar o toca los botones de agregar para crear uno nuevo. + + Agregar autor a conjunto de seguimiento + Agregar o quitar usuario de las listas, o crear una nueva lista con este usuario. + %1$s no se encuentra en esta lista + Tus conjuntos de seguimiento + No se han encontrado conjuntos de seguimiento, o no tienes ningún conjunto de seguimiento. Toca abajo para actualizar o utiliza el menú para crear uno. + Hubo un problema al recuperar: %1$s + Crear nueva lista + Nombre del conjunto + Descripción del conjunto (opcional) + Crear conjunto + Cambiar nombre del conjunto + Estás cambiando el nombre de + a.. El puerto predeterminado es 9050 ## Conéctate a través de Tor con Orbot \n\n1. Instala [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android). @@ -822,6 +845,8 @@ Media Alta Sin comprimir + Usa H.265/HEVC Codec + Mejor calidad en tamaños de archivos más pequeños, pero no todos los dispositivos admiten la reproducción H.265. Editar borrador Iniciar sesión con código QR Ruta @@ -1001,6 +1026,8 @@ Descargar Error al abrir el archivo No hay aplicaciones torrent instaladas para abrir y descargar el archivo. + El evento no tiene suficiente información para crear un enlace magnético + Mis listas/conjuntos Selecciona una lista para filtrar el feed Cerrar sesión al bloquear dispositivo Mensaje privado @@ -1011,4 +1038,5 @@ Buscar hashtag: #%1$s No traducir del Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo. + Seleccionar firmante diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index d9be7f50f..bb790d8d7 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -111,6 +111,7 @@ Publicar Guardar Crear + Cambiar nombre Cancelar Error al subir la imagen Dirección del relé @@ -440,6 +441,28 @@ A mi alrededor Global Lista de silenciados + Conjuntos de seguimiento + Marcadores etiquetados + Marcadores generales + Público + Privado + Mixto + Al parecer, aún no tienes ningún conjunto de seguimiento. + \nToca abajo para actualizar o toca los botones de agregar para crear uno nuevo. + + Agregar autor a conjunto de seguimiento + Agregar o quitar usuario de las listas, o crear una nueva lista con este usuario. + %1$s no se encuentra en esta lista + Tus conjuntos de seguimiento + No se han encontrado conjuntos de seguimiento, o no tienes ningún conjunto de seguimiento. Toca abajo para actualizar o utiliza el menú para crear uno. + Hubo un problema al recuperar: %1$s + Crear nueva lista + Nombre del conjunto + Descripción del conjunto (opcional) + Crear conjunto + Cambiar nombre del conjunto + Estás cambiando el nombre de + a.. El puerto predeterminado es 9050 ## Conéctate a través de Tor con Orbot \n\n1. Instala [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android). @@ -822,6 +845,8 @@ Media Alta Sin comprimir + Usa H.265/HEVC Codec + Mejor calidad en tamaños de archivos más pequeños, pero no todos los dispositivos admiten la reproducción H.265. Editar borrador Iniciar sesión con código QR Ruta @@ -1001,6 +1026,8 @@ Descargar Error al abrir el archivo No hay aplicaciones torrent instaladas para abrir y descargar el archivo. + El evento no tiene suficiente información para crear un enlace magnético + Mis listas/conjuntos Selecciona una lista para filtrar el feed Cerrar sesión al bloquear dispositivo Mensaje privado @@ -1011,4 +1038,5 @@ Buscar hashtag: #%1$s No traducir del Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo. + Seleccionar firmante diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index e1db6d5b0..9e54ce9e4 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -457,9 +457,8 @@ लेखक जोडें अनुगम्य सूची में प्रयोक्ता को सूचियों में जोडें अथवा हटाएँ अथवा इस प्रयोक्ता के साथ नई सूची बनाएँ। - सूची %1$s के लिए चिह्न - "%1$s इस सूची में है" - "%1$s इस सूची में नहीं है" + सूची %1$s के लिए चिह्न + %1$s इस सूची में नहीं है आपके अनुगम्य सूचियाँ कोई अनुगम्य सूचियाँ प्राप्त नहीं। अथवा आपका कोई अनुगम्य सूचियाँ हैं नहीं। नवीकरण के लिए नीचे दबाएँ अथवा विकल्पसूची द्वारा एक नया बनाएँ। लाने में अपक्रम : %1$s @@ -860,6 +859,8 @@ मध्यम उच्च असंकुचित + उच्च कार्यक्षमता चलचित्र संकुचनविस्तारण अथवा एच॰२६५ क्रमलेख का प्रयोग करें + अधिक गुणवत्ता के साथ सूक्ष्मतर अभिलेख आकार परन्तु एच॰२६५ चालन सभी यन्त्रों द्वारा अवलम्बित नहीं। पाण्डुलिपि का सम्पादन करें क्यूआर॰ क्रमचित्र के साथ प्रवेशांकन करें मार्ग diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 790806e5d..4fb56b773 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -457,9 +457,8 @@ 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" - "A(z) %1$s nincs 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. Probléma történt a következő lekérdezésekor: %1$s 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..408a5ec41 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -454,9 +454,8 @@ 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" - "%1$s nie jest 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ć. Podczas pobierania wystąpił błąd: %1$s diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 439c76e55..2c716eb7a 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -453,9 +453,8 @@ 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" - "%1$s não está 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. Houve um problema ao buscar: %1$s @@ -1064,4 +1063,24 @@ Enviar Esta mensagem desaparecerá em %1$d dias Selecionar assinador + + Novo + %1$s é um membro público + %1$s é um membro privado + Adicionar como membro público + Adicionar como membro privado + Perfis públicos + Perfis privados + membro + membros + Nenhum membro + Copiar/Clonar conjunto de seguidores + Modificar descrição + Esta lista não tem descrição + Descrição atual: + Você pode definir um nome/descrição personalizada para este conjunto clonado abaixo. + (Nome original do conjunto) + (Descrição original do conjunto) + Copiar/Clonar conjunto + Modificar diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index a5ad70e03..fc189ad67 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -123,6 +123,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Pošlji Shrani Ustvari + Preimenuj Prekliči Neuspelo nalaganje slike Naslov releja @@ -456,8 +457,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem V moji okolici Globalno Spisek utišanih + Označeni zaznamki + Splošni zaznamki + Javno Zasebno Mešano + Dodaj ali odstrani uprabnika iz seznama, ali pa ustvari nov seznam s tem uporabnikom. + Ustvari nov seznam Prevzeta vrata so 9050 ## Poveži se preko Tor omrežja z Orbot aplikacijo \n\n1. Namesti [Orbot aplikacijo](https://play.google.com/store/apps/details?id=org.torproject.android) @@ -845,6 +851,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Srednja Visoka Nestisnjeno + Uporabi H.265/HEVC Codec Uredi osnutek Vpiši se z QR kodo Pot @@ -1049,4 +1056,5 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Želite poslati poročilo o napaki Amethyst-u preko zasebnega sporočila? Vaši osebni podatki NE bojo posredovani. Pošlji To sporočilo bo izginilo čez %1$d dni + Izberi podpisnika diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 4eb00d879..c79d279f2 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -453,9 +453,8 @@ 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" - "%1$s finns inte 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. Ett problem uppstod vid hämtning: %1$s @@ -1063,4 +1062,24 @@ Skicka Detta meddelande försvinner om %1$d dagar Välj signatör + Ny + %1$s är en offentlig medlem + %1$s är en privat medlem + Lägg till som offentlig medlem + Lägg till som privat medlem + Offentliga profiler + Privata profiler + medlem + medlemmar + Inga medlemmar + Kopiera/Klona följlista + Ändra beskrivning + Den här listan har ingen beskrivning + Nuvarande beskrivning: + Du kan ange ett anpassat namn/beskrivning för denna klonlista nedan. + (Ursprungligt listnamn) + (Ursprunglig beskrivning) + Kopiera/Klona lista + Ändra + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index c08294305..434f9503b 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -457,9 +457,8 @@ 添加作者到关注集 从列表中添加或删除用户,或用此用户创建一个新列表。 - %1$s 列表的图标 - "此列表中有 %1$s" - "此列表中没有 %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 c1527b5dd..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" } @@ -102,6 +102,7 @@ androidx-security-crypto-ktx = { group = "androidx.security", name = "security-c androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } audiowaveform = { group = "com.github.lincollincol", name = "compose-audiowaveform", version.ref = "audiowaveform" } @@ -128,6 +129,7 @@ markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", nam markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" } markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt index 0459ad5f0..1118b2135 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt @@ -134,7 +134,7 @@ class BookmarkListEvent( val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() resign( privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()), - tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()), + tags = earlierVersion.tags, signer = signer, createdAt = createdAt, ) 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) + } + } + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEventTest.kt new file mode 100644 index 000000000..549253cf2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEventTest.kt @@ -0,0 +1,123 @@ +/** + * 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.quartz.nip51Lists.bookmarkList + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BookmarkListEventTest { + private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + + @Test + fun publicTagsPreservedWhenRemovingFromPrivateBookmarks() = + runBlocking { + // Create a test event bookmark + val testEventId = "a".repeat(64) + val testBookmark = EventBookmark(testEventId) + + // Create a bookmark list with event in public bookmarks + val initialEvent = + BookmarkListEvent.create( + publicBookmarks = listOf(testBookmark), + privateBookmarks = emptyList(), + signer = signer, + createdAt = 1740669816, + ) + + // Count the public tags (should be 1 for the bookmark) + val initialPublicTagCount = initialEvent.tags.count { tag -> tag.size >= 2 && tag[0] == "e" && tag[1] == testEventId } + assertTrue( + initialPublicTagCount == 1, + "Should have exactly 1 public bookmark tag initially", + ) + + // Try to remove the bookmark from private bookmarks (even though it's only in public) + // This simulates the scenario where a bookmark exists in both lists + val updatedEvent = + BookmarkListEvent.remove( + earlierVersion = initialEvent, + bookmarkIdTag = testBookmark, + isPrivate = true, + signer = signer, + createdAt = 1740669817, + ) + + // Count the public tags again + val finalPublicTagCount = updatedEvent.tags.count { tag -> tag.size >= 2 && tag[0] == "e" && tag[1] == testEventId } + + // CRITICAL: The public tag count should be unchanged + // Without the fix, this would be 0 because the bug also removed public tags + assertTrue( + finalPublicTagCount == initialPublicTagCount, + "Public tags should be preserved when removing from private bookmarks. Initial: $initialPublicTagCount, Final: $finalPublicTagCount", + ) + + // Also verify using publicBookmarks() method + assertTrue( + updatedEvent.publicBookmarks().any { it is EventBookmark && it.eventId == testEventId }, + "Bookmark should still be accessible via publicBookmarks() method", + ) + } + + @Test + fun publicTagsRemovedWhenRemovingFromPublicBookmarks() = + runBlocking { + // Create a test event bookmark + val testEventId = "b".repeat(64) + val testBookmark = EventBookmark(testEventId) + + // Create a bookmark list with event only in public bookmarks + val initialEvent = + BookmarkListEvent.create( + publicBookmarks = listOf(testBookmark), + privateBookmarks = emptyList(), + signer = signer, + createdAt = 1740669816, + ) + + // Remove from public bookmarks + val updatedEvent = + BookmarkListEvent.remove( + earlierVersion = initialEvent, + bookmarkIdTag = testBookmark, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + // Verify the bookmark was removed from public bookmarks + val finalPublicTagCount = updatedEvent.tags.count { tag -> tag.size >= 2 && tag[0] == "e" && tag[1] == testEventId } + assertTrue( + finalPublicTagCount == 0, + "Public tags should be removed when isPrivate=false", + ) + + assertFalse( + updatedEvent.publicBookmarks().any { it is EventBookmark && it.eventId == testEventId }, + "Bookmark should be removed from public bookmarks", + ) + } +}