This commit is contained in:
Vitor Pamplona
2025-10-14 14:15:38 -04:00
33 changed files with 1286 additions and 337 deletions
+5
View File
@@ -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
@@ -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<ImageSegment>()
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<Segment>()
val galleryRenders = mutableListOf<List<MediaUrlImage>>()
// Set up the test with mocked dependencies
val context =
RenderContext(
state = state,
backgroundColor = mutableStateOf(Color.White),
quotesLeft = 3,
callbackUri = null,
accountViewModel = mockk<AccountViewModel>(relaxed = true),
nav = mockk<INav>(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<ImageSegment>()
state.paragraphs.forEach { paragraph ->
paragraph.words.forEach { word ->
if (word is ImageSegment) {
imageSegments.add(word)
}
}
}
assertEquals(2, imageSegments.size)
// Track renders
val singleWordRenders = mutableListOf<Segment>()
val galleryRenders = mutableListOf<List<MediaUrlImage>>()
val context =
RenderContext(
state = state,
backgroundColor = mutableStateOf(Color.White),
quotesLeft = 3,
callbackUri = null,
accountViewModel = mockk<AccountViewModel>(relaxed = true),
nav = mockk<INav>(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<ImageSegment>()
state.paragraphs.forEach { paragraph ->
paragraph.words.forEach { word ->
if (word is ImageSegment) {
imageSegments.add(word)
}
}
}
assertEquals(1, imageSegments.size)
val singleWordRenders = mutableListOf<Segment>()
val galleryRenders = mutableListOf<List<MediaUrlImage>>()
val context =
RenderContext(
state = state,
backgroundColor = mutableStateOf(Color.White),
quotesLeft = 3,
callbackUri = null,
accountViewModel = mockk<AccountViewModel>(relaxed = true),
nav = mockk<INav>(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)
}
}
@@ -31,9 +31,10 @@ data class FollowSet(
val identifierTag: String,
val title: String,
val description: String?,
val visibility: SetVisibility,
val profiles: Set<String>,
) : NostrSet(setVisibility = visibility, content = profiles) {
val visibility: SetVisibility = SetVisibility.Mixed,
val privateProfiles: Set<String> = emptySet(),
val publicProfiles: Set<String> = 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(),
)
}
}
@@ -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 {}
}
}
}
@@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.model.nip51Lists.followSets
sealed class NostrSet(
val setVisibility: SetVisibility,
val content: Collection<String>,
val privateContent: Collection<String>,
val publicContent: Collection<String>,
)
class CuratedBookmarkSet(
val name: String,
val visibility: SetVisibility,
val setItems: List<String>,
) : NostrSet(visibility, setItems)
val privateSetItems: List<String>,
val publicSetItems: List<String>,
) : NostrSet(visibility, privateSetItems, publicSetItems)
@@ -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),
)
}
}
@@ -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
@@ -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<String?>(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.")
},
@@ -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<String?>(null) }
val optionalCloneDescription = remember { mutableStateOf<String?>(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<String?>(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))
}
},
)
}
@@ -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)
},
@@ -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)
@@ -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<User>,
publicMemberList: List<User>,
privateMemberList: List<User>,
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))
}
}
}
}
@@ -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,
@@ -453,9 +453,8 @@
<string name="follow_set_empty_feed_msg">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é.</string>
<string name="follow_set_add_author_from_note_action">Přidat autora do sady sledování</string>
<string name="follow_set_profile_actions_menu_description">Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem.</string>
<string name="follow_set_type_description">Ikona pro seznam %1$s</string>
<string name="follow_set_presence_indicator">"%1$s je v tomto seznamu"</string>
<string name="follow_set_absence_indicator">"%1$s není v tomto seznamu"</string>
<string name="follow_set_icon_description">Ikona pro seznam %1$s</string>
<string name="follow_set_absence_indicator">%1$s není v tomto seznamu</string>
<string name="follow_set_man_dialog_title">Vaše sady sledování</string>
<string name="follow_set_empty_dialog_msg">Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové.</string>
<string name="follow_set_error_dialog_msg">Došlo k problému při načítání: %1$s</string>
@@ -1064,4 +1063,24 @@
<string name="crashreport_found_send">Odeslat</string>
<string name="this_message_will_disappear_in_days">Tato zpráva zmizí za %1$d dní</string>
<string name="select_signer">Vybrat podepisovatele</string>
<string name="follow_set_create_btn_label">Nový</string>
<string name="follow_set_public_presence_indicator">%1$s je veřejný člen</string>
<string name="follow_set_private_presence_indicator">%1$s je soukromý člen</string>
<string name="follow_set_public_member_add_label">Přidat jako veřejného člena</string>
<string name="follow_set_private_member_add_label">Přidat jako soukromého člena</string>
<string name="follow_set_public_members_header_label">Veřejné profily</string>
<string name="follow_set_private_members_header_label">Soukromé profily</string>
<string name="follow_set_single_member_label">člen</string>
<string name="follow_set_multiple_member_label">členové</string>
<string name="follow_set_empty_label">Žádní členové</string>
<string name="follow_set_copy_dialog_title">Kopírovat/Klonovat sadu sledování</string>
<string name="follow_set_desc_modify_label">Upravit popis</string>
<string name="follow_set_empty_desc_label">Tento seznam nemá žádný popis</string>
<string name="follow_set_current_desc_label">Aktuální popis:</string>
<string name="follow_set_copy_indicator_description">Níže můžete nastavit vlastní název/popisek pro tuto klonovanou sadu.</string>
<string name="follow_set_copy_name_label">(Původní název sady)</string>
<string name="follow_set_copy_desc_label">(Původní popis sady)</string>
<string name="follow_set_copy_action_btn_label">Kopírovat/Klonovat sadu</string>
<string name="follow_set_desc_modify_btn_label">Upravit</string>
</resources>
+1 -2
View File
@@ -961,8 +961,7 @@
<string name="follow_set_empty_feed_msg">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é.</string>
<string name="follow_set_add_author_from_note_action">Přidat autora do sady sledování</string>
<string name="follow_set_profile_actions_menu_description">Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem.</string>
<string name="follow_set_type_description">Ikona pro seznam %1$s</string>
<string name="follow_set_presence_indicator">%1$s je v tomto seznamu</string>
<string name="follow_set_icon_description">Ikona pro seznam %1$s</string>
<string name="follow_set_absence_indicator">%1$s není v tomto seznamu</string>
<string name="follow_set_man_dialog_title">Vaše sady sledování</string>
<string name="follow_set_empty_dialog_msg">Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové.</string>
@@ -459,9 +459,8 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_empty_feed_msg">Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen.</string>
<string name="follow_set_add_author_from_note_action">Autor zum Folge-Set hinzufügen</string>
<string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string>
<string name="follow_set_type_description">Symbol für %1$s-Liste</string>
<string name="follow_set_presence_indicator">"%1$s ist in dieser Liste"</string>
<string name="follow_set_absence_indicator">"%1$s ist nicht in dieser Liste"</string>
<string name="follow_set_icon_description">Symbol für %1$s-Liste</string>
<string name="follow_set_absence_indicator">%1$s ist nicht in dieser Liste</string>
<string name="follow_set_man_dialog_title">Deine Folge-Sets</string>
<string name="follow_set_empty_dialog_msg">Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen.</string>
<string name="follow_set_error_dialog_msg">Beim Abrufen ist ein Problem aufgetreten: %1$s</string>
@@ -1069,4 +1068,24 @@ anz der Bedingungen ist erforderlich</string>
<string name="crashreport_found_send">Senden</string>
<string name="this_message_will_disappear_in_days">Diese Nachricht verschwindet in %1$d Tagen</string>
<string name="select_signer">Signierer auswählen</string>
<string name="follow_set_create_btn_label">Neu</string>
<string name="follow_set_public_presence_indicator">%1$s ist ein öffentliches Mitglied</string>
<string name="follow_set_private_presence_indicator">%1$s ist ein privates Mitglied</string>
<string name="follow_set_public_member_add_label">Als öffentliches Mitglied hinzufügen</string>
<string name="follow_set_private_member_add_label">Als privates Mitglied hinzufügen</string>
<string name="follow_set_public_members_header_label">Öffentliche Profile</string>
<string name="follow_set_private_members_header_label">Private Profile</string>
<string name="follow_set_single_member_label">Mitglied</string>
<string name="follow_set_multiple_member_label">Mitglieder</string>
<string name="follow_set_empty_label">Keine Mitglieder</string>
<string name="follow_set_copy_dialog_title">Follow-Set kopieren/klonen</string>
<string name="follow_set_desc_modify_label">Beschreibung bearbeiten</string>
<string name="follow_set_empty_desc_label">Diese Liste hat keine Beschreibung</string>
<string name="follow_set_current_desc_label">Aktuelle Beschreibung:</string>
<string name="follow_set_copy_indicator_description">Unten kannst du einen eigenen Namen/Beschreibung für diesen Klon festlegen.</string>
<string name="follow_set_copy_name_label">(Original-Set-Name)</string>
<string name="follow_set_copy_desc_label">(Original-Set-Beschreibung)</string>
<string name="follow_set_copy_action_btn_label">Set kopieren/klonen</string>
<string name="follow_set_desc_modify_btn_label">Bearbeiten</string>
</resources>
+1 -2
View File
@@ -1001,8 +1001,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="follow_set_empty_feed_msg">Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen.</string>
<string name="follow_set_add_author_from_note_action">Autor zum Folge-Set hinzufügen</string>
<string name="follow_set_profile_actions_menu_description">Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen.</string>
<string name="follow_set_type_description">Symbol für %1$s-Liste</string>
<string name="follow_set_presence_indicator">%1$s ist in dieser Liste</string>
<string name="follow_set_icon_description">Symbol für %1$s-Liste</string>
<string name="follow_set_absence_indicator">%1$s ist nicht in dieser Liste</string>
<string name="follow_set_man_dialog_title">Deine Folge-Sets</string>
<string name="follow_set_empty_dialog_msg">Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen.</string>
@@ -112,6 +112,7 @@
<string name="post">Publicar</string>
<string name="save">Guardar</string>
<string name="create">Crear</string>
<string name="rename">Cambiar nombre</string>
<string name="cancel">Cancelar</string>
<string name="failed_to_upload_the_image">No se pudo cargar la imagen</string>
<string name="relay_address">Dirección del relé</string>
@@ -445,6 +446,28 @@
<string name="follow_list_aroundme">A mi alrededor</string>
<string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Lista de silenciados</string>
<string name="follow_sets">Conjuntos de seguimiento</string>
<string name="labeled_bookmarks">Marcadores etiquetados</string>
<string name="general_bookmarks">Marcadores generales</string>
<string name="follow_set_type_public">Público</string>
<string name="follow_set_type_private">Privado</string>
<string name="follow_set_type_mixed">Mixto</string>
<string name="follow_set_empty_feed_msg"> 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.
</string>
<string name="follow_set_add_author_from_note_action">Añadir autor a conjunto de seguimiento</string>
<string name="follow_set_profile_actions_menu_description">Añadir o eliminar usuario de las listas, o crear una nueva lista con este usuario.</string>
<string name="follow_set_absence_indicator">%1$s no se encuentra en esta lista</string>
<string name="follow_set_man_dialog_title">Tus conjuntos de seguimiento</string>
<string name="follow_set_empty_dialog_msg">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.</string>
<string name="follow_set_error_dialog_msg">Ha habido un problema al recuperar: %1$s</string>
<string name="follow_set_creation_menu_title">Crear nueva lista</string>
<string name="follow_set_creation_name_label">Nombre del conjunto</string>
<string name="follow_set_creation_desc_label">Descripción del conjunto (opcional)</string>
<string name="follow_set_creation_action_btn_label">Crear conjunto</string>
<string name="follow_set_rename_btn_label">Cambiar nombre del conjunto</string>
<string name="follow_set_rename_dialog_indicator_first_part">Estás cambiando el nombre de </string>
<string name="follow_set_rename_dialog_indicator_second_part"> a..</string>
<string name="connect_through_your_orbot_setup_short">El puerto predeterminado es 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 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 @@
<string name="media_compression_quality_medium">Media</string>
<string name="media_compression_quality_high">Alta</string>
<string name="media_compression_quality_uncompressed">Sin comprimir</string>
<string name="video_codec_h265_label">Usa H.265/HEVC Codec</string>
<string name="video_codec_h265_description">Mejor calidad en tamaños de archivos más pequeños, pero no todos los dispositivos admiten la reproducción H.265.</string>
<string name="edit_draft">Editar borrador</string>
<string name="login_with_qr_code">Iniciar sesión con código QR</string>
<string name="route">Ruta</string>
@@ -1014,6 +1039,8 @@
<string name="torrent_download">Descargar</string>
<string name="torrent_failure">Error al abrir el archivo</string>
<string name="torrent_no_apps">No hay aplicaciones torrent instaladas para abrir y descargar el archivo.</string>
<string name="torrent_no_info">El evento no tiene suficiente información para crear un enlace magnético</string>
<string name="my_lists_and_sets">Mis listas/conjuntos</string>
<string name="select_list_to_filter">Selecciona una lista para filtrar el tablón</string>
<string name="temporary_account">Cerrar sesión al bloquear dispositivo</string>
<string name="private_message">Mensaje privado</string>
@@ -1035,4 +1062,5 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">¿Te gustaría enviar el informe de error reciente a Amethyst en un mensaje directo? No se compartirá información personal</string>
<string name="crashreport_found_send">Enviarlo</string>
<string name="this_message_will_disappear_in_days">Este mensaje desaparecerá en %1$d días</string>
<string name="select_signer">Seleccionar firmante</string>
</resources>
@@ -111,6 +111,7 @@
<string name="post">Publicar</string>
<string name="save">Guardar</string>
<string name="create">Crear</string>
<string name="rename">Cambiar nombre</string>
<string name="cancel">Cancelar</string>
<string name="failed_to_upload_the_image">Error al subir la imagen</string>
<string name="relay_address">Dirección del relé</string>
@@ -440,6 +441,28 @@
<string name="follow_list_aroundme">A mi alrededor</string>
<string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Lista de silenciados</string>
<string name="follow_sets">Conjuntos de seguimiento</string>
<string name="labeled_bookmarks">Marcadores etiquetados</string>
<string name="general_bookmarks">Marcadores generales</string>
<string name="follow_set_type_public">Público</string>
<string name="follow_set_type_private">Privado</string>
<string name="follow_set_type_mixed">Mixto</string>
<string name="follow_set_empty_feed_msg"> 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.
</string>
<string name="follow_set_add_author_from_note_action">Agregar autor a conjunto de seguimiento</string>
<string name="follow_set_profile_actions_menu_description">Agregar o quitar usuario de las listas, o crear una nueva lista con este usuario.</string>
<string name="follow_set_absence_indicator">%1$s no se encuentra en esta lista</string>
<string name="follow_set_man_dialog_title">Tus conjuntos de seguimiento</string>
<string name="follow_set_empty_dialog_msg">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.</string>
<string name="follow_set_error_dialog_msg">Hubo un problema al recuperar: %1$s</string>
<string name="follow_set_creation_menu_title">Crear nueva lista</string>
<string name="follow_set_creation_name_label">Nombre del conjunto</string>
<string name="follow_set_creation_desc_label">Descripción del conjunto (opcional)</string>
<string name="follow_set_creation_action_btn_label">Crear conjunto</string>
<string name="follow_set_rename_btn_label">Cambiar nombre del conjunto</string>
<string name="follow_set_rename_dialog_indicator_first_part">Estás cambiando el nombre de </string>
<string name="follow_set_rename_dialog_indicator_second_part"> a..</string>
<string name="connect_through_your_orbot_setup_short">El puerto predeterminado es 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 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 @@
<string name="media_compression_quality_medium">Media</string>
<string name="media_compression_quality_high">Alta</string>
<string name="media_compression_quality_uncompressed">Sin comprimir</string>
<string name="video_codec_h265_label">Usa H.265/HEVC Codec</string>
<string name="video_codec_h265_description">Mejor calidad en tamaños de archivos más pequeños, pero no todos los dispositivos admiten la reproducción H.265.</string>
<string name="edit_draft">Editar borrador</string>
<string name="login_with_qr_code">Iniciar sesión con código QR</string>
<string name="route">Ruta</string>
@@ -1001,6 +1026,8 @@
<string name="torrent_download">Descargar</string>
<string name="torrent_failure">Error al abrir el archivo</string>
<string name="torrent_no_apps">No hay aplicaciones torrent instaladas para abrir y descargar el archivo.</string>
<string name="torrent_no_info">El evento no tiene suficiente información para crear un enlace magnético</string>
<string name="my_lists_and_sets">Mis listas/conjuntos</string>
<string name="select_list_to_filter">Selecciona una lista para filtrar el feed</string>
<string name="temporary_account">Cerrar sesión al bloquear dispositivo</string>
<string name="private_message">Mensaje privado</string>
@@ -1011,4 +1038,5 @@
<string name="search_by_hashtag">Buscar hashtag: #%1$s</string>
<string name="dont_translate_from">No traducir del</string>
<string name="dont_translate_from_description">Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo.</string>
<string name="select_signer">Seleccionar firmante</string>
</resources>
@@ -111,6 +111,7 @@
<string name="post">Publicar</string>
<string name="save">Guardar</string>
<string name="create">Crear</string>
<string name="rename">Cambiar nombre</string>
<string name="cancel">Cancelar</string>
<string name="failed_to_upload_the_image">Error al subir la imagen</string>
<string name="relay_address">Dirección del relé</string>
@@ -440,6 +441,28 @@
<string name="follow_list_aroundme">A mi alrededor</string>
<string name="follow_list_global">Global</string>
<string name="follow_list_mute_list">Lista de silenciados</string>
<string name="follow_sets">Conjuntos de seguimiento</string>
<string name="labeled_bookmarks">Marcadores etiquetados</string>
<string name="general_bookmarks">Marcadores generales</string>
<string name="follow_set_type_public">Público</string>
<string name="follow_set_type_private">Privado</string>
<string name="follow_set_type_mixed">Mixto</string>
<string name="follow_set_empty_feed_msg"> 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.
</string>
<string name="follow_set_add_author_from_note_action">Agregar autor a conjunto de seguimiento</string>
<string name="follow_set_profile_actions_menu_description">Agregar o quitar usuario de las listas, o crear una nueva lista con este usuario.</string>
<string name="follow_set_absence_indicator">%1$s no se encuentra en esta lista</string>
<string name="follow_set_man_dialog_title">Tus conjuntos de seguimiento</string>
<string name="follow_set_empty_dialog_msg">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.</string>
<string name="follow_set_error_dialog_msg">Hubo un problema al recuperar: %1$s</string>
<string name="follow_set_creation_menu_title">Crear nueva lista</string>
<string name="follow_set_creation_name_label">Nombre del conjunto</string>
<string name="follow_set_creation_desc_label">Descripción del conjunto (opcional)</string>
<string name="follow_set_creation_action_btn_label">Crear conjunto</string>
<string name="follow_set_rename_btn_label">Cambiar nombre del conjunto</string>
<string name="follow_set_rename_dialog_indicator_first_part">Estás cambiando el nombre de </string>
<string name="follow_set_rename_dialog_indicator_second_part"> a..</string>
<string name="connect_through_your_orbot_setup_short">El puerto predeterminado es 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 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 @@
<string name="media_compression_quality_medium">Media</string>
<string name="media_compression_quality_high">Alta</string>
<string name="media_compression_quality_uncompressed">Sin comprimir</string>
<string name="video_codec_h265_label">Usa H.265/HEVC Codec</string>
<string name="video_codec_h265_description">Mejor calidad en tamaños de archivos más pequeños, pero no todos los dispositivos admiten la reproducción H.265.</string>
<string name="edit_draft">Editar borrador</string>
<string name="login_with_qr_code">Iniciar sesión con código QR</string>
<string name="route">Ruta</string>
@@ -1001,6 +1026,8 @@
<string name="torrent_download">Descargar</string>
<string name="torrent_failure">Error al abrir el archivo</string>
<string name="torrent_no_apps">No hay aplicaciones torrent instaladas para abrir y descargar el archivo.</string>
<string name="torrent_no_info">El evento no tiene suficiente información para crear un enlace magnético</string>
<string name="my_lists_and_sets">Mis listas/conjuntos</string>
<string name="select_list_to_filter">Selecciona una lista para filtrar el feed</string>
<string name="temporary_account">Cerrar sesión al bloquear dispositivo</string>
<string name="private_message">Mensaje privado</string>
@@ -1011,4 +1038,5 @@
<string name="search_by_hashtag">Buscar hashtag: #%1$s</string>
<string name="dont_translate_from">No traducir del</string>
<string name="dont_translate_from_description">Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo.</string>
<string name="select_signer">Seleccionar firmante</string>
</resources>
@@ -457,9 +457,8 @@
</string>
<string name="follow_set_add_author_from_note_action">लेखक जोडें अनुगम्य सूची में</string>
<string name="follow_set_profile_actions_menu_description">प्रयोक्ता को सूचियों में जोडें अथवा हटाएँ अथवा इस प्रयोक्ता के साथ नई सूची बनाएँ।</string>
<string name="follow_set_type_description">सूची %1$s के लिए चिह्न</string>
<string name="follow_set_presence_indicator">"%1$s इस सूची में है"</string>
<string name="follow_set_absence_indicator">"%1$s इस सूची में नहीं है"</string>
<string name="follow_set_icon_description">सूची %1$s के लिए चिह्न</string>
<string name="follow_set_absence_indicator">%1$s इस सूची में नहीं है</string>
<string name="follow_set_man_dialog_title">आपके अनुगम्य सूचियाँ</string>
<string name="follow_set_empty_dialog_msg">कोई अनुगम्य सूचियाँ प्राप्त नहीं। अथवा आपका कोई अनुगम्य सूचियाँ हैं नहीं। नवीकरण के लिए नीचे दबाएँ अथवा विकल्पसूची द्वारा एक नया बनाएँ।</string>
<string name="follow_set_error_dialog_msg">लाने में अपक्रम : %1$s</string>
@@ -860,6 +859,8 @@
<string name="media_compression_quality_medium">मध्यम</string>
<string name="media_compression_quality_high">उच्च</string>
<string name="media_compression_quality_uncompressed">असंकुचित</string>
<string name="video_codec_h265_label">उच्च कार्यक्षमता चलचित्र संकुचनविस्तारण अथवा एच॰२६५ क्रमलेख का प्रयोग करें</string>
<string name="video_codec_h265_description">अधिक गुणवत्ता के साथ सूक्ष्मतर अभिलेख आकार परन्तु एच॰२६५ चालन सभी यन्त्रों द्वारा अवलम्बित नहीं।</string>
<string name="edit_draft">पाण्डुलिपि का सम्पादन करें</string>
<string name="login_with_qr_code">क्यूआर॰ क्रमचित्र के साथ प्रवेशांकन करें</string>
<string name="route">मार्ग</string>
@@ -457,9 +457,8 @@
</string>
<string name="follow_set_add_author_from_note_action">Szerző hozzáadása a követési gyűjteményhez</string>
<string name="follow_set_profile_actions_menu_description">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.</string>
<string name="follow_set_type_description">Ikon a(z) %1$s nevű listához</string>
<string name="follow_set_presence_indicator">"A(z) %1$s már a létezik a listában"</string>
<string name="follow_set_absence_indicator">"A(z) %1$s nincs a listában"</string>
<string name="follow_set_icon_description">Ikon a(z) %1$s nevű listához</string>
<string name="follow_set_absence_indicator">A(z) %1$s nincs a listában</string>
<string name="follow_set_man_dialog_title">Saját követési gyüjtemények</string>
<string name="follow_set_empty_dialog_msg">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.</string>
<string name="follow_set_error_dialog_msg">Probléma történt a következő lekérdezésekor: %1$s</string>
@@ -114,7 +114,7 @@
<string name="yes"></string>
<string name="no"></string>
<string name="follow_list_selection">Sekot saraksts</string>
<string name="follow_set_type_description">Ikona %1$s sarakstam</string>
<string name="follow_set_icon_description">Ikona %1$s sarakstam</string>
<string name="follow_set_creation_menu_title">Izveidot jaunu sarakstu</string>
<string name="follow_set_creation_dialog_title">Jaunais %1$s saraksts</string>
<string name="follow_set_creation_name_label">Kolekcijas nosaukums</string>
@@ -454,9 +454,8 @@
</string>
<string name="follow_set_add_author_from_note_action">Dodaj autora do zbioru obserwowanych</string>
<string name="follow_set_profile_actions_menu_description">Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem.</string>
<string name="follow_set_type_description">Ikona dla listy %1$s</string>
<string name="follow_set_presence_indicator">"%1$s jest obecny na liście"</string>
<string name="follow_set_absence_indicator">"%1$s nie jest na liście"</string>
<string name="follow_set_icon_description">Ikona dla listy %1$s</string>
<string name="follow_set_absence_indicator">%1$s nie jest na liście</string>
<string name="follow_set_man_dialog_title">Twój zbiór obserwowanych</string>
<string name="follow_set_empty_dialog_msg">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ć.</string>
<string name="follow_set_error_dialog_msg">Podczas pobierania wystąpił błąd: %1$s</string>
@@ -453,9 +453,8 @@
<string name="follow_set_empty_feed_msg">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.</string>
<string name="follow_set_add_author_from_note_action">Adicionar autor ao conjunto de seguimento</string>
<string name="follow_set_profile_actions_menu_description">Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário.</string>
<string name="follow_set_type_description">Ícone da lista %1$s</string>
<string name="follow_set_presence_indicator">"%1$s está presente nesta lista"</string>
<string name="follow_set_absence_indicator">"%1$s não está nesta lista"</string>
<string name="follow_set_icon_description">Ícone da lista %1$s</string>
<string name="follow_set_absence_indicator">%1$s não está nesta lista</string>
<string name="follow_set_man_dialog_title">Seus conjuntos de seguimento</string>
<string name="follow_set_empty_dialog_msg">Nenhum conjunto de seguimento foi encontrado ou você não possui nenhum. Toque abaixo para atualizar ou use o menu para criar um.</string>
<string name="follow_set_error_dialog_msg">Houve um problema ao buscar: %1$s</string>
@@ -1064,4 +1063,24 @@
<string name="crashreport_found_send">Enviar</string>
<string name="this_message_will_disappear_in_days">Esta mensagem desaparecerá em %1$d dias</string>
<string name="select_signer">Selecionar assinador</string>
<string name="follow_set_create_btn_label">Novo</string>
<string name="follow_set_public_presence_indicator">%1$s é um membro público</string>
<string name="follow_set_private_presence_indicator">%1$s é um membro privado</string>
<string name="follow_set_public_member_add_label">Adicionar como membro público</string>
<string name="follow_set_private_member_add_label">Adicionar como membro privado</string>
<string name="follow_set_public_members_header_label">Perfis públicos</string>
<string name="follow_set_private_members_header_label">Perfis privados</string>
<string name="follow_set_single_member_label">membro</string>
<string name="follow_set_multiple_member_label">membros</string>
<string name="follow_set_empty_label">Nenhum membro</string>
<string name="follow_set_copy_dialog_title">Copiar/Clonar conjunto de seguidores</string>
<string name="follow_set_desc_modify_label">Modificar descrição</string>
<string name="follow_set_empty_desc_label">Esta lista não tem descrição</string>
<string name="follow_set_current_desc_label">Descrição atual:</string>
<string name="follow_set_copy_indicator_description">Você pode definir um nome/descrição personalizada para este conjunto clonado abaixo.</string>
<string name="follow_set_copy_name_label">(Nome original do conjunto)</string>
<string name="follow_set_copy_desc_label">(Descrição original do conjunto)</string>
<string name="follow_set_copy_action_btn_label">Copiar/Clonar conjunto</string>
<string name="follow_set_desc_modify_btn_label">Modificar</string>
</resources>
@@ -123,6 +123,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="post">Pošlji</string>
<string name="save">Shrani</string>
<string name="create">Ustvari</string>
<string name="rename">Preimenuj</string>
<string name="cancel">Prekliči</string>
<string name="failed_to_upload_the_image">Neuspelo nalaganje slike</string>
<string name="relay_address">Naslov releja</string>
@@ -456,8 +457,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="follow_list_aroundme">V moji okolici</string>
<string name="follow_list_global">Globalno</string>
<string name="follow_list_mute_list">Spisek utišanih</string>
<string name="labeled_bookmarks">Označeni zaznamki</string>
<string name="general_bookmarks">Splošni zaznamki</string>
<string name="follow_set_type_public">Javno</string>
<string name="follow_set_type_private">Zasebno</string>
<string name="follow_set_type_mixed">Mešano</string>
<string name="follow_set_profile_actions_menu_description">Dodaj ali odstrani uprabnika iz seznama, ali pa ustvari nov seznam s tem uporabnikom.</string>
<string name="follow_set_creation_menu_title">Ustvari nov seznam</string>
<string name="connect_through_your_orbot_setup_short">Prevzeta vrata so 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 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</string>
<string name="media_compression_quality_medium">Srednja</string>
<string name="media_compression_quality_high">Visoka</string>
<string name="media_compression_quality_uncompressed">Nestisnjeno</string>
<string name="video_codec_h265_label">Uporabi H.265/HEVC Codec</string>
<string name="edit_draft">Uredi osnutek</string>
<string name="login_with_qr_code">Vpiši se z QR kodo</string>
<string name="route">Pot</string>
@@ -1049,4 +1056,5 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Želite poslati poročilo o napaki Amethyst-u preko zasebnega sporočila? Vaši osebni podatki NE bojo posredovani.</string>
<string name="crashreport_found_send">Pošlji</string>
<string name="this_message_will_disappear_in_days">To sporočilo bo izginilo čez %1$d dni</string>
<string name="select_signer">Izberi podpisnika</string>
</resources>
@@ -453,9 +453,8 @@
<string name="follow_set_empty_feed_msg">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.</string>
<string name="follow_set_add_author_from_note_action">Lägg till författare i följ-set</string>
<string name="follow_set_profile_actions_menu_description">Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare.</string>
<string name="follow_set_type_description">Ikon för %1$s-lista</string>
<string name="follow_set_presence_indicator">"%1$s finns i denna lista"</string>
<string name="follow_set_absence_indicator">"%1$s finns inte i denna lista"</string>
<string name="follow_set_icon_description">Ikon för %1$s-lista</string>
<string name="follow_set_absence_indicator">%1$s finns inte i denna lista</string>
<string name="follow_set_man_dialog_title">Dina följ-set</string>
<string name="follow_set_empty_dialog_msg">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.</string>
<string name="follow_set_error_dialog_msg">Ett problem uppstod vid hämtning: %1$s</string>
@@ -1063,4 +1062,24 @@
<string name="crashreport_found_send">Skicka</string>
<string name="this_message_will_disappear_in_days">Detta meddelande försvinner om %1$d dagar</string>
<string name="select_signer">Välj signatör</string>
<string name="follow_set_create_btn_label">Ny</string>
<string name="follow_set_public_presence_indicator">%1$s är en offentlig medlem</string>
<string name="follow_set_private_presence_indicator">%1$s är en privat medlem</string>
<string name="follow_set_public_member_add_label">Lägg till som offentlig medlem</string>
<string name="follow_set_private_member_add_label">Lägg till som privat medlem</string>
<string name="follow_set_public_members_header_label">Offentliga profiler</string>
<string name="follow_set_private_members_header_label">Privata profiler</string>
<string name="follow_set_single_member_label">medlem</string>
<string name="follow_set_multiple_member_label">medlemmar</string>
<string name="follow_set_empty_label">Inga medlemmar</string>
<string name="follow_set_copy_dialog_title">Kopiera/Klona följlista</string>
<string name="follow_set_desc_modify_label">Ändra beskrivning</string>
<string name="follow_set_empty_desc_label">Den här listan har ingen beskrivning</string>
<string name="follow_set_current_desc_label">Nuvarande beskrivning:</string>
<string name="follow_set_copy_indicator_description">Du kan ange ett anpassat namn/beskrivning för denna klonlista nedan.</string>
<string name="follow_set_copy_name_label">(Ursprungligt listnamn)</string>
<string name="follow_set_copy_desc_label">(Ursprunglig beskrivning)</string>
<string name="follow_set_copy_action_btn_label">Kopiera/Klona lista</string>
<string name="follow_set_desc_modify_btn_label">Ändra</string>
</resources>
@@ -457,9 +457,8 @@
</string>
<string name="follow_set_add_author_from_note_action">添加作者到关注集</string>
<string name="follow_set_profile_actions_menu_description">从列表中添加或删除用户,或用此用户创建一个新列表。</string>
<string name="follow_set_type_description">%1$s 列表的图标</string>
<string name="follow_set_presence_indicator">"此列表中有 %1$s"</string>
<string name="follow_set_absence_indicator">"此列表中没有 %1$s"</string>
<string name="follow_set_icon_description">%1$s 列表的图标</string>
<string name="follow_set_absence_indicator">此列表中有 %1$s</string>
<string name="follow_set_man_dialog_title">您的关注集</string>
<string name="follow_set_empty_dialog_msg">未找到关注集,或者你还没有任何关注集。轻按下方刷新,或使用按钮新建。</string>
<string name="follow_set_error_dialog_msg">获取时出了问题: %1$s</string>
+24 -6
View File
@@ -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.
</string>
<string name="follow_set_create_btn_label">New</string>
<string name="follow_set_add_author_from_note_action">Add author to follow set</string>
<string name="follow_set_profile_actions_menu_description">Add or remove user from lists, or create a new list with this user.</string>
<string name="follow_set_type_description">Icon for %1$s List</string>
<string name="follow_set_presence_indicator">"%1$s is present in this list"</string>
<string name="follow_set_absence_indicator">"%1$s is not in this list"</string>
<string name="follow_set_icon_description">Icon for follow set</string>
<string name="follow_set_public_presence_indicator">%1$s is a public member</string>
<string name="follow_set_private_presence_indicator">%1$s is a private member</string>
<string name="follow_set_public_member_add_label">Add as public member</string>
<string name="follow_set_private_member_add_label">Add as private member</string>
<string name="follow_set_public_members_header_label">Public Profiles</string>
<string name="follow_set_private_members_header_label">Private Profiles</string>
<string name="follow_set_single_member_label">member</string>
<string name="follow_set_multiple_member_label">members</string>
<string name="follow_set_empty_label">No members</string>
<string name="follow_set_absence_indicator">%1$s is not in this list</string>
<string name="follow_set_man_dialog_title">Your Follow Sets</string>
<string name="follow_set_empty_dialog_msg">No follow sets were found, or you don\'t have any follow sets. Tap below to refresh, or use the menu to create one.</string>
<string name="follow_set_error_dialog_msg">There was a problem while fetching: %1$s</string>
<string name="follow_set_creation_menu_title">Make New List</string>
<string name="follow_set_creation_item_label">"Create new %1$s list with user</string>
<string name="follow_set_creation_item_description">Creates a %1$s follow set, and adds %2$s to it.</string>
<string name="follow_set_creation_dialog_title">New %1$s List</string>
<string name="follow_set_creation_item_label">New list with %1$s membership</string>
<string name="follow_set_creation_item_description">Creates a new follow set, and adds %1$s as a %2$s member.</string>
<string name="follow_set_creation_dialog_title">New Follow Set</string>
<string name="follow_set_copy_dialog_title">Copy/Clone Follow Set</string>
<string name="follow_set_desc_modify_label">Modify description</string>
<string name="follow_set_empty_desc_label">This list doesn\'t have a description</string>
<string name="follow_set_current_desc_label">Current description:</string>
<string name="follow_set_copy_indicator_description">You can set a custom name/description for this clone set below.</string>
<string name="follow_set_creation_name_label">Set name</string>
<string name="follow_set_copy_name_label">(Original Set name)</string>
<string name="follow_set_creation_desc_label">Set description(optional)</string>
<string name="follow_set_copy_desc_label">(Original Set description)</string>
<string name="follow_set_creation_action_btn_label">Create set</string>
<string name="follow_set_copy_action_btn_label">Copy/Clone set</string>
<string name="follow_set_rename_btn_label">Rename set</string>
<string name="follow_set_desc_modify_btn_label">Modify</string>
<string name="follow_set_rename_dialog_indicator_first_part">You are renaming from </string>
<string name="follow_set_rename_dialog_indicator_second_part"> to..</string>
+4 -2
View File
@@ -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" }
@@ -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,
)
@@ -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<String> = emptyList(),
firstPrivateMembers: List<String> = 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<String> = emptyList(),
firstPrivateMembers: List<String> = 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)
}
}
}
}
}
@@ -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",
)
}
}