From e2df96ea4bb82d57a326eaa09e2483539ed12bd7 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Wed, 5 Nov 2025 17:51:12 +0100 Subject: [PATCH 01/23] Introduce LabeledBookmarkListEvent. --- .../LabeledBookmarkListEvent.kt | 224 ++++++++++++++++++ .../labeledBookmarkList/TagArrayBuilderExt.kt | 29 +++ 2 files changed, 253 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt new file mode 100644 index 000000000..1df4d03fd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt @@ -0,0 +1,224 @@ +/** + * 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.labeledBookmarkList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Immutable +class LabeledBookmarkListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider { + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) + + override fun addressHints() = tags.mapNotNull(AddressBookmark::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(AddressBookmark::parseAddressId) + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse) + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun nameOrTitle() = name() ?: title() + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun countBookmarks() = tags.count(BookmarkIdTag::isTagged) + + fun publicBookmarks(): List = tags.mapNotNull(BookmarkIdTag::parse) + + suspend fun privateBookmarks(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(BookmarkIdTag::parse) + + companion object { + const val KIND = 30003 + + const val ALT = "A labeled list of bookmarks" + + @OptIn(ExperimentalUuidApi::class) + fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, Uuid.random().toString()) + + suspend fun create( + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): LabeledBookmarkListEvent = + if (isPrivate) { + create( + publicBookmarks = emptyList(), + privateBookmarks = listOf(bookmarkIdTag), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicBookmarks = listOf(bookmarkIdTag), + privateBookmarks = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: LabeledBookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): LabeledBookmarkListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.plus(bookmarkIdTag.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(bookmarkIdTag.toTagArray()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: LabeledBookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): LabeledBookmarkListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()), + tags = earlierVersion.tags, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): LabeledBookmarkListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + @OptIn(ExperimentalUuidApi::class) + suspend fun create( + name: String = "", + publicBookmarks: List = emptyList(), + privateBookmarks: List = emptyList(), + dTag: String = Uuid.random().toString(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): LabeledBookmarkListEvent { + val template = build(name, publicBookmarks, privateBookmarks, signer, dTag, createdAt) + return signer.sign(template) + } + + @OptIn(ExperimentalUuidApi::class) + suspend fun build( + name: String = "", + publicBookmarks: List = emptyList(), + privateBookmarks: List = emptyList(), + signer: NostrSigner, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateBookmarks.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + dTag(dTag) + alt(ALT) + name(name) + bookmarks(publicBookmarks) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt new file mode 100644 index 000000000..f0f91e669 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt @@ -0,0 +1,29 @@ +/** + * 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.labeledBookmarkList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag + +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.bookmarks(bookmarks: List) = addAll(bookmarks.map { it.toTagArray() }) From d5a29100293a6fea75a6c548f52dfb7982624cb9 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Sun, 9 Nov 2025 22:11:12 +0100 Subject: [PATCH 02/23] Add support for descriptions in LabeledBookmarkListEvent. Make function names clearer, and add modification functions. --- .../LabeledBookmarkListEvent.kt | 81 ++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt index 1df4d03fd..7fe79140d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.replaceAll import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag @@ -89,13 +90,17 @@ class LabeledBookmarkListEvent( fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, Uuid.random().toString()) suspend fun create( + name: String = "", bookmarkIdTag: BookmarkIdTag, isPrivate: Boolean, + optionalListDescription: String? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): LabeledBookmarkListEvent = if (isPrivate) { create( + name = name, + description = optionalListDescription, publicBookmarks = emptyList(), privateBookmarks = listOf(bookmarkIdTag), signer = signer, @@ -103,6 +108,8 @@ class LabeledBookmarkListEvent( ) } else { create( + name = name, + description = optionalListDescription, publicBookmarks = listOf(bookmarkIdTag), privateBookmarks = emptyList(), signer = signer, @@ -110,7 +117,7 @@ class LabeledBookmarkListEvent( ) } - suspend fun add( + suspend fun addBookmark( earlierVersion: LabeledBookmarkListEvent, bookmarkIdTag: BookmarkIdTag, isPrivate: Boolean, @@ -134,7 +141,7 @@ class LabeledBookmarkListEvent( ) } - suspend fun remove( + suspend fun removeBookmark( earlierVersion: LabeledBookmarkListEvent, bookmarkIdTag: BookmarkIdTag, isPrivate: Boolean, @@ -158,6 +165,70 @@ class LabeledBookmarkListEvent( ) } + suspend fun modifyName( + earlierVersion: LabeledBookmarkListEvent, + newName: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): LabeledBookmarkListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val currentTitle = earlierVersion.tags.first { it[0] == NameTag.TAG_NAME || it[0] == TitleTag.TAG_NAME } + val newTitleTag = + if (currentTitle[0] == NameTag.TAG_NAME) { + NameTag.assemble(newName) + } else { + TitleTag.assemble(newName) + } + + return resign( + tags = earlierVersion.tags.replaceAll(currentTitle, newTitleTag), + privateTags = privateTags.replaceAll(currentTitle, newTitleTag), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun modifyDescription( + earlierVersion: LabeledBookmarkListEvent, + newDescription: String?, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): LabeledBookmarkListEvent? { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val currentDescriptionTag = earlierVersion.tags.firstOrNull { it[0] == DescriptionTag.TAG_NAME } + val currentDescription = currentDescriptionTag?.get(1) + if (currentDescription.equals(newDescription)) { + // Do nothing + return null + } else { + if (newDescription == null || newDescription.isEmpty()) { + return resign( + tags = earlierVersion.tags.remove { it[0] == DescriptionTag.TAG_NAME }, + privateTags = privateTags.remove { it[0] == DescriptionTag.TAG_NAME }, + signer = signer, + createdAt = createdAt, + ) + } else { + val newDescriptionTag = DescriptionTag.assemble(newDescription) + return if (currentDescriptionTag == null) { + resign( + tags = earlierVersion.tags.plusElement(newDescriptionTag), + privateTags = privateTags, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + tags = earlierVersion.tags.replaceAll(currentDescriptionTag, newDescriptionTag), + privateTags = privateTags.replaceAll(currentDescriptionTag, newDescriptionTag), + signer = signer, + createdAt = createdAt, + ) + } + } + } + } + suspend fun resign( tags: TagArray, privateTags: TagArray, @@ -189,13 +260,17 @@ class LabeledBookmarkListEvent( @OptIn(ExperimentalUuidApi::class) suspend fun create( name: String = "", + description: String? = null, publicBookmarks: List = emptyList(), privateBookmarks: List = emptyList(), dTag: String = Uuid.random().toString(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): LabeledBookmarkListEvent { - val template = build(name, publicBookmarks, privateBookmarks, signer, dTag, createdAt) + val template = + build(name, publicBookmarks, privateBookmarks, signer, dTag, createdAt) { + if (description != null) addUnique(DescriptionTag.assemble(description)) + } return signer.sign(template) } From 751cbdcaa94d1ac318f331c9cb76dba040ea4341 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Sun, 9 Nov 2025 22:12:54 +0100 Subject: [PATCH 03/23] Introduce LabeledBookmarkList and a full implementation for LabeledBookmarkListsState. --- .../LabeledBookmarkList.kt | 33 +++ .../LabeledBookmarkListsState.kt | 239 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt new file mode 100644 index 000000000..b60e77fef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt @@ -0,0 +1,33 @@ +/** + * 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.model.nip51Lists.labeledBookmarkLists + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag + +@Stable +data class LabeledBookmarkList( + val identifier: String, + val title: String, + val description: String?, + val privateBookmarks: Set = emptySet(), + val publicBookmarks: Set = emptySet(), +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt new file mode 100644 index 000000000..48d7099fb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -0,0 +1,239 @@ +/** + * 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.model.nip51Lists.labeledBookmarkLists + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.anyNotNullEvent +import com.vitorpamplona.amethyst.model.eventIdSet +import com.vitorpamplona.amethyst.model.events +import com.vitorpamplona.amethyst.model.filter +import com.vitorpamplona.amethyst.model.updateFlow +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update + +class LabeledBookmarkListsState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, +) { + val user = cache.getOrCreateUser(signer.pubKey) + + fun existingLabeledBookmarkNotes() = cache.addressables.filter(LabeledBookmarkListEvent.KIND, user.pubkeyHex) + + val labeledBookmarkListVersions = MutableStateFlow(0) + + val labeledBookmarkListNotes = + labeledBookmarkListVersions + .map { existingLabeledBookmarkNotes() } + .onStart { emit(existingLabeledBookmarkNotes()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + val labeledBookmarkListEventIds = + labeledBookmarkListNotes + .map { it.eventIdSet() } + .onStart { emit(labeledBookmarkListNotes.value.eventIdSet()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + @OptIn(ExperimentalCoroutinesApi::class) + val latestBookmarkLists: StateFlow> = + labeledBookmarkListNotes + .transformLatest { emitAll(it.updateFlow()) } + .onStart { emit(labeledBookmarkListNotes.value.events()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + suspend fun LabeledBookmarkListEvent.toLabeledBookmarkList() = + LabeledBookmarkList( + identifier = dTag(), + title = nameOrTitle() ?: dTag(), + description = description(), + privateBookmarks = privateBookmarks(signer)?.toSet() ?: emptySet(), + publicBookmarks = publicBookmarks().toSet(), + ) + + suspend fun List.toLabeledBookmarkListsFeed() = map { it.toLabeledBookmarkList() }.sortedBy { it.title } + + val listFeedFlow = + latestBookmarkLists + .map { it.toLabeledBookmarkListsFeed() } + .onStart { emit(latestBookmarkLists.value.toLabeledBookmarkListsFeed()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + fun DeletionEvent.hasAnyDeletedBookmarkLists() = deleteAddressesWithKind(LabeledBookmarkListEvent.KIND) || deletesAnyEventIn(labeledBookmarkListEventIds.value) + + fun hasItemInNoteList(notes: Set): Boolean = + notes.anyNotNullEvent { event -> + if (event.pubKey == signer.pubKey) { + event is LabeledBookmarkListEvent || (event is DeletionEvent && event.hasAnyDeletedBookmarkLists()) + } else { + false + } + } + + fun newNotes(newNotes: Set) { + if (hasItemInNoteList(newNotes)) { + forceRefresh() + } + } + + fun deletedNotes(deletedNotes: Set) { + if (hasItemInNoteList(deletedNotes)) { + forceRefresh() + } + } + + fun forceRefresh() { + labeledBookmarkListVersions.update { it + 1 } + } + + fun getLabeledBookmarkListNote(bookmarkIdentifier: String): AddressableNote? = existingLabeledBookmarkNotes().find { it.dTag() == bookmarkIdentifier } + + fun getLabeledBookmarkList(bookmarkIdentifier: String): LabeledBookmarkListEvent = getLabeledBookmarkListNote(bookmarkIdentifier)?.event as LabeledBookmarkListEvent + + suspend fun addLabeledBookmarkList( + listName: String, + listDescription: String? = null, + firstBookmark: BookmarkIdTag? = null, + isBookmarkPrivate: Boolean = false, + account: Account, + ) { + val newList = + LabeledBookmarkListEvent.create( + name = listName, + description = listDescription, + publicBookmarks = if (!isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(), + privateBookmarks = if (isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(), + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun renameBookmarkList( + newName: String, + bookmarkList: LabeledBookmarkList, + account: Account, + ) { + val listEvent = getLabeledBookmarkList(bookmarkList.identifier) + val renamedList = + LabeledBookmarkListEvent.modifyName( + earlierVersion = listEvent, + newName = newName, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(renamedList) + } + + suspend fun modifyListDescription( + newDescription: String?, + bookmarkList: LabeledBookmarkList, + account: Account, + ) { + val listEvent = getLabeledBookmarkList(bookmarkList.identifier) + val modifiedList = + LabeledBookmarkListEvent.modifyDescription( + earlierVersion = listEvent, + newDescription = newDescription, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(modifiedList) + } + + suspend fun cloneBookmarkList( + currentBookmarkList: LabeledBookmarkList, + customCloneName: String?, + customCloneDescription: String?, + account: Account, + ) { + val clonedList = + LabeledBookmarkListEvent.create( + name = customCloneName ?: currentBookmarkList.title, + description = customCloneDescription ?: currentBookmarkList.description, + publicBookmarks = currentBookmarkList.publicBookmarks.toList(), + privateBookmarks = currentBookmarkList.privateBookmarks.toList(), + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(clonedList) + } + + suspend fun deleteBookmarkList( + bookmarkList: LabeledBookmarkList, + account: Account, + ) { + val listEvent = getLabeledBookmarkList(bookmarkList.identifier) + val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(listEvent))) + account.sendMyPublicAndPrivateOutbox(deletionEvent) + } + + suspend fun addBookmarkToList( + bookmark: BookmarkIdTag, + bookmarkList: LabeledBookmarkList, + isBookmarkPrivate: Boolean, + account: Account, + ) { + val currentBookmarkList = getLabeledBookmarkList(bookmarkList.identifier) + val updatedList = + LabeledBookmarkListEvent.addBookmark( + earlierVersion = currentBookmarkList, + bookmarkIdTag = bookmark, + isPrivate = isBookmarkPrivate, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(updatedList) + } + + suspend fun removeBookmarkFromList( + bookmark: BookmarkIdTag, + bookmarkList: LabeledBookmarkList, + isBookmarkPrivate: Boolean, + account: Account, + ) { + val currentBookmarkList = getLabeledBookmarkList(bookmarkList.identifier) + val updatedList = + LabeledBookmarkListEvent.removeBookmark( + earlierVersion = currentBookmarkList, + bookmarkIdTag = bookmark, + isPrivate = isBookmarkPrivate, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(updatedList) + } +} From 0c06bd4271b08a8c5ed607c5e2885ea682b7c752 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Mon, 10 Nov 2025 19:34:55 +0100 Subject: [PATCH 04/23] Build out UI for the list of labeled bookmarks, or bookmark groups, borrowing some components from elsewhere. --- .../vitorpamplona/amethyst/model/Account.kt | 2 + .../bookmarkgroups/list/BookmarkGroupItem.kt | 520 ++++++++++++++++++ .../list/ListOfBookmarkGroupsFeedView.kt | 94 ++++ .../list/ListOfBookmarkGroupsScreen.kt | 175 ++++++ .../list/NewBookmarkGroupCreationDialog.kt | 106 ++++ 5 files changed, 897 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 25b8f346c..7747c59eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecry import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkListsState import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState @@ -291,6 +292,7 @@ class Account( val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings) + val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope) val bookmarkState = BookmarkListState(signer, cache, scope) val emoji = EmojiPackState(signer, cache, scope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt new file mode 100644 index 000000000..637b5a35e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt @@ -0,0 +1,520 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material.icons.outlined.CollectionsBookmark +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +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.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.Font10SP +import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders +import com.vitorpamplona.amethyst.ui.theme.Size10Modifier +import com.vitorpamplona.amethyst.ui.theme.Size50ModifierOffset10 +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp + +@Composable +fun BookmarkGroupItem( + modifier: Modifier = Modifier, + bookmarkList: LabeledBookmarkList, + onClick: () -> Unit, + onRename: (String) -> Unit, + onDescriptionChange: (String?) -> Unit, + onClone: (customName: String?, customDescription: String?) -> Unit, + onDelete: () -> Unit, +) { + ListItem( + modifier = modifier, + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(bookmarkList.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + + Column( + modifier = NoSoTinyBorders, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.End, + ) { + BookmarkGroupOptionsButton( + bookmarkGroupName = bookmarkList.title, + bookmarkGroupDescription = bookmarkList.description, + onGroupRename = onRename, + onGroupDescriptionChange = onDescriptionChange, + onGroupCloneCreate = onClone, + onGroupDelete = onDelete, + ) + } + } + }, + supportingContent = { + Text( + bookmarkList.description ?: "", + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + }, + leadingContent = { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.CollectionsBookmark, + contentDescription = "Icon for bookmark group", + modifier = Size50ModifierOffset10, + ) + BookmarkMembershipStatusAndNumberDisplay( + modifier = Modifier.align(Alignment.BottomCenter), + privateBookmarksSize = bookmarkList.privateBookmarks.size, + publicBookmarksSize = bookmarkList.publicBookmarks.size, + ) + } + }, + trailingContent = { + IconButton( + onClick = onClick, + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null) + Text(text = "View") + } + } + }, + ) +} + +@Composable +fun BookmarkMembershipStatusAndNumberDisplay( + modifier: Modifier, + privateBookmarksSize: Int, + publicBookmarksSize: Int, +) { + Row( + modifier = modifier.offset(y = (-5).dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, + ) { + if (privateBookmarksSize <= 0 && publicBookmarksSize <= 0) { + Text( + text = stringRes(R.string.follow_set_empty_label2), + fontSize = Font10SP, + ) + } else { + if (privateBookmarksSize > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy2dp, + ) { + Icon( + imageVector = Icons.Outlined.Lock, + modifier = Size10Modifier, + contentDescription = null, + ) + Text( + text = privateBookmarksSize.toString(), + fontSize = Font10SP, + ) + } + } + if (publicBookmarksSize > 0) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy2dp, + ) { + Icon( + imageVector = Icons.Outlined.Public, + modifier = Size10Modifier, + contentDescription = null, + ) + Text( + text = publicBookmarksSize.toString(), + fontSize = Font10SP, + ) + } + } + } + } +} + +@Composable +private fun BookmarkGroupOptionsButton( + modifier: Modifier = Modifier, + bookmarkGroupName: String, + bookmarkGroupDescription: String?, + onGroupRename: (String) -> Unit, + onGroupDescriptionChange: (String?) -> Unit, + onGroupCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit, + onGroupDelete: () -> Unit, +) { + val isMenuOpen = remember { mutableStateOf(false) } + + ClickableBox( + onClick = { isMenuOpen.value = true }, + ) { + VerticalDotsIcon() + + GroupOptionsMenu( + groupName = bookmarkGroupName, + groupDescription = bookmarkGroupDescription, + isExpanded = isMenuOpen.value, + onDismiss = { isMenuOpen.value = false }, + onGroupRename = onGroupRename, + onGroupDescriptionChange = onGroupDescriptionChange, + onGroupClone = onGroupCloneCreate, + onDelete = onGroupDelete, + ) + } +} + +@Composable +private fun GroupOptionsMenu( + modifier: Modifier = Modifier, + isExpanded: Boolean, + groupName: String, + groupDescription: String?, + onGroupRename: (String) -> Unit, + onGroupDescriptionChange: (String?) -> Unit, + onGroupClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit, + onDelete: () -> Unit, + onDismiss: () -> Unit, +) { + val isRenameDialogOpen = remember { mutableStateOf(false) } + val renameString = remember { mutableStateOf("") } + + val isDescriptionModDialogOpen = remember { mutableStateOf(false) } + + val isCopyDialogOpen = remember { mutableStateOf(false) } + val optionalCloneName = remember { mutableStateOf(null) } + val optionalCloneDescription = remember { mutableStateOf(null) } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = onDismiss, + ) { + DropdownMenuItem( + text = { + Text(text = stringRes(R.string.follow_set_rename_btn_label)) + }, + onClick = { + isRenameDialogOpen.value = true + 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) { + GroupRenameDialog( + currentName = groupName, + newName = renameString.value, + onStringRenameChange = { + renameString.value = it + }, + onDismissDialog = { isRenameDialogOpen.value = false }, + onGroupRename = { + onGroupRename(renameString.value) + }, + ) + } + + if (isDescriptionModDialogOpen.value) { + GroupModifyDescriptionDialog( + currentDescription = groupDescription, + onDismissDialog = { isDescriptionModDialogOpen.value = false }, + onModifyDescription = onGroupDescriptionChange, + ) + } + + if (isCopyDialogOpen.value) { + GroupCloneDialog( + optionalNewName = optionalCloneName.value, + optionalNewDesc = optionalCloneDescription.value, + onCloneNameChange = { + optionalCloneName.value = it + }, + onCloneDescChange = { + optionalCloneDescription.value = it + }, + onCloneCreate = { name, description -> + onGroupClone(optionalCloneName.value, optionalCloneDescription.value) + }, + onDismiss = { isCopyDialogOpen.value = false }, + ) + } +} + +@Composable +private fun GroupRenameDialog( + modifier: Modifier = Modifier, + currentName: String, + newName: String, + onStringRenameChange: (String) -> Unit, + onDismissDialog: () -> Unit, + onGroupRename: (String) -> Unit, +) { + val renameIndicator = + buildAnnotatedString { + append(stringRes(R.string.follow_set_rename_dialog_indicator_first_part) + " ") + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Normal, + fontSize = 15.sp, + ), + ) { + append("\"" + currentName + "\"") + } + append(" " + stringRes(R.string.follow_set_rename_dialog_indicator_second_part)) + } + + AlertDialog( + onDismissRequest = onDismissDialog, + title = { + Text(text = stringRes(R.string.follow_set_rename_btn_label)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(Size5dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = renameIndicator, + fontSize = 15.sp, + fontWeight = FontWeight.Light, + fontStyle = FontStyle.Italic, + ) + TextField( + value = newName, + onValueChange = onStringRenameChange, + ) + } + }, + confirmButton = { + Button( + onClick = { + onGroupRename(newName) + onDismissDialog() + }, + ) { Text(text = stringRes(R.string.rename)) } + }, + dismissButton = { + Button(onClick = onDismissDialog) { Text(text = stringRes(R.string.cancel)) } + }, + ) +} + +@Composable +private fun GroupModifyDescriptionDialog( + modifier: Modifier = Modifier, + currentDescription: String?, + onDismissDialog: () -> Unit, + onModifyDescription: (String?) -> Unit, +) { + val updatedDescription = remember { mutableStateOf(null) } + + val modifyIndicatorLabel = + if (currentDescription == null) { + stringRes(R.string.follow_set_empty_desc_label) + } else { + buildAnnotatedString { + append(stringRes(R.string.follow_set_current_desc_label) + " ") + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Normal, + fontSize = 15.sp, + ), + ) { + append("\"" + currentDescription + "\"") + } + }.text + } + + AlertDialog( + onDismissRequest = onDismissDialog, + title = { + Text(text = stringRes(R.string.follow_set_desc_modify_label)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(Size5dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = modifyIndicatorLabel, + fontSize = 15.sp, + fontWeight = FontWeight.Light, + fontStyle = FontStyle.Italic, + ) + TextField( + value = updatedDescription.value ?: "", + onValueChange = { updatedDescription.value = it }, + ) + } + }, + confirmButton = { + Button( + onClick = { + onModifyDescription(updatedDescription.value) + onDismissDialog() + }, + ) { Text(text = stringRes(R.string.follow_set_desc_modify_btn_label)) } + }, + dismissButton = { + Button(onClick = onDismissDialog) { Text(text = stringRes(R.string.cancel)) } + }, + ) +} + +@Composable +private fun GroupCloneDialog( + 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 = "Clone Bookmark Group", + ) + } + }, + 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 group clone name + TextField( + value = optionalNewName ?: "", + onValueChange = onCloneNameChange, + label = { + Text(text = stringRes(R.string.follow_set_copy_name_label)) + }, + ) + Spacer(modifier = DoubleVertSpacer) + // For the group clone description + TextField( + value = optionalNewDesc ?: "", + onValueChange = onCloneDescChange, + label = { + Text(text = stringRes(R.string.follow_set_copy_desc_label)) + }, + ) + } + }, + confirmButton = { + Button( + onClick = { + onCloneCreate(optionalNewName, optionalNewDesc) + onDismiss() + }, + ) { + Text(stringRes(R.string.follow_set_copy_action_btn_label)) + } + }, + dismissButton = { + Button( + onClick = onDismiss, + ) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt new file mode 100644 index 000000000..3ae22735a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun ListOfBookmarkGroupsFeedView( + groupListFeedSource: StateFlow>, + onOpenItem: (String) -> Unit, + onRenameItem: (targetBookmarkGroup: LabeledBookmarkList, newName: String) -> Unit, + onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList, newDescription: String?) -> Unit, + onItemClone: (bookmarkGroup: LabeledBookmarkList, customName: String?, customDesc: String?) -> Unit, + onDeleteItem: (bookmarkGroup: LabeledBookmarkList) -> Unit, +) { + val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle() + + if (bookmarkGroupFeedState.isEmpty()) { + BookmarkGroupsFeedEmpty(message = "You do not have any bookmark groups yet. Tap the new button below to make one.") + } else { + LazyColumn( + state = rememberLazyListState(), + contentPadding = FeedPadding, + ) { + itemsIndexed( + bookmarkGroupFeedState, + key = { _: Int, item: LabeledBookmarkList -> item.identifier }, + ) { _, groupItem -> + BookmarkGroupItem( + modifier = Modifier.fillMaxSize().animateItem(), + bookmarkList = groupItem, + onClick = { onOpenItem(groupItem.identifier) }, + onRename = { onRenameItem(groupItem, it) }, + onDescriptionChange = { onItemDescriptionChange(groupItem, it) }, + onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) }, + onDelete = { onDeleteItem(groupItem) }, + ) + HorizontalDivider(thickness = DividerThickness) + } + } + } +} + +@Composable +fun BookmarkGroupsFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) { + Column( + Modifier.fillMaxSize().padding(horizontal = Size40dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(message) + Spacer(modifier = StdVertSpacer) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt new file mode 100644 index 000000000..7b892549f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -0,0 +1,175 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun ListOfBookmarkGroupsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + ListOfBookmarkGroupsFeed( + listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, + addBookmarkGroup = { title, description -> + accountViewModel.runIOCatching { + accountViewModel.account.labeledBookmarkLists.addLabeledBookmarkList( + listName = title, + listDescription = description, + account = accountViewModel.account, + ) + } + }, + openBookmarkGroup = { + nav.nav(Route.BookmarkGroupView(it)) + }, + renameBookmarkGroup = { bookmarkGroup, newName -> + accountViewModel.runIOCatching { + accountViewModel.account.labeledBookmarkLists.renameBookmarkList( + newName = newName, + bookmarkList = bookmarkGroup, + account = accountViewModel.account, + ) + } + }, + changeBookmarkGroupDescription = { bookmarkGroup, newDescription -> + accountViewModel.runIOCatching { + accountViewModel.account.labeledBookmarkLists.modifyListDescription( + newDescription = newDescription, + bookmarkList = bookmarkGroup, + account = accountViewModel.account, + ) + } + }, + cloneBookmarkGroup = { bookmarkGroup, customName, customDesc -> + accountViewModel.runIOCatching { + accountViewModel.account.labeledBookmarkLists.cloneBookmarkList( + currentBookmarkList = bookmarkGroup, + customCloneName = customName, + customCloneDescription = customDesc, + account = accountViewModel.account, + ) + } + }, + deleteBookmarkGroup = { bookmarkGroup -> + accountViewModel.runIOCatching { + accountViewModel.account.labeledBookmarkLists.deleteBookmarkList( + bookmarkList = bookmarkGroup, + account = accountViewModel.account, + ) + } + }, + nav, + ) +} + +@Composable +fun ListOfBookmarkGroupsFeed( + listSource: StateFlow>, + addBookmarkGroup: (title: String, description: String?) -> Unit, + openBookmarkGroup: (identifier: String) -> Unit, + renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList, newName: String) -> Unit, + changeBookmarkGroupDescription: (bookmarkGroup: LabeledBookmarkList, newDescription: String?) -> Unit, + cloneBookmarkGroup: (bookmarkGroup: LabeledBookmarkList, customName: String?, customDesc: String?) -> Unit, + deleteBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton(caption = "Bookmark Groups", nav::popBack) + }, + floatingActionButton = { + BookmarkGroupFabAndMenu(onAddGroup = addBookmarkGroup) + }, + ) { paddingValues -> + Column( + Modifier + .padding( + top = paddingValues.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding(), + ).fillMaxHeight(), + ) { + ListOfBookmarkGroupsFeedView( + groupListFeedSource = listSource, + onOpenItem = openBookmarkGroup, + onRenameItem = renameBookmarkGroup, + onItemDescriptionChange = changeBookmarkGroupDescription, + onItemClone = cloneBookmarkGroup, + onDeleteItem = deleteBookmarkGroup, + ) + } + } +} + +@Composable +fun BookmarkGroupFabAndMenu(onAddGroup: (name: String, description: String?) -> Unit) { + val isSetAdditionDialogOpen = remember { mutableStateOf(false) } + + ExtendedFloatingActionButton( + text = { + Text(text = stringRes(R.string.follow_set_create_btn_label)) + }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = null, + ) + }, + onClick = { + isSetAdditionDialogOpen.value = true + }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) + + if (isSetAdditionDialogOpen.value) { + NewBookmarkGroupCreationDialog( + onDismiss = { + isSetAdditionDialogOpen.value = false + }, + onCreateGroup = { name, description -> + onAddGroup(name, description) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt new file mode 100644 index 000000000..ae744bdcf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import kotlin.toString + +@Composable +fun NewBookmarkGroupCreationDialog( + modifier: Modifier = Modifier, + onDismiss: () -> Unit, + onCreateGroup: (name: String, description: String?) -> Unit, +) { + val newGroupName = remember { mutableStateOf("") } + val newGroupDescription = remember { mutableStateOf(null) } + + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "New Bookmark Group", + ) + } + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + // For the new bookmark group name + TextField( + value = newGroupName.value, + onValueChange = { newGroupName.value = it }, + label = { + Text(text = "Group name") + }, + ) + Spacer(modifier = DoubleVertSpacer) + // For the group description + TextField( + value = + (if (newGroupDescription.value != null) newGroupDescription.value else "").toString(), + onValueChange = { newGroupDescription.value = it }, + label = { + Text(text = "Group description(optional)") + }, + ) + } + }, + confirmButton = { + Button( + onClick = { + onCreateGroup(newGroupName.value, newGroupDescription.value) + onDismiss() + }, + ) { + Text("Create Group") + } + }, + dismissButton = { + Button( + onClick = onDismiss, + ) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} From 34e5e7de5b5bd0b65f67c8b9bbadf4016650c0b3 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 14 Nov 2025 19:38:48 +0100 Subject: [PATCH 05/23] Add bookmark groups option to side menu. Make them work by hooking them up in Account. --- .../java/com/vitorpamplona/amethyst/model/Account.kt | 2 ++ .../amethyst/ui/navigation/AppNavigation.kt | 3 +++ .../amethyst/ui/navigation/drawer/DrawerContent.kt | 9 +++++++++ .../amethyst/ui/navigation/routes/Routes.kt | 2 ++ amethyst/src/main/res/values/strings.xml | 2 ++ 5 files changed, 18 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7747c59eb..894bd9f27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1784,6 +1784,7 @@ class Account( newNotesPreProcessor.runNew(newNotes) peopleLists.newNotes(newNotes) followLists.newNotes(newNotes) + labeledBookmarkLists.newNotes(newNotes) } } } @@ -1794,6 +1795,7 @@ class Account( newNotesPreProcessor.runDeleted(deletedNotes) peopleLists.deletedNotes(deletedNotes) followLists.deletedNotes(deletedNotes) + labeledBookmarkLists.deletedNotes(deletedNotes) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b35f388b0..c13bd6124 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen @@ -129,6 +130,8 @@ fun AppNavigation( composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { EditPeopleListScreen(it.userToAdd, accountViewModel, nav) } + composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } + composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 1f309d01c..12f2fe58f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.CloudUpload +import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd import androidx.compose.material.icons.outlined.Key @@ -451,6 +452,14 @@ fun ListContent( route = Route.Bookmarks, ) + NavigationRow( + title = R.string.bookmark_groups, + icon = Icons.Outlined.CollectionsBookmark, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.BookmarkGroups, + ) + NavigationRow( title = R.string.drafts, icon = Icons.Outlined.Drafts, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index fa30ee8b2..b3997b85b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -46,6 +46,8 @@ sealed class Route { @Serializable object Bookmarks : Route() + @Serializable object BookmarkGroups : Route() + @Serializable object Drafts : Route() @Serializable object Settings : Route() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index aee6b7f40..d780f1b94 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -387,6 +387,8 @@ Remove from Private Bookmarks Remove from Public Bookmarks + Bookmark Groups + Wallet Connect Service Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible Wallet Connect Pubkey From 822d464a6e4ccc38a3d96852d847370b4e984fc2 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 14 Nov 2025 19:45:32 +0100 Subject: [PATCH 06/23] Add support for LabeledBookmarkListEvent in the LocalCache, and EventFactory(to prevent crashes when creating one). --- .../java/com/vitorpamplona/amethyst/model/LocalCache.kt | 8 ++++++++ .../kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt | 2 ++ 2 files changed, 10 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3a2085460..18baf8273 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent @@ -862,6 +863,12 @@ object LocalCache : ILocalCache { return false } + fun consume( + event: LabeledBookmarkListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( event: MuteListEvent, relay: NormalizedRelayUrl?, @@ -2811,6 +2818,7 @@ object LocalCache : ILocalCache { is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified) is InteractiveStorySceneEvent -> consume(event, relay, wasVerified) is InteractiveStoryReadingStateEvent -> consume(event, relay, wasVerified) + is LabeledBookmarkListEvent -> consume(event, relay, wasVerified) is LiveActivitiesEvent -> consume(event, relay, wasVerified) is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) is LnZapEvent -> consume(event, relay, wasVerified) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 5b6fee5a8..d8260c3dd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -80,6 +80,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent @@ -248,6 +249,7 @@ class EventFactory { InteractiveStoryPrologueEvent.KIND -> InteractiveStoryPrologueEvent(id, pubKey, createdAt, tags, content, sig) InteractiveStorySceneEvent.KIND -> InteractiveStorySceneEvent(id, pubKey, createdAt, tags, content, sig) InteractiveStoryReadingStateEvent.KIND -> InteractiveStoryReadingStateEvent(id, pubKey, createdAt, tags, content, sig) + LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) From f1c3a881f1d0f40e1edd6e6e3dca39d3137b6bcb Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 14 Nov 2025 20:12:02 +0100 Subject: [PATCH 07/23] Change the UI of BookmarkGroupItem to add buttons for viewing parts of the bookmark group. --- .../loggedIn/bookmarkgroups/BookmarkType.kt | 31 +++ .../bookmarkgroups/list/BookmarkGroupItem.kt | 198 ++++++++++++------ amethyst/src/main/res/drawable/post.xml | 1 + 3 files changed, 169 insertions(+), 61 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt create mode 100644 amethyst/src/main/res/drawable/post.xml diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt new file mode 100644 index 000000000..1f1a4c315 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups + +sealed interface BookmarkType { + object PostBookmark : BookmarkType + + object ArticleBookmark : BookmarkType + + object LinkBookmark : BookmarkType + + object HashtagBookmark : BookmarkType +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt index 637b5a35e..91a58b46a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt @@ -20,32 +20,39 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.outlined.CollectionsBookmark +import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Numbers import androidx.compose.material.icons.outlined.Public import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle @@ -58,86 +65,155 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font10SP import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders import com.vitorpamplona.amethyst.ui.theme.Size10Modifier -import com.vitorpamplona.amethyst.ui.theme.Size50ModifierOffset10 +import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @Composable fun BookmarkGroupItem( modifier: Modifier = Modifier, bookmarkList: LabeledBookmarkList, - onClick: () -> Unit, + onClick: (bookmarkItemType: BookmarkType) -> Unit, onRename: (String) -> Unit, onDescriptionChange: (String?) -> Unit, onClone: (customName: String?, customDescription: String?) -> Unit, onDelete: () -> Unit, ) { - ListItem( - modifier = modifier, - headlineContent = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(bookmarkList.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + var isExpanded by remember { mutableStateOf(false) } + Row( + modifier = modifier.clickable(onClick = { isExpanded = !isExpanded }), + ) { + Column( + modifier = Modifier.animateContentSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ListItem( + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(bookmarkList.title, maxLines = 1, overflow = TextOverflow.Ellipsis) - Column( - modifier = NoSoTinyBorders, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.End, - ) { - BookmarkGroupOptionsButton( - bookmarkGroupName = bookmarkList.title, - bookmarkGroupDescription = bookmarkList.description, - onGroupRename = onRename, - onGroupDescriptionChange = onDescriptionChange, - onGroupCloneCreate = onClone, - onGroupDelete = onDelete, - ) - } - } - }, - supportingContent = { - Text( - bookmarkList.description ?: "", - overflow = TextOverflow.Ellipsis, - maxLines = 2, + Column( + modifier = NoSoTinyBorders, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.End, + ) { + BookmarkGroupOptionsButton( + bookmarkGroupName = bookmarkList.title, + bookmarkGroupDescription = bookmarkList.description, + onGroupRename = onRename, + onGroupDescriptionChange = onDescriptionChange, + onGroupCloneCreate = onClone, + onGroupDelete = onDelete, + ) + } + } + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + bookmarkList.description ?: "", + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Outlined.CollectionsBookmark, + contentDescription = "Icon for bookmark group", + modifier = Size40Modifier, + ) + Spacer(StdVertSpacer) + BookmarkMembershipStatusAndNumberDisplay( + modifier = Modifier.align(Alignment.CenterHorizontally), + privateBookmarksSize = bookmarkList.privateBookmarks.size, + publicBookmarksSize = bookmarkList.publicBookmarks.size, + ) + } + }, ) - }, - leadingContent = { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Outlined.CollectionsBookmark, - contentDescription = "Icon for bookmark group", - modifier = Size50ModifierOffset10, - ) - BookmarkMembershipStatusAndNumberDisplay( - modifier = Modifier.align(Alignment.BottomCenter), - privateBookmarksSize = bookmarkList.privateBookmarks.size, - publicBookmarksSize = bookmarkList.publicBookmarks.size, + if (isExpanded) { + BookmarkGroupActions( + modifier = Modifier.fillMaxWidth(), + openPostBookmarks = { onClick(BookmarkType.PostBookmark) }, + openArticleBookmarks = { onClick(BookmarkType.ArticleBookmark) }, ) } - }, - trailingContent = { - IconButton( - onClick = onClick, - ) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null) - Text(text = "View") - } - } - }, - ) + } + } +} + +@Composable +private fun BookmarkGroupActions( + modifier: Modifier = Modifier, + openPostBookmarks: () -> Unit = {}, + openArticleBookmarks: () -> Unit = {}, + openLinkBookmarks: () -> Unit = {}, + openHashtagBookmarks: () -> Unit = {}, +) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.SpaceAround, + itemVerticalAlignment = Alignment.CenterVertically, + maxLines = 2, + maxItemsInEachRow = 2, + ) { + FilledTonalButton( + onClick = openPostBookmarks, + ) { + Icon( + painter = painterResource(R.drawable.post), + contentDescription = null, + ) + Text("View Posts") + } + FilledTonalButton( + onClick = openArticleBookmarks, + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Article, + contentDescription = null, + ) + Text("View Articles") + } + FilledTonalButton( + onClick = openLinkBookmarks, + ) { + Icon( + imageVector = Icons.Outlined.Link, + contentDescription = null, + ) + Text("View Links") + } + FilledTonalButton( + onClick = openHashtagBookmarks, + ) { + Icon( + imageVector = Icons.Outlined.Numbers, + contentDescription = null, + ) + Text("View Hashtags") + } + } } @Composable diff --git a/amethyst/src/main/res/drawable/post.xml b/amethyst/src/main/res/drawable/post.xml new file mode 100644 index 000000000..e7e168441 --- /dev/null +++ b/amethyst/src/main/res/drawable/post.xml @@ -0,0 +1 @@ + \ No newline at end of file From eb8b19ceb94fbddfa2e5862fe2908fe3c844dd14 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Sun, 16 Nov 2025 15:40:46 +0100 Subject: [PATCH 08/23] Slight function name refactor in LabeledBookmarkListsState, and inclusion of a method to reference a particular bookmark group. Add route for viewing a bookmark group, and wire UI accordingly. --- .../LabeledBookmarkListsState.kt | 21 +++++++++++++------ .../amethyst/ui/navigation/routes/Routes.kt | 6 ++++++ .../list/ListOfBookmarkGroupsFeedView.kt | 5 +++-- .../list/ListOfBookmarkGroupsScreen.kt | 17 ++++++++------- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt index 48d7099fb..bac6be565 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -127,7 +127,16 @@ class LabeledBookmarkListsState( fun getLabeledBookmarkListNote(bookmarkIdentifier: String): AddressableNote? = existingLabeledBookmarkNotes().find { it.dTag() == bookmarkIdentifier } - fun getLabeledBookmarkList(bookmarkIdentifier: String): LabeledBookmarkListEvent = getLabeledBookmarkListNote(bookmarkIdentifier)?.event as LabeledBookmarkListEvent + fun getLabeledBookmarkListEvent(bookmarkIdentifier: String): LabeledBookmarkListEvent = getLabeledBookmarkListNote(bookmarkIdentifier)?.event as LabeledBookmarkListEvent + + fun getLabeledBookmarkListFlow(bookmarkIdentifier: String) = + labeledBookmarkListNotes + .map { getLabeledBookmarkListEvent(bookmarkIdentifier).toLabeledBookmarkList() } + .onStart { + emit( + getLabeledBookmarkListEvent(bookmarkIdentifier).toLabeledBookmarkList(), + ) + }.flowOn(Dispatchers.IO) suspend fun addLabeledBookmarkList( listName: String, @@ -152,7 +161,7 @@ class LabeledBookmarkListsState( bookmarkList: LabeledBookmarkList, account: Account, ) { - val listEvent = getLabeledBookmarkList(bookmarkList.identifier) + val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier) val renamedList = LabeledBookmarkListEvent.modifyName( earlierVersion = listEvent, @@ -167,7 +176,7 @@ class LabeledBookmarkListsState( bookmarkList: LabeledBookmarkList, account: Account, ) { - val listEvent = getLabeledBookmarkList(bookmarkList.identifier) + val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier) val modifiedList = LabeledBookmarkListEvent.modifyDescription( earlierVersion = listEvent, @@ -198,7 +207,7 @@ class LabeledBookmarkListsState( bookmarkList: LabeledBookmarkList, account: Account, ) { - val listEvent = getLabeledBookmarkList(bookmarkList.identifier) + val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier) val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(listEvent))) account.sendMyPublicAndPrivateOutbox(deletionEvent) } @@ -209,7 +218,7 @@ class LabeledBookmarkListsState( isBookmarkPrivate: Boolean, account: Account, ) { - val currentBookmarkList = getLabeledBookmarkList(bookmarkList.identifier) + val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkList.identifier) val updatedList = LabeledBookmarkListEvent.addBookmark( earlierVersion = currentBookmarkList, @@ -226,7 +235,7 @@ class LabeledBookmarkListsState( isBookmarkPrivate: Boolean, account: Account, ) { - val currentBookmarkList = getLabeledBookmarkList(bookmarkList.identifier) + val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkList.identifier) val updatedList = LabeledBookmarkListEvent.removeBookmark( earlierVersion = currentBookmarkList, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index c69ce89c5..827825f70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.navigation.routes import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -49,6 +50,11 @@ sealed class Route { @Serializable object BookmarkGroups : Route() + @Serializable data class BookmarkGroupView( + val dTag: String, + val bookmarkType: BookmarkType, + ) : Route() + @Serializable object Drafts : Route() @Serializable object Settings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index 3ae22735a..2621d06d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -47,7 +48,7 @@ import kotlinx.coroutines.flow.StateFlow @Composable fun ListOfBookmarkGroupsFeedView( groupListFeedSource: StateFlow>, - onOpenItem: (String) -> Unit, + onOpenItem: (String, BookmarkType) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList, newName: String) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList, newDescription: String?) -> Unit, onItemClone: (bookmarkGroup: LabeledBookmarkList, customName: String?, customDesc: String?) -> Unit, @@ -69,7 +70,7 @@ fun ListOfBookmarkGroupsFeedView( BookmarkGroupItem( modifier = Modifier.fillMaxSize().animateItem(), bookmarkList = groupItem, - onClick = { onOpenItem(groupItem.identifier) }, + onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) }, onRename = { onRenameItem(groupItem, it) }, onDescriptionChange = { onItemDescriptionChange(groupItem, it) }, onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index 7b892549f..131a860e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.flow.StateFlow @@ -52,7 +53,7 @@ fun ListOfBookmarkGroupsScreen( ListOfBookmarkGroupsFeed( listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, addBookmarkGroup = { title, description -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.labeledBookmarkLists.addLabeledBookmarkList( listName = title, listDescription = description, @@ -60,11 +61,11 @@ fun ListOfBookmarkGroupsScreen( ) } }, - openBookmarkGroup = { - nav.nav(Route.BookmarkGroupView(it)) + openBookmarkGroup = { identifier, bookmarkType -> + nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) }, renameBookmarkGroup = { bookmarkGroup, newName -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.labeledBookmarkLists.renameBookmarkList( newName = newName, bookmarkList = bookmarkGroup, @@ -73,7 +74,7 @@ fun ListOfBookmarkGroupsScreen( } }, changeBookmarkGroupDescription = { bookmarkGroup, newDescription -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.labeledBookmarkLists.modifyListDescription( newDescription = newDescription, bookmarkList = bookmarkGroup, @@ -82,7 +83,7 @@ fun ListOfBookmarkGroupsScreen( } }, cloneBookmarkGroup = { bookmarkGroup, customName, customDesc -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.labeledBookmarkLists.cloneBookmarkList( currentBookmarkList = bookmarkGroup, customCloneName = customName, @@ -92,7 +93,7 @@ fun ListOfBookmarkGroupsScreen( } }, deleteBookmarkGroup = { bookmarkGroup -> - accountViewModel.runIOCatching { + accountViewModel.launchSigner { accountViewModel.account.labeledBookmarkLists.deleteBookmarkList( bookmarkList = bookmarkGroup, account = accountViewModel.account, @@ -107,7 +108,7 @@ fun ListOfBookmarkGroupsScreen( fun ListOfBookmarkGroupsFeed( listSource: StateFlow>, addBookmarkGroup: (title: String, description: String?) -> Unit, - openBookmarkGroup: (identifier: String) -> Unit, + openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList, newName: String) -> Unit, changeBookmarkGroupDescription: (bookmarkGroup: LabeledBookmarkList, newDescription: String?) -> Unit, cloneBookmarkGroup: (bookmarkGroup: LabeledBookmarkList, customName: String?, customDesc: String?) -> Unit, From c50c3f26c8f41bd18e73075077c20e34828af053 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Mon, 17 Nov 2025 18:17:16 +0100 Subject: [PATCH 09/23] Move BookmarkType to an enum instead, for serialization purposes. --- .../ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt index 1f1a4c315..7b988f9fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups -sealed interface BookmarkType { - object PostBookmark : BookmarkType +enum class BookmarkType { + PostBookmark, - object ArticleBookmark : BookmarkType + ArticleBookmark, - object LinkBookmark : BookmarkType + LinkBookmark, - object HashtagBookmark : BookmarkType + HashtagBookmark, } From c48436b0a6b334a7f7ded3a600e28dff958e788e Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Mon, 17 Nov 2025 18:51:39 +0100 Subject: [PATCH 10/23] Introduce BookmarkGroupScreen, with it's associated ViewModel, and the different category-based views. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../bookmarkgroups/display/ArticleListView.kt | 98 ++++++ .../display/BookmarkGroupScreen.kt | 291 ++++++++++++++++++ .../display/BookmarkGroupViewModel.kt | 126 ++++++++ .../bookmarkgroups/display/HashtagListView.kt | 59 ++++ .../bookmarkgroups/display/LinksListView.kt | 59 ++++ .../bookmarkgroups/display/PostListView.kt | 98 ++++++ 7 files changed, 733 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/HashtagListView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/LinksListView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index f12291edb..d0d134dc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen @@ -139,6 +140,7 @@ fun AppNavigation( composableFromBottomArgs { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) } composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } + composableFromEndArgs { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt new file mode 100644 index 000000000..164efe81f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +@Composable +fun RenderArticleList( + bookmarkGroupViewModel: BookmarkGroupViewModel, + pagerState: PagerState, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val privateArticles = bookmarkGroupViewModel.privateArticles().collectAsStateWithLifecycle() + val publicArticles = bookmarkGroupViewModel.publicArticles().collectAsStateWithLifecycle() + + HorizontalPager(pagerState, modifier) { page -> + when (page) { + 0 -> + ArticleList( + modifier = Modifier.fillMaxSize(), + articles = publicArticles.value, + isPrivate = false, + accountViewModel = accountViewModel, + nav = nav, + ) + 1 -> + ArticleList( + modifier = Modifier.fillMaxSize(), + articles = privateArticles.value, + isPrivate = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun ArticleList( + modifier: Modifier = Modifier, + articles: List, + isPrivate: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + val listState = rememberLazyListState() + + LazyColumn( + modifier = modifier, + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(articles, key = { _, item -> item.toNAddr() }) { _, item -> + // TODO: Find a way to integrate bookmark group callbacks into the note below + NoteCompose( + baseNote = item, + modifier = Modifier.animateContentSize(), + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt new file mode 100644 index 000000000..3cf1390fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -0,0 +1,291 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import kotlinx.coroutines.launch + +@Composable +fun BookmarkGroupScreen( + bookmarkIdentifier: String, + bookmarkType: BookmarkType, + accountViewModel: AccountViewModel, + nav: INav, +) { + val bookmarkGroupViewModel: BookmarkGroupViewModel = + viewModel( + factory = BookmarkGroupViewModel.Initializer(accountViewModel.account, bookmarkIdentifier), + ) + + BookmarkGroupScreenView( + bookmarkGroupViewModel, + bookmarkType, + broadcastBookmarkGroup = { + accountViewModel.launchSigner { + val groupNote = accountViewModel.account.labeledBookmarkLists.getLabeledBookmarkListNote(bookmarkIdentifier) + groupNote?.let { + accountViewModel.broadcast(it) + } + } + }, + deleteBookmarkGroup = { + accountViewModel.launchSigner { + bookmarkGroupViewModel.deleteBookmarkGroup() + } + }, + accountViewModel, + nav, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookmarkGroupScreenView( + bookmarkGroupViewModel: BookmarkGroupViewModel, + bookmarkType: BookmarkType, + broadcastBookmarkGroup: () -> Unit, + deleteBookmarkGroup: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val pagerState = rememberPagerState { 2 } + val privateItemTypeLabel = + when (bookmarkType) { + BookmarkType.ArticleBookmark -> "Private Articles" + BookmarkType.HashtagBookmark -> "Private Hashtags" + BookmarkType.LinkBookmark -> "Private Links" + BookmarkType.PostBookmark -> "Private Posts" + } + + val publicItemTypeLabel = + when (bookmarkType) { + BookmarkType.ArticleBookmark -> "Public Articles" + BookmarkType.HashtagBookmark -> "Public Hashtags" + BookmarkType.LinkBookmark -> "Public Links" + BookmarkType.PostBookmark -> "Public Posts" + } + Scaffold( + topBar = { + Column { + TopAppBar( + title = { + TitleAndDescription(bookmarkGroupViewModel) + }, + navigationIcon = { + IconButton(nav::popBack) { + ArrowBackIcon() + } + }, + actions = { + BookmarkGroupActionsMenuButton( + onBroadcastList = broadcastBookmarkGroup, + onDeleteList = deleteBookmarkGroup, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + TabRow( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + ) { + val scope = rememberCoroutineScope() + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(text = publicItemTypeLabel) }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(text = privateItemTypeLabel) }, + ) + } + } + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + top = padding.calculateTopPadding(), + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding) + .imePadding(), + ) { + when (bookmarkType) { + BookmarkType.PostBookmark -> + RenderPostList( + bookmarkGroupViewModel, + pagerState, + accountViewModel, + nav, + ) + BookmarkType.ArticleBookmark -> + RenderArticleList( + bookmarkGroupViewModel, + pagerState, + accountViewModel, + nav, + ) + BookmarkType.HashtagBookmark -> RenderHashtagList(bookmarkGroupViewModel, pagerState) + BookmarkType.LinkBookmark -> RenderLinksList(bookmarkGroupViewModel, pagerState) + } + } + } +} + +@Composable +private fun TitleAndDescription(viewModel: BookmarkGroupViewModel) { + val selectedSetState = viewModel.selectedBookmarkGroupFlow.collectAsStateWithLifecycle() + selectedSetState.value?.let { bookmarkGroup -> + ListItem( + headlineContent = { + Text( + text = bookmarkGroup.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + bookmarkGroup.description?.let { description -> + Text( + text = description, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + ) + } +} + +@Composable +fun BookmarkGroupActionsMenuButton( + onBroadcastList: () -> Unit, + onDeleteList: () -> Unit, +) { + val isActionListOpen = remember { mutableStateOf(false) } + + ClickableBox( + modifier = + StdPadding + .size(30.dp) + .border( + width = Dp.Hairline, + color = ButtonDefaults.filledTonalButtonColors().containerColor, + shape = ButtonBorder, + ).background( + color = ButtonDefaults.filledTonalButtonColors().containerColor, + shape = ButtonBorder, + ), + onClick = { isActionListOpen.value = true }, + ) { + VerticalDotsIcon() + BookmarkGroupActionsMenu( + onCloseMenu = { isActionListOpen.value = false }, + isOpen = isActionListOpen.value, + onBroadcastList = onBroadcastList, + onDeleteList = onDeleteList, + ) + } +} + +@Composable +fun BookmarkGroupActionsMenu( + onCloseMenu: () -> Unit, + isOpen: Boolean, + onBroadcastList: () -> Unit, + onDeleteList: () -> Unit, +) { + DropdownMenu( + expanded = isOpen, + onDismissRequest = onCloseMenu, + ) { + DropdownMenuItem( + text = { + Text("Broadcast Bookmark Group") + }, + onClick = { + onBroadcastList() + onCloseMenu() + }, + ) + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { + Text("Delete Bookmark Group") + }, + onClick = { + onDeleteList() + onCloseMenu() + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt new file mode 100644 index 000000000..e057dff93 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@Stable +class BookmarkGroupViewModel( + val account: Account, + bookmarkGroupIdentifier: String, +) : ViewModel() { + val selectedBookmarkGroupFlow = + account.labeledBookmarkLists + .getLabeledBookmarkListFlow(bookmarkGroupIdentifier) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null) + + fun publicPosts() = + selectedBookmarkGroupFlow + .filterNotNull() + .map { group -> + group.publicBookmarks + .filter { it is EventBookmark } + .map { account.cache.getOrCreateNote((it as EventBookmark).eventId) } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun privatePosts() = + selectedBookmarkGroupFlow + .filterNotNull() + .map { group -> + group.privateBookmarks + .filter { it is EventBookmark } + .map { account.cache.getOrCreateNote((it as EventBookmark).eventId) } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun publicArticles() = + selectedBookmarkGroupFlow + .filterNotNull() + .map { group -> + group.publicBookmarks + .filter { it is AddressBookmark } + .map { account.cache.getOrCreateAddressableNote((it as AddressBookmark).address) } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun privateArticles() = + selectedBookmarkGroupFlow + .filterNotNull() + .map { group -> + group.privateBookmarks + .filter { it is AddressBookmark } + .map { account.cache.getOrCreateAddressableNote((it as AddressBookmark).address) } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + // TODO: Add implementations for Hashtag and Link bookmarks + + suspend fun deleteBookmarkGroup() { + selectedBookmarkGroupFlow.value?.let { + account.labeledBookmarkLists.deleteBookmarkList(it, account) + } + } + + suspend fun addBookmarkToGroup( + bookmark: BookmarkIdTag, + isPrivate: Boolean, + account: Account, + ) { + selectedBookmarkGroupFlow.value?.let { + account.labeledBookmarkLists.addBookmarkToList( + bookmark, + it, + isPrivate, + account, + ) + } + } + + suspend fun removeBookmarkFromGroup( + bookmark: BookmarkIdTag, + isPrivate: Boolean, + account: Account, + ) { + selectedBookmarkGroupFlow.value?.let { + account.labeledBookmarkLists.removeBookmarkFromList( + bookmark, + it, + isPrivate, + account, + ) + } + } + + class Initializer( + val account: Account, + val bookmarkGroupIdentifier: String, + ) : ViewModelProvider.NewInstanceFactory() { + override fun create(modelClass: Class): T = BookmarkGroupViewModel(account, bookmarkGroupIdentifier) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/HashtagListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/HashtagListView.kt new file mode 100644 index 000000000..7f3f922ff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/HashtagListView.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun RenderHashtagList( + bookmarkGroupViewModel: BookmarkGroupViewModel, + pagerState: PagerState, + modifier: Modifier = Modifier, +) { + HorizontalPager(pagerState, modifier) { page -> + when (page) { + 0 -> HashtagsList(isPrivate = true) + 1 -> HashtagsList(isPrivate = false) + } + } +} + +@Composable +private fun HashtagsList( + modifier: Modifier = Modifier, + // list: List, + isPrivate: Boolean, +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("To be implemented") + // Text("Number of ${if (isPrivate) "private" else "public"} hashtags: ${list.size}") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/LinksListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/LinksListView.kt new file mode 100644 index 000000000..13ae5e64e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/LinksListView.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +// TODO: Come back to work on this +@Composable +fun RenderLinksList( + bookmarkGroupViewModel: BookmarkGroupViewModel, + pagerState: PagerState, + modifier: Modifier = Modifier, +) { + HorizontalPager(pagerState, modifier) { page -> + when (page) { + 0 -> LinksList(isPrivate = true) + 1 -> LinksList(isPrivate = false) + } + } +} + +@Composable +private fun LinksList( + // list: List, + isPrivate: Boolean, +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("To be implemented") + // Text("Number of ${if (isPrivate) "private" else "public"} links: ${list.size}") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt new file mode 100644 index 000000000..fd7b11fb3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.FeedPadding + +@Composable +fun RenderPostList( + bookmarkGroupViewModel: BookmarkGroupViewModel, + pagerState: PagerState, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val privatePosts = bookmarkGroupViewModel.privatePosts().collectAsStateWithLifecycle() + val publicPosts = bookmarkGroupViewModel.publicPosts().collectAsStateWithLifecycle() + + HorizontalPager(pagerState, modifier) { page -> + when (page) { + 0 -> + PostList( + modifier = Modifier.fillMaxSize(), + posts = publicPosts.value, + isPrivate = false, + accountViewModel = accountViewModel, + nav = nav, + ) + 1 -> + PostList( + modifier = Modifier.fillMaxSize(), + posts = privatePosts.value, + isPrivate = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun PostList( + modifier: Modifier = Modifier, + posts: List, + isPrivate: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + val listState = rememberLazyListState() + + LazyColumn( + modifier = modifier, + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(posts, key = { _, item -> item.idHex }) { _, item -> + // TODO: Find a way to integrate bookmark group callbacks into the note below + NoteCompose( + baseNote = item, + modifier = Modifier.animateContentSize(), + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} From 267d680ffcfe82f94cacd66f413fcc91f46c4ec7 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 21 Nov 2025 14:11:55 +0100 Subject: [PATCH 11/23] Fix some methods in LabeledBookmarkListsState. Add useful methods to LabeledBookmarkList and BookmarkGroupViewModel. --- .../LabeledBookmarkList.kt | 12 ++- .../LabeledBookmarkListsState.kt | 26 +++-- .../display/BookmarkGroupViewModel.kt | 96 ++++++++++--------- 3 files changed, 80 insertions(+), 54 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt index b60e77fef..ac33fb0dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark @Stable data class LabeledBookmarkList( @@ -30,4 +32,12 @@ data class LabeledBookmarkList( val description: String?, val privateBookmarks: Set = emptySet(), val publicBookmarks: Set = emptySet(), -) +) { + // TODO: Add methods for LInk and Hashtag, after their implementation. + + val privatePostBookmarks = privateBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark } + val publicPostBookmarks = publicBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark } + + val privateArticleBookmarks = privateBookmarks.filter { it is AddressBookmark }.map { bookmarkIdTag -> bookmarkIdTag as AddressBookmark } + val publicArticleBookmarks = publicBookmarks.filter { it is AddressBookmark }.map { bookmarkIdTag -> bookmarkIdTag as AddressBookmark } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt index bac6be565..6d980d63a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -98,6 +98,11 @@ class LabeledBookmarkListsState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) + fun List.getList(bookmarkListId: String) = + this.firstOrNull { + it.identifier == bookmarkListId + } + fun DeletionEvent.hasAnyDeletedBookmarkLists() = deleteAddressesWithKind(LabeledBookmarkListEvent.KIND) || deletesAnyEventIn(labeledBookmarkListEventIds.value) fun hasItemInNoteList(notes: Set): Boolean = @@ -130,11 +135,11 @@ class LabeledBookmarkListsState( fun getLabeledBookmarkListEvent(bookmarkIdentifier: String): LabeledBookmarkListEvent = getLabeledBookmarkListNote(bookmarkIdentifier)?.event as LabeledBookmarkListEvent fun getLabeledBookmarkListFlow(bookmarkIdentifier: String) = - labeledBookmarkListNotes - .map { getLabeledBookmarkListEvent(bookmarkIdentifier).toLabeledBookmarkList() } + listFeedFlow + .map { it.getList(bookmarkIdentifier) } .onStart { emit( - getLabeledBookmarkListEvent(bookmarkIdentifier).toLabeledBookmarkList(), + listFeedFlow.value.getList(bookmarkIdentifier), ) }.flowOn(Dispatchers.IO) @@ -204,21 +209,22 @@ class LabeledBookmarkListsState( } suspend fun deleteBookmarkList( - bookmarkList: LabeledBookmarkList, + bookmarkListIdentifier: String, account: Account, ) { - val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier) - val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(listEvent))) + val listEvent = getLabeledBookmarkListEvent(bookmarkListIdentifier) + val deletionEventTemplate = DeletionEvent.build(listOf(listEvent)) + val deletionEvent = account.signer.sign(deletionEventTemplate) account.sendMyPublicAndPrivateOutbox(deletionEvent) } suspend fun addBookmarkToList( bookmark: BookmarkIdTag, - bookmarkList: LabeledBookmarkList, + bookmarkListIdentifier: String, isBookmarkPrivate: Boolean, account: Account, ) { - val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkList.identifier) + val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier) val updatedList = LabeledBookmarkListEvent.addBookmark( earlierVersion = currentBookmarkList, @@ -231,11 +237,11 @@ class LabeledBookmarkListsState( suspend fun removeBookmarkFromList( bookmark: BookmarkIdTag, - bookmarkList: LabeledBookmarkList, + bookmarkListIdentifier: String, isBookmarkPrivate: Boolean, account: Account, ) { - val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkList.identifier) + val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier) val updatedList = LabeledBookmarkListEvent.removeBookmark( earlierVersion = currentBookmarkList, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt index e057dff93..a0cd966be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark @@ -32,11 +33,12 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlin.collections.emptyList @Stable class BookmarkGroupViewModel( val account: Account, - bookmarkGroupIdentifier: String, + val bookmarkGroupIdentifier: String, ) : ViewModel() { val selectedBookmarkGroupFlow = account.labeledBookmarkLists @@ -46,75 +48,83 @@ class BookmarkGroupViewModel( fun publicPosts() = selectedBookmarkGroupFlow .filterNotNull() - .map { group -> - group.publicBookmarks - .filter { it is EventBookmark } - .map { account.cache.getOrCreateNote((it as EventBookmark).eventId) } - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + .map { group -> group.publicPostBookmarks.map { account.cache.getOrCreateNote(it.eventId) } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) fun privatePosts() = selectedBookmarkGroupFlow .filterNotNull() - .map { group -> - group.privateBookmarks - .filter { it is EventBookmark } - .map { account.cache.getOrCreateNote((it as EventBookmark).eventId) } - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + .map { group -> group.privatePostBookmarks.map { account.cache.getOrCreateNote(it.eventId) } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) fun publicArticles() = selectedBookmarkGroupFlow .filterNotNull() - .map { group -> - group.publicBookmarks - .filter { it is AddressBookmark } - .map { account.cache.getOrCreateAddressableNote((it as AddressBookmark).address) } - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + .map { group -> group.publicArticleBookmarks.map { account.cache.getOrCreateAddressableNote(it.address) } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) fun privateArticles() = selectedBookmarkGroupFlow .filterNotNull() - .map { group -> - group.privateBookmarks - .filter { it is AddressBookmark } - .map { account.cache.getOrCreateAddressableNote((it as AddressBookmark).address) } - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + .map { group -> group.privateArticleBookmarks.map { account.cache.getOrCreateAddressableNote(it.address) } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) // TODO: Add implementations for Hashtag and Link bookmarks - suspend fun deleteBookmarkGroup() { - selectedBookmarkGroupFlow.value?.let { - account.labeledBookmarkLists.deleteBookmarkList(it, account) - } + suspend fun deleteBookmarkGroup(groupIdentifier: String) { + account.labeledBookmarkLists.deleteBookmarkList(groupIdentifier, account) } suspend fun addBookmarkToGroup( + groupIdentifier: String = bookmarkGroupIdentifier, bookmark: BookmarkIdTag, isPrivate: Boolean, - account: Account, ) { - selectedBookmarkGroupFlow.value?.let { - account.labeledBookmarkLists.addBookmarkToList( - bookmark, - it, - isPrivate, - account, - ) - } + account.labeledBookmarkLists.addBookmarkToList( + bookmark, + groupIdentifier, + isPrivate, + account, + ) + } + + suspend fun removePostBookmark( + groupIdentifier: String = bookmarkGroupIdentifier, + bookmarkPostId: String, + isPrivate: Boolean, + ) { + val eventBookmark = EventBookmark(bookmarkPostId) + removeBookmarkFromGroup( + groupIdentifier, + eventBookmark, + isPrivate, + ) + } + + suspend fun removeArticleBookmark( + groupIdentifier: String = bookmarkGroupIdentifier, + bookmarkArticleAddress: Address, + isPrivate: Boolean, + ) { + val eventBookmark = AddressBookmark(bookmarkArticleAddress) + removeBookmarkFromGroup( + groupIdentifier, + eventBookmark, + isPrivate, + ) } suspend fun removeBookmarkFromGroup( + groupIdentifier: String = bookmarkGroupIdentifier, bookmark: BookmarkIdTag, isPrivate: Boolean, - account: Account, ) { - selectedBookmarkGroupFlow.value?.let { - account.labeledBookmarkLists.removeBookmarkFromList( - bookmark, - it, - isPrivate, - account, - ) - } + account.labeledBookmarkLists.removeBookmarkFromList( + bookmark, + groupIdentifier, + isPrivate, + account, + ) } class Initializer( From 808030d17682060069da0f7373f941b60a1534e7 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 21 Nov 2025 14:20:57 +0100 Subject: [PATCH 12/23] First category-based screen: PostListView. Modify NoteCompose to use a different note options menu when in the PostListView. --- .../amethyst/ui/note/NoteCompose.kt | 18 +- .../display/BookmarkGroupOptions.kt | 323 ++++++++++++++++++ .../bookmarkgroups/display/PostListView.kt | 30 +- 3 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 8442ffeed..30522d87d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -241,6 +241,7 @@ fun NoteCompose( parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, nav: INav, + moreOptions: (@Composable () -> Unit)? = null, ) { WatchNoteEvent( baseNote = baseNote, @@ -269,6 +270,7 @@ fun NoteCompose( parentBackgroundColor = parentBackgroundColor, accountViewModel = accountViewModel, nav = nav, + moreOptions = moreOptions, ) } } @@ -288,6 +290,7 @@ fun AcceptableNote( parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, nav: INav, + moreOptions: (@Composable () -> Unit)?, ) { if (isQuotedNote || isBoostedNote) { val noteEvent = baseNote.event @@ -333,6 +336,7 @@ fun AcceptableNote( accountViewModel = accountViewModel, showPopup = showPopup, nav = nav, + moreOptions = moreOptions, ) } } @@ -379,6 +383,7 @@ fun AcceptableNote( accountViewModel = accountViewModel, showPopup = showPopup, nav = nav, + moreOptions = moreOptions, ) } } @@ -438,6 +443,7 @@ private fun CheckNewAndRenderNote( accountViewModel: AccountViewModel, showPopup: () -> Unit, nav: INav, + moreOptions: (@Composable () -> Unit)? = null, ) { val backgroundColor = calculateBackgroundColor( @@ -466,6 +472,7 @@ private fun CheckNewAndRenderNote( quotesLeft = quotesLeft, accountViewModel = accountViewModel, nav = nav, + moreOptions = moreOptions, ) } } @@ -513,6 +520,7 @@ fun InnerNoteWithReactions( quotesLeft: Int, accountViewModel: AccountViewModel, nav: INav, + moreOptions: (@Composable () -> Unit)? = null, ) { val notBoostedNorQuote = !isBoostedNote && !isQuotedNote val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) @@ -556,6 +564,7 @@ fun InnerNoteWithReactions( editState = editState, accountViewModel = accountViewModel, nav = nav, + moreOptions = moreOptions, ) RenderApprovalIfNeeded(baseNote, accountViewModel, nav) @@ -638,6 +647,7 @@ fun NoteBody( editState: State>, accountViewModel: AccountViewModel, nav: INav, + moreOptions: (@Composable () -> Unit)? = null, ) { FirstUserInfoRow( baseNote = baseNote, @@ -645,6 +655,7 @@ fun NoteBody( editState = editState, accountViewModel = accountViewModel, nav = nav, + moreOptions = moreOptions, ) if (showSecondRow) { @@ -1135,6 +1146,7 @@ fun FirstUserInfoRow( editState: State>, accountViewModel: AccountViewModel, nav: INav, + moreOptions: (@Composable () -> Unit)? = null, ) { Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) { val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent @@ -1185,7 +1197,11 @@ fun FirstUserInfoRow( TimeAgo(baseNote) - MoreOptionsButton(baseNote, editState, accountViewModel, nav) + if (moreOptions == null) { + MoreOptionsButton(baseNote, editState, accountViewModel, nav) + } else { + moreOptions() + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.kt new file mode 100644 index 000000000..2a51815da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.kt @@ -0,0 +1,323 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display + +import android.content.Intent +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.EditPostView +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams +import com.vitorpamplona.amethyst.ui.note.elements.WatchBookmarksFollowsAndAccount +import com.vitorpamplona.amethyst.ui.note.externalLinkForNote +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun BookmarkGroupItemOptions( + baseNote: Note, + onDeleteBookmarkGroup: () -> Unit, + editState: State>? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val popupExpanded = remember { mutableStateOf(false) } + + ClickableBox( + modifier = Size24Modifier, + onClick = { popupExpanded.value = true }, + ) { + VerticalDotsIcon() + + if (popupExpanded.value) { + BookmarkGroupItemOptionsMenu( + note = baseNote, + onDismiss = { popupExpanded.value = false }, + onDeleteBookmarkGroup = onDeleteBookmarkGroup, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun BookmarkGroupItemOptionsMenu( + note: Note, + onDismiss: () -> Unit, + onDeleteBookmarkGroup: () -> Unit, + editState: State>? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + var reportDialogShowing by remember { mutableStateOf(false) } + + var state by remember { + mutableStateOf( + DropDownParams( + isFollowingAuthor = false, + isPrivateBookmarkNote = false, + isPublicBookmarkNote = false, + isLoggedUser = false, + isSensitive = false, + showSensitiveContent = null, + ), + ) + } + + val wantsToEditPost = + remember { + mutableStateOf(false) + } + + if (wantsToEditPost.value) { + // avoids changing while drafting a note and a new event shows up. + val versionLookingAt = + remember { + (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value + } + + EditPostView( + onClose = { + onDismiss() + wantsToEditPost.value = false + }, + edit = note, + versionLookingAt = versionLookingAt, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + DropdownMenu( + expanded = true, + onDismissRequest = onDismiss, + ) { + val clipboardManager = LocalClipboardManager.current + val appContext = LocalContext.current.applicationContext + val actContext = LocalContext.current + + WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState -> + if (state != newState) { + state = newState + } + } + + val scope = rememberCoroutineScope() + DropdownMenuItem( + text = { Text("Remove from Bookmark List") }, + onClick = { + onDeleteBookmarkGroup() + onDismiss() + }, + ) + // TODO: Work on moving feature below +// DropdownMenuItem( +// text = { Text("Move to Public") }, +// onClick = +// ) + HorizontalDivider(thickness = DividerThickness) + + if (!state.isFollowingAuthor) { + DropdownMenuItem( + text = { Text(stringRes(R.string.follow)) }, + onClick = { + val author = note.author ?: return@DropdownMenuItem + accountViewModel.follow(author) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + } else { + DropdownMenuItem( + text = { Text(stringRes(R.string.unfollow)) }, + onClick = { + val author = note.author ?: return@DropdownMenuItem + accountViewModel.unfollow(author) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + } +// DropdownMenuItem( +// text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) }, +// onClick = { +// val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem +// nav.nav(Route.PeopleListManagement(authorHexKey)) +// onDismiss() +// }, +// ) + DropdownMenuItem( + text = { Text(stringRes(R.string.copy_text)) }, + onClick = { + val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note + accountViewModel.decrypt(lastNoteVersion) { + clipboardManager.setText(AnnotatedString(it)) + } + onDismiss() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.copy_user_pubkey)) }, + onClick = { + note.author?.let { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}")) + onDismiss() + } + } + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.copy_note_id)) }, + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString(note.toNostrUri())) + onDismiss() + } + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.quick_action_share)) }, + onClick = { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + externalLinkForNote(note), + ) + putExtra( + Intent.EXTRA_TITLE, + stringRes(actContext, R.string.quick_action_share_browser_link), + ) + } + + val shareIntent = + Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) + ContextCompat.startActivity(actContext, shareIntent, null) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + if (state.isLoggedUser && note.isDraft()) { + DropdownMenuItem( + text = { Text(stringRes(R.string.edit_draft)) }, + onClick = { + nav.nav { + routeEditDraftTo(note, accountViewModel.account) + } + }, + ) + } + if (note.event is TextNoteEvent && !note.isDraft()) { + if (state.isLoggedUser) { + DropdownMenuItem( + text = { Text(stringRes(R.string.edit_post)) }, + onClick = { + wantsToEditPost.value = true + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringRes(R.string.propose_an_edit)) }, + onClick = { + wantsToEditPost.value = true + }, + ) + } + } + DropdownMenuItem( + text = { Text(stringRes(R.string.broadcast)) }, + onClick = { + accountViewModel.broadcast(note) + onDismiss() + }, + ) + HorizontalDivider(thickness = DividerThickness) + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { + DropdownMenuItem( + text = { Text(stringRes(R.string.timestamp_pending)) }, + onClick = { + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringRes(R.string.timestamp_it)) }, + onClick = { + accountViewModel.timestamp(note) + onDismiss() + }, + ) + } + HorizontalDivider(thickness = DividerThickness) + if (state.isLoggedUser) { + DropdownMenuItem( + text = { Text(stringRes(R.string.request_deletion)) }, + onClick = { + accountViewModel.delete(note) + onDismiss() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringRes(R.string.block_report)) }, + onClick = { reportDialogShowing = true }, + ) + } + } + + if (reportDialogShowing) { + ReportNoteDialog(note = note, accountViewModel = accountViewModel) { + reportDialogShowing = false + onDismiss() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt index fd7b11fb3..4346fdf24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.Note @@ -41,27 +42,32 @@ fun RenderPostList( bookmarkGroupViewModel: BookmarkGroupViewModel, pagerState: PagerState, accountViewModel: AccountViewModel, + deletePostBookmark: (postId: String, isPrivate: Boolean) -> Unit, nav: INav, modifier: Modifier = Modifier, ) { - val privatePosts = bookmarkGroupViewModel.privatePosts().collectAsStateWithLifecycle() - val publicPosts = bookmarkGroupViewModel.publicPosts().collectAsStateWithLifecycle() + val privatePosts by bookmarkGroupViewModel.privatePosts().collectAsStateWithLifecycle() + val publicPosts by bookmarkGroupViewModel.publicPosts().collectAsStateWithLifecycle() HorizontalPager(pagerState, modifier) { page -> when (page) { 0 -> PostList( modifier = Modifier.fillMaxSize(), - posts = publicPosts.value, - isPrivate = false, + posts = publicPosts, + onDeletePostBookmark = { postId -> + deletePostBookmark(postId, false) + }, accountViewModel = accountViewModel, nav = nav, ) 1 -> PostList( modifier = Modifier.fillMaxSize(), - posts = privatePosts.value, - isPrivate = true, + posts = privatePosts, + onDeletePostBookmark = { postId -> + deletePostBookmark(postId, true) + }, accountViewModel = accountViewModel, nav = nav, ) @@ -73,7 +79,7 @@ fun RenderPostList( private fun PostList( modifier: Modifier = Modifier, posts: List, - isPrivate: Boolean, + onDeletePostBookmark: (postId: String) -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { @@ -88,10 +94,18 @@ private fun PostList( // TODO: Find a way to integrate bookmark group callbacks into the note below NoteCompose( baseNote = item, - modifier = Modifier.animateContentSize(), + modifier = Modifier.animateItem().animateContentSize(), quotesLeft = 3, accountViewModel = accountViewModel, nav = nav, + moreOptions = { + BookmarkGroupItemOptions( + baseNote = item, + onDeleteBookmarkGroup = { onDeletePostBookmark(item.idHex) }, + accountViewModel = accountViewModel, + nav = nav, + ) + }, ) } } From 26c66a97b5d341b53f8674d40a45335c24e7a610 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 21 Nov 2025 14:51:10 +0100 Subject: [PATCH 13/23] Bring ArticleListView up to speed. Fix callback naming mistake in BookmarkGroupItemOptions and refactor usages accordingly. --- .../bookmarkgroups/display/ArticleListView.kt | 31 ++++++++++++++----- ...Options.kt => BookmarkGroupItemOptions.kt} | 8 ++--- .../bookmarkgroups/display/PostListView.kt | 3 +- 3 files changed, 29 insertions(+), 13 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/{BookmarkGroupOptions.kt => BookmarkGroupItemOptions.kt} (98%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt index 164efe81f..1f4f595d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.AddressableNote @@ -35,33 +36,39 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.nip01Core.core.Address @Composable fun RenderArticleList( bookmarkGroupViewModel: BookmarkGroupViewModel, pagerState: PagerState, accountViewModel: AccountViewModel, + deleteArticleBookmark: (articleAddress: Address, isPrivate: Boolean) -> Unit, nav: INav, modifier: Modifier = Modifier, ) { - val privateArticles = bookmarkGroupViewModel.privateArticles().collectAsStateWithLifecycle() - val publicArticles = bookmarkGroupViewModel.publicArticles().collectAsStateWithLifecycle() + val privateArticles by bookmarkGroupViewModel.privateArticles().collectAsStateWithLifecycle() + val publicArticles by bookmarkGroupViewModel.publicArticles().collectAsStateWithLifecycle() HorizontalPager(pagerState, modifier) { page -> when (page) { 0 -> ArticleList( modifier = Modifier.fillMaxSize(), - articles = publicArticles.value, - isPrivate = false, + articles = publicArticles, + onDeleteArticleBookmark = { articleAddress -> + deleteArticleBookmark(articleAddress, false) + }, accountViewModel = accountViewModel, nav = nav, ) 1 -> ArticleList( modifier = Modifier.fillMaxSize(), - articles = privateArticles.value, - isPrivate = true, + articles = privateArticles, + onDeleteArticleBookmark = { articleAddress -> + deleteArticleBookmark(articleAddress, true) + }, accountViewModel = accountViewModel, nav = nav, ) @@ -73,7 +80,7 @@ fun RenderArticleList( fun ArticleList( modifier: Modifier = Modifier, articles: List, - isPrivate: Boolean, + onDeleteArticleBookmark: (Address) -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { @@ -92,6 +99,16 @@ fun ArticleList( quotesLeft = 3, accountViewModel = accountViewModel, nav = nav, + moreOptions = { + BookmarkGroupItemOptions( + baseNote = item, + onDeleteBookmarkItem = { + onDeleteArticleBookmark(item.address) + }, + accountViewModel = accountViewModel, + nav = nav, + ) + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 2a51815da..3ed9f76cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -60,7 +60,7 @@ import kotlinx.coroutines.launch @Composable fun BookmarkGroupItemOptions( baseNote: Note, - onDeleteBookmarkGroup: () -> Unit, + onDeleteBookmarkItem: () -> Unit, editState: State>? = null, accountViewModel: AccountViewModel, nav: INav, @@ -77,7 +77,7 @@ fun BookmarkGroupItemOptions( BookmarkGroupItemOptionsMenu( note = baseNote, onDismiss = { popupExpanded.value = false }, - onDeleteBookmarkGroup = onDeleteBookmarkGroup, + onDeleteBookmarkItem = onDeleteBookmarkItem, editState = editState, accountViewModel = accountViewModel, nav = nav, @@ -90,7 +90,7 @@ fun BookmarkGroupItemOptions( fun BookmarkGroupItemOptionsMenu( note: Note, onDismiss: () -> Unit, - onDeleteBookmarkGroup: () -> Unit, + onDeleteBookmarkItem: () -> Unit, editState: State>? = null, accountViewModel: AccountViewModel, nav: INav, @@ -152,7 +152,7 @@ fun BookmarkGroupItemOptionsMenu( DropdownMenuItem( text = { Text("Remove from Bookmark List") }, onClick = { - onDeleteBookmarkGroup() + onDeleteBookmarkItem() onDismiss() }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt index 4346fdf24..d6976df7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt @@ -91,7 +91,6 @@ private fun PostList( state = listState, ) { itemsIndexed(posts, key = { _, item -> item.idHex }) { _, item -> - // TODO: Find a way to integrate bookmark group callbacks into the note below NoteCompose( baseNote = item, modifier = Modifier.animateItem().animateContentSize(), @@ -101,7 +100,7 @@ private fun PostList( moreOptions = { BookmarkGroupItemOptions( baseNote = item, - onDeleteBookmarkGroup = { onDeletePostBookmark(item.idHex) }, + onDeleteBookmarkItem = { onDeletePostBookmark(item.idHex) }, accountViewModel = accountViewModel, nav = nav, ) From b4aca5937205ae0676dc6cf2e4c2ac6affd38d3c Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 21 Nov 2025 18:19:41 +0100 Subject: [PATCH 14/23] Use stringResources. Implement the necessary callbacks in BookmarkGroupScreen. Disable component stats display in BookmarkGroupItem until Link/Hashtag support is implemented. --- .../ui/navigation/drawer/DrawerContent.kt | 2 +- .../display/BookmarkGroupScreen.kt | 125 +++++++++++++----- .../bookmarkgroups/list/BookmarkGroupItem.kt | 26 ++-- .../list/ListOfBookmarkGroupsFeedView.kt | 2 +- .../list/ListOfBookmarkGroupsScreen.kt | 4 +- amethyst/src/main/res/values/strings.xml | 27 +++- 6 files changed, 132 insertions(+), 54 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 791272cc9..7a3c89af3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -453,7 +453,7 @@ fun ListContent( ) NavigationRow( - title = R.string.bookmark_groups, + title = R.string.bookmark_lists, icon = Icons.Outlined.CollectionsBookmark, tint = MaterialTheme.colorScheme.onBackground, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 3cf1390fa..7fb6aba8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu @@ -44,6 +45,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -54,12 +56,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.StdPadding @@ -91,8 +95,9 @@ fun BookmarkGroupScreen( }, deleteBookmarkGroup = { accountViewModel.launchSigner { - bookmarkGroupViewModel.deleteBookmarkGroup() + bookmarkGroupViewModel.deleteBookmarkGroup(bookmarkIdentifier) } + nav.popBack() }, accountViewModel, nav, @@ -110,21 +115,6 @@ fun BookmarkGroupScreenView( nav: INav, ) { val pagerState = rememberPagerState { 2 } - val privateItemTypeLabel = - when (bookmarkType) { - BookmarkType.ArticleBookmark -> "Private Articles" - BookmarkType.HashtagBookmark -> "Private Hashtags" - BookmarkType.LinkBookmark -> "Private Links" - BookmarkType.PostBookmark -> "Private Posts" - } - - val publicItemTypeLabel = - when (bookmarkType) { - BookmarkType.ArticleBookmark -> "Public Articles" - BookmarkType.HashtagBookmark -> "Public Hashtags" - BookmarkType.LinkBookmark -> "Public Links" - BookmarkType.PostBookmark -> "Public Posts" - } Scaffold( topBar = { Column { @@ -148,24 +138,11 @@ fun BookmarkGroupScreenView( containerColor = MaterialTheme.colorScheme.surface, ), ) - TabRow( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - ) { - val scope = rememberCoroutineScope() - Tab( - selected = pagerState.currentPage == 0, - onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(text = publicItemTypeLabel) }, - ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(text = privateItemTypeLabel) }, - ) - } + BookmarkGroupHeaderTabs( + bookmarkGroupViewModel, + bookmarkType, + pagerState, + ) } }, ) { padding -> @@ -185,6 +162,15 @@ fun BookmarkGroupScreenView( bookmarkGroupViewModel, pagerState, accountViewModel, + deletePostBookmark = { postId, isPrivate -> + accountViewModel.launchSigner { + bookmarkGroupViewModel.removePostBookmark( + bookmarkGroupViewModel.bookmarkGroupIdentifier, + postId, + isPrivate, + ) + } + }, nav, ) BookmarkType.ArticleBookmark -> @@ -192,6 +178,15 @@ fun BookmarkGroupScreenView( bookmarkGroupViewModel, pagerState, accountViewModel, + deleteArticleBookmark = { articleAddress, isPrivate -> + accountViewModel.launchSigner { + bookmarkGroupViewModel.removeArticleBookmark( + bookmarkGroupViewModel.bookmarkGroupIdentifier, + articleAddress, + isPrivate, + ) + } + }, nav, ) BookmarkType.HashtagBookmark -> RenderHashtagList(bookmarkGroupViewModel, pagerState) @@ -226,6 +221,64 @@ private fun TitleAndDescription(viewModel: BookmarkGroupViewModel) { } } +@Composable +fun BookmarkGroupHeaderTabs( + bookmarkGroupViewModel: BookmarkGroupViewModel, + bookmarkType: BookmarkType, + pagerState: PagerState, +) { + val bookmarkGroup by bookmarkGroupViewModel.selectedBookmarkGroupFlow.collectAsStateWithLifecycle() + val privateItemTypeLabel = + when (bookmarkType) { + BookmarkType.PostBookmark -> + bookmarkGroup?.let { + stringRes(R.string.private_posts_count, it.privatePostBookmarks.size) + } ?: stringRes(R.string.private_posts_label) + BookmarkType.ArticleBookmark -> + bookmarkGroup?.let { + stringRes(R.string.private_articles_count, it.privateArticleBookmarks.size) + } ?: stringRes(R.string.private_posts_label) + + // TODO: Match the implementations in the pair below to the pair above. + BookmarkType.HashtagBookmark -> stringRes(R.string.private_hashtags_label) + BookmarkType.LinkBookmark -> stringRes(R.string.private_links_label) + } + + val publicItemTypeLabel = + when (bookmarkType) { + BookmarkType.PostBookmark -> + bookmarkGroup?.let { + stringRes(R.string.public_posts_count, it.publicPostBookmarks.size) + } ?: stringRes(R.string.public_posts_label) + BookmarkType.ArticleBookmark -> + bookmarkGroup?.let { + stringRes(R.string.public_articles_count, it.publicArticleBookmarks.size) + } ?: stringRes(R.string.public_articles_label) + // TODO: Match the implementations in the pair below to the pair above. + BookmarkType.HashtagBookmark -> stringRes(R.string.public_hashtags_label) + BookmarkType.LinkBookmark -> stringRes(R.string.public_links_label) + } + + TabRow( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + ) { + val scope = rememberCoroutineScope() + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(text = publicItemTypeLabel) }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(text = privateItemTypeLabel) }, + ) + } +} + @Composable fun BookmarkGroupActionsMenuButton( onBroadcastList: () -> Unit, @@ -270,7 +323,7 @@ fun BookmarkGroupActionsMenu( ) { DropdownMenuItem( text = { - Text("Broadcast Bookmark Group") + Text(stringRes(R.string.bookmark_list_broadcast_btn_label)) }, onClick = { onBroadcastList() @@ -280,7 +333,7 @@ fun BookmarkGroupActionsMenu( HorizontalDivider(thickness = DividerThickness) DropdownMenuItem( text = { - Text("Delete Bookmark Group") + Text(stringRes(R.string.bookmark_list_delete_btn_label)) }, onClick = { onDeleteList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt index 91a58b46a..67c8cb6cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt @@ -75,7 +75,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @Composable fun BookmarkGroupItem( @@ -138,15 +137,16 @@ fun BookmarkGroupItem( ) { Icon( imageVector = Icons.Outlined.CollectionsBookmark, - contentDescription = "Icon for bookmark group", + contentDescription = stringRes(R.string.bookmark_list_icon_label), modifier = Size40Modifier, ) - Spacer(StdVertSpacer) - BookmarkMembershipStatusAndNumberDisplay( - modifier = Modifier.align(Alignment.CenterHorizontally), - privateBookmarksSize = bookmarkList.privateBookmarks.size, - publicBookmarksSize = bookmarkList.publicBookmarks.size, - ) + // TODO: Fix the component below, with per-category stats +// Spacer(StdVertSpacer) +// BookmarkMembershipStatusAndNumberDisplay( +// modifier = Modifier.align(Alignment.CenterHorizontally), +// privateBookmarksSize = bookmarkList.privateBookmarks.size, +// publicBookmarksSize = bookmarkList.publicBookmarks.size, +// ) } }, ) @@ -184,7 +184,7 @@ private fun BookmarkGroupActions( painter = painterResource(R.drawable.post), contentDescription = null, ) - Text("View Posts") + Text(stringRes(R.string.bookmark_list_posts_btn_label)) } FilledTonalButton( onClick = openArticleBookmarks, @@ -193,7 +193,7 @@ private fun BookmarkGroupActions( imageVector = Icons.AutoMirrored.Outlined.Article, contentDescription = null, ) - Text("View Articles") + Text(stringRes(R.string.bookmark_list_articles_btn_label)) } FilledTonalButton( onClick = openLinkBookmarks, @@ -202,7 +202,7 @@ private fun BookmarkGroupActions( imageVector = Icons.Outlined.Link, contentDescription = null, ) - Text("View Links") + Text(stringRes(R.string.bookmark_list_links_btn_label)) } FilledTonalButton( onClick = openHashtagBookmarks, @@ -211,7 +211,7 @@ private fun BookmarkGroupActions( imageVector = Icons.Outlined.Numbers, contentDescription = null, ) - Text("View Hashtags") + Text(stringRes(R.string.bookmark_list_hashtags_btn_label)) } } } @@ -542,7 +542,7 @@ private fun GroupCloneDialog( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "Clone Bookmark Group", + text = stringRes(R.string.bookmark_list_clone_btn_label), ) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index 2621d06d7..bffb9e4ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -57,7 +57,7 @@ fun ListOfBookmarkGroupsFeedView( val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle() if (bookmarkGroupFeedState.isEmpty()) { - BookmarkGroupsFeedEmpty(message = "You do not have any bookmark groups yet. Tap the new button below to make one.") + BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) } else { LazyColumn( state = rememberLazyListState(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index 131a860e0..2bac37b44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -95,7 +95,7 @@ fun ListOfBookmarkGroupsScreen( deleteBookmarkGroup = { bookmarkGroup -> accountViewModel.launchSigner { accountViewModel.account.labeledBookmarkLists.deleteBookmarkList( - bookmarkList = bookmarkGroup, + bookmarkListIdentifier = bookmarkGroup.identifier, account = accountViewModel.account, ) } @@ -117,7 +117,7 @@ fun ListOfBookmarkGroupsFeed( ) { Scaffold( topBar = { - TopBarWithBackButton(caption = "Bookmark Groups", nav::popBack) + TopBarWithBackButton(caption = stringRes(R.string.bookmark_lists), nav::popBack) }, floatingActionButton = { BookmarkGroupFabAndMenu(onAddGroup = addBookmarkGroup) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 24b4312f1..7df7eae60 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -387,7 +387,32 @@ Remove from Private Bookmarks Remove from Public Bookmarks - Bookmark Groups + Bookmark Lists + Icon for bookmark list + Clone Bookmark List + Broadcast Bookmark List + Delete Bookmark List + View Posts + View Articles + View Links + View Hashtags + You do not have any bookmark lists yet. Tap the new button below to make one. + Private Posts + Private Posts(%1$s) + Public Posts + Public Posts(%1$s) + Private Articles + Private Articles(%1$s) + Public Articles + Public Articles(%1$s) + Private Hashtags + Private Hashtags(%1$s) + Public Hashtags + Public Hashtags(%1$s) + Private Links + Private Links(%1$s) + Public Links + Public Links(%1$s) Wallet Connect Service Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible From 6fc5084931d65e4ef1e567f9ed8497a4ca808ee6 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Wed, 26 Nov 2025 19:47:42 +0100 Subject: [PATCH 15/23] Remove HashtagListView and LinksListView due to spec changes, with their entries in BookmarkType. --- .../loggedIn/bookmarkgroups/BookmarkType.kt | 4 -- .../bookmarkgroups/display/HashtagListView.kt | 59 ------------------- .../bookmarkgroups/display/LinksListView.kt | 59 ------------------- 3 files changed, 122 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/HashtagListView.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/LinksListView.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt index 7b988f9fd..2631e84f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt @@ -24,8 +24,4 @@ enum class BookmarkType { PostBookmark, ArticleBookmark, - - LinkBookmark, - - HashtagBookmark, } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/HashtagListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/HashtagListView.kt deleted file mode 100644 index 7f3f922ff..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/HashtagListView.kt +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier - -@Composable -fun RenderHashtagList( - bookmarkGroupViewModel: BookmarkGroupViewModel, - pagerState: PagerState, - modifier: Modifier = Modifier, -) { - HorizontalPager(pagerState, modifier) { page -> - when (page) { - 0 -> HashtagsList(isPrivate = true) - 1 -> HashtagsList(isPrivate = false) - } - } -} - -@Composable -private fun HashtagsList( - modifier: Modifier = Modifier, - // list: List, - isPrivate: Boolean, -) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("To be implemented") - // Text("Number of ${if (isPrivate) "private" else "public"} hashtags: ${list.size}") - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/LinksListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/LinksListView.kt deleted file mode 100644 index 13ae5e64e..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/LinksListView.kt +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier - -// TODO: Come back to work on this -@Composable -fun RenderLinksList( - bookmarkGroupViewModel: BookmarkGroupViewModel, - pagerState: PagerState, - modifier: Modifier = Modifier, -) { - HorizontalPager(pagerState, modifier) { page -> - when (page) { - 0 -> LinksList(isPrivate = true) - 1 -> LinksList(isPrivate = false) - } - } -} - -@Composable -private fun LinksList( - // list: List, - isPrivate: Boolean, -) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("To be implemented") - // Text("Number of ${if (isPrivate) "private" else "public"} links: ${list.size}") - } -} From b00eb413fa39f079f99bba520f3719ba9ad299e8 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Wed, 26 Nov 2025 19:49:16 +0100 Subject: [PATCH 16/23] Other Hashtag/Link related refactors. --- .../display/BookmarkGroupScreen.kt | 9 ------- .../bookmarkgroups/list/BookmarkGroupItem.kt | 24 +------------------ amethyst/src/main/res/values/strings.xml | 8 ------- 3 files changed, 1 insertion(+), 40 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 7fb6aba8f..e06eaea47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -189,8 +189,6 @@ fun BookmarkGroupScreenView( }, nav, ) - BookmarkType.HashtagBookmark -> RenderHashtagList(bookmarkGroupViewModel, pagerState) - BookmarkType.LinkBookmark -> RenderLinksList(bookmarkGroupViewModel, pagerState) } } } @@ -238,10 +236,6 @@ fun BookmarkGroupHeaderTabs( bookmarkGroup?.let { stringRes(R.string.private_articles_count, it.privateArticleBookmarks.size) } ?: stringRes(R.string.private_posts_label) - - // TODO: Match the implementations in the pair below to the pair above. - BookmarkType.HashtagBookmark -> stringRes(R.string.private_hashtags_label) - BookmarkType.LinkBookmark -> stringRes(R.string.private_links_label) } val publicItemTypeLabel = @@ -254,9 +248,6 @@ fun BookmarkGroupHeaderTabs( bookmarkGroup?.let { stringRes(R.string.public_articles_count, it.publicArticleBookmarks.size) } ?: stringRes(R.string.public_articles_label) - // TODO: Match the implementations in the pair below to the pair above. - BookmarkType.HashtagBookmark -> stringRes(R.string.public_hashtags_label) - BookmarkType.LinkBookmark -> stringRes(R.string.public_links_label) } TabRow( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt index 67c8cb6cb..6f2916cb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt @@ -32,9 +32,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.outlined.CollectionsBookmark -import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.Numbers import androidx.compose.material.icons.outlined.Public import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -166,8 +164,6 @@ private fun BookmarkGroupActions( modifier: Modifier = Modifier, openPostBookmarks: () -> Unit = {}, openArticleBookmarks: () -> Unit = {}, - openLinkBookmarks: () -> Unit = {}, - openHashtagBookmarks: () -> Unit = {}, ) { FlowRow( modifier = modifier, @@ -195,24 +191,6 @@ private fun BookmarkGroupActions( ) Text(stringRes(R.string.bookmark_list_articles_btn_label)) } - FilledTonalButton( - onClick = openLinkBookmarks, - ) { - Icon( - imageVector = Icons.Outlined.Link, - contentDescription = null, - ) - Text(stringRes(R.string.bookmark_list_links_btn_label)) - } - FilledTonalButton( - onClick = openHashtagBookmarks, - ) { - Icon( - imageVector = Icons.Outlined.Numbers, - contentDescription = null, - ) - Text(stringRes(R.string.bookmark_list_hashtags_btn_label)) - } } } @@ -394,7 +372,7 @@ private fun GroupOptionsMenu( optionalCloneDescription.value = it }, onCloneCreate = { name, description -> - onGroupClone(optionalCloneName.value, optionalCloneDescription.value) + onGroupClone(name, description) }, onDismiss = { isCopyDialogOpen.value = false }, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index beae57c2c..8e1d4542e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -406,14 +406,6 @@ Private Articles(%1$s) Public Articles Public Articles(%1$s) - Private Hashtags - Private Hashtags(%1$s) - Public Hashtags - Public Hashtags(%1$s) - Private Links - Private Links(%1$s) - Public Links - Public Links(%1$s) Wallet Connect Service Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible From e8f55c64568a8db3a5b58f2d70ca3de9b295c3aa Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Wed, 26 Nov 2025 22:21:10 +0100 Subject: [PATCH 17/23] Post Bookmark management screen. --- .../BookmarkGroupManagementItem.kt | 214 ++++++++++++++++++ .../PostBookmarkListManagementScreen.kt | 139 ++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt new file mode 100644 index 000000000..5c07cd9ee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt @@ -0,0 +1,214 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BookmarkAdd +import androidx.compose.material.icons.filled.BookmarkRemove +import androidx.compose.material.icons.outlined.CollectionsBookmark +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.RemoveCircleOutline +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp + +@Composable +fun BookmarkGroupManagementItem( + modifier: Modifier = Modifier, + listTitle: String, + isPrivateMemberBookmark: Boolean, + isPublicMemberBookmark: Boolean, + onClick: () -> Unit, + onAddBookmarkToGroup: (shouldBookmarkBePrivate: Boolean) -> Unit, + onRemoveBookmarkFromGroup: () -> Unit, +) { + ListItem( + modifier = modifier.clickable(onClick = onClick), + headlineContent = { + Text( + text = listTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + BookmarkStatusInList(isPublicMemberBookmark, isPrivateMemberBookmark) + }, + leadingContent = { + Box( + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.CollectionsBookmark, + contentDescription = stringRes(R.string.bookmark_list_icon_label), + modifier = Size50Modifier, + ) + } + }, + trailingContent = { + val isBookmarkInGroup = isPrivateMemberBookmark || isPublicMemberBookmark + BookmarkManagementOptions( + isBookmarkInList = isBookmarkInGroup, + onAddBookmark = onAddBookmarkToGroup, + onRemoveBookmark = onRemoveBookmarkFromGroup, + ) + }, + ) +} + +@Composable +fun BookmarkStatusInList( + isPublicMemberBookmark: Boolean, + isPrivateMemberBookmark: Boolean, +) { + Row( + modifier = HalfHalfVertPadding, + horizontalArrangement = SpacedBy5dp, + verticalAlignment = Alignment.CenterVertically, + ) { + val text = + if (isPublicMemberBookmark) { + "is a public bookmark here" + } else if (isPrivateMemberBookmark) { + "is a private bookmark here" + } else { + "is not a bookmark here" + } + + val icon = + if (isPublicMemberBookmark) { + Icons.Outlined.Public + } else if (isPrivateMemberBookmark) { + Icons.Outlined.Lock + } else { + Icons.Outlined.RemoveCircleOutline + } + + Icon( + imageVector = icon, + contentDescription = text, + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = text, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + ) + } +} + +@Composable +fun BookmarkManagementOptions( + isBookmarkInList: Boolean, + onAddBookmark: (shouldBePrivate: Boolean) -> Unit, + onRemoveBookmark: () -> Unit, +) { + val isBookmarkAddTapped = remember { mutableStateOf(false) } + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IconButton( + onClick = { + if (isBookmarkInList) { + onRemoveBookmark() + } else { + isBookmarkAddTapped.value = true + } + }, + modifier = + Modifier + .background( + color = + if (isBookmarkInList) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primary + }, + shape = RoundedCornerShape(percent = 80), + ), + ) { + if (isBookmarkInList) { + Icon( + imageVector = Icons.Filled.BookmarkRemove, + contentDescription = "Remove bookmark from list", + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } else { + Icon( + imageVector = Icons.Filled.BookmarkAdd, + contentDescription = "Add bookmark to list", + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + + DropdownMenu( + expanded = isBookmarkAddTapped.value, + onDismissRequest = { isBookmarkAddTapped.value = false }, + ) { + DropdownMenuItem( + text = { + Text(text = "Add as public bookmark") + }, + onClick = { + onAddBookmark(false) + isBookmarkAddTapped.value = false + }, + ) + DropdownMenuItem( + text = { + Text(text = "Add as private bookmark") + }, + onClick = { + onAddBookmark(true) + isBookmarkAddTapped.value = false + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt new file mode 100644 index 000000000..e7c9c6da5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.recalculateWindowInsets +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkGroupsFeedEmpty +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark + +@Composable +fun PostBookmarkListManagementScreen( + postId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadNote(baseNoteHex = postId, accountViewModel = accountViewModel) { + it?.let { + ListManagementView( + modifier = Modifier.fillMaxSize().recalculateWindowInsets(), + note = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun ListManagementView( + modifier: Modifier = Modifier, + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow + .collectAsStateWithLifecycle() + Scaffold( + modifier = modifier, + topBar = { + TopBarWithBackButton(caption = "Bookmark this post", nav::popBack) + }, + floatingActionButton = { + NewListButton { } + }, + ) { contentPadding -> + Column( + modifier = + Modifier + .padding( + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + ).consumeWindowInsets(contentPadding) + .imePadding(), + ) { + if (bookmarkGroups.isEmpty()) { + BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) + } else { + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxWidth(), + ) { + itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> + val maybePublicBookmark = bookmarkList.publicPostBookmarks.firstOrNull { it.eventId == note.idHex } + val maybePrivateBookmark = bookmarkList.privatePostBookmarks.firstOrNull { it.eventId == note.idHex } + BookmarkGroupManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + listTitle = bookmarkList.title, + isPublicMemberBookmark = maybePublicBookmark != null, + isPrivateMemberBookmark = maybePrivateBookmark != null, + onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.PostBookmark)) }, + onAddBookmarkToGroup = { shouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.addBookmarkToList( + bookmark = EventBookmark(eventId = note.idHex, relay = note.relayHintUrl(), author = note.author?.pubkeyHex), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = shouldBePrivate, + account = accountViewModel.account, + ) + } + }, + onRemoveBookmarkFromGroup = { + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( + bookmark = EventBookmark(eventId = note.idHex), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = maybePrivateBookmark != null, + account = accountViewModel.account, + ) + } + }, + ) + } + } + } + } + } +} From 5742968f4c9c5a42740bcfca6de9631df13bbd57 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Wed, 26 Nov 2025 22:22:50 +0100 Subject: [PATCH 18/23] Article Bookmark management screen. Add routes and nav entries for both screens. --- .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../amethyst/ui/navigation/routes/Routes.kt | 17 +++ .../ArticleBookmarkListManagementScreen.kt | 140 ++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 802c81aa2..a382d4d73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawe import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen @@ -143,6 +145,8 @@ fun AppNavigation( composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } composableFromEndArgs { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } + composableFromBottomArgs { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } + composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromBottomArgs { ShowQRScreen(it.pubkey, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index ac100adc4..ecf3d700a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.navigation.routes import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.toRoute +import com.vitorpamplona.amethyst.ui.navigation.routes.Route.Community import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -55,6 +56,22 @@ sealed class Route { val bookmarkType: BookmarkType, ) : Route() + @Serializable data class PostBookmarkManagement( + val postId: String, + ) : Route() + + @Serializable data class ArticleBookmarkManagement( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + @Serializable object Drafts : Route() @Serializable object Settings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt new file mode 100644 index 000000000..a6b992a7c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.recalculateWindowInsets +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkGroupsFeedEmpty +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +@Composable +fun ArticleBookmarkListManagementScreen( + articleAddress: Address, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadAddressableNote(address = articleAddress, accountViewModel = accountViewModel) { + it?.let { + ListManagementView( + modifier = Modifier.fillMaxSize().recalculateWindowInsets(), + note = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun ListManagementView( + modifier: Modifier = Modifier, + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow + .collectAsStateWithLifecycle() + Scaffold( + modifier = modifier, + topBar = { + TopBarWithBackButton(caption = "Bookmark this article", nav::popBack) + }, + floatingActionButton = { + NewListButton { } + }, + ) { contentPadding -> + Column( + modifier = + Modifier + .padding( + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + ).consumeWindowInsets(contentPadding) + .imePadding(), + ) { + if (bookmarkGroups.isEmpty()) { + BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) + } else { + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxWidth(), + ) { + itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> + val maybePublicBookmark = bookmarkList.publicArticleBookmarks.firstOrNull { it.address == note.address } + val maybePrivateBookmark = bookmarkList.privateArticleBookmarks.firstOrNull { it.address == note.address } + BookmarkGroupManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + listTitle = bookmarkList.title, + isPublicMemberBookmark = maybePublicBookmark != null, + isPrivateMemberBookmark = maybePrivateBookmark != null, + onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.ArticleBookmark)) }, + onAddBookmarkToGroup = { shouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.addBookmarkToList( + bookmark = AddressBookmark(address = note.address, relayHint = note.relayHintUrl()), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = shouldBePrivate, + account = accountViewModel.account, + ) + } + }, + onRemoveBookmarkFromGroup = { + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( + bookmark = AddressBookmark(address = note.address), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = maybePrivateBookmark != null, + account = accountViewModel.account, + ) + } + }, + ) + } + } + } + } + } +} From a0bff70dc2a195454e68d39b7c7b24b8b23bd8a4 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Wed, 26 Nov 2025 22:25:59 +0100 Subject: [PATCH 19/23] Add bookmark list addition/removal to the note dropdown menu. --- .../amethyst/ui/note/elements/DropDownMenu.kt | 17 +++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 ++ 2 files changed, 19 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 98043a7b8..a15f4c944 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBookmarks import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows @@ -58,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -297,6 +299,21 @@ fun NoteDropDownMenu( ) } HorizontalDivider(thickness = DividerThickness) + note.let { + val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) + DropdownMenuItem( + text = { Text(stringRes(R.string.manage_bookmark_label, noteBookmarkType)) }, + onClick = { + if (note.event is LongTextNoteEvent) { + val noteAddress = (note as AddressableNote).address + nav.nav(Route.ArticleBookmarkManagement(noteAddress)) + } else { + nav.nav(Route.PostBookmarkManagement(note.idHex)) + } + onDismiss() + }, + ) + } if (state.isPrivateBookmarkNote) { DropdownMenuItem( text = { Text(stringRes(R.string.remove_from_private_bookmarks)) }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8e1d4542e..72d08e497 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -112,6 +112,7 @@ "Error parsing preview for %1$s : %2$s" "Preview Card Image for %1$s" + Article New Channel Channel Name My Awesome Group @@ -406,6 +407,7 @@ Private Articles(%1$s) Public Articles Public Articles(%1$s) + Manage %1$s in Bookmark Lists Wallet Connect Service Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible From 07f82d3a229897e3d38c78b6fdcec9a03a01e29c Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Wed, 26 Nov 2025 22:44:16 +0100 Subject: [PATCH 20/23] String resources. Remove unneeded Todo. --- .../bookmarkgroups/display/ArticleListView.kt | 1 - .../display/BookmarkGroupItemOptions.kt | 2 +- .../ArticleBookmarkListManagementScreen.kt | 2 +- .../BookmarkGroupManagementItem.kt | 14 +++++++------- .../PostBookmarkListManagementScreen.kt | 2 +- amethyst/src/main/res/values/strings.xml | 10 ++++++++++ 6 files changed, 20 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt index 1f4f595d5..11317e928 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt @@ -92,7 +92,6 @@ fun ArticleList( state = listState, ) { itemsIndexed(articles, key = { _, item -> item.toNAddr() }) { _, item -> - // TODO: Find a way to integrate bookmark group callbacks into the note below NoteCompose( baseNote = item, modifier = Modifier.animateContentSize(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 3ed9f76cd..e734d78d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -150,7 +150,7 @@ fun BookmarkGroupItemOptionsMenu( val scope = rememberCoroutineScope() DropdownMenuItem( - text = { Text("Remove from Bookmark List") }, + text = { Text(stringRes(R.string.bookmark_remove_action_label)) }, onClick = { onDeleteBookmarkItem() onDismiss() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt index a6b992a7c..d47880432 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt @@ -80,7 +80,7 @@ private fun ListManagementView( Scaffold( modifier = modifier, topBar = { - TopBarWithBackButton(caption = "Bookmark this article", nav::popBack) + TopBarWithBackButton(caption = stringRes(R.string.article_bookmark_management_title), nav::popBack) }, floatingActionButton = { NewListButton { } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt index 5c07cd9ee..ae084ac4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt @@ -110,11 +110,11 @@ fun BookmarkStatusInList( ) { val text = if (isPublicMemberBookmark) { - "is a public bookmark here" + stringRes(R.string.public_bookmark_presence_indicator) } else if (isPrivateMemberBookmark) { - "is a private bookmark here" + stringRes(R.string.private_bookmark_presence_indicator) } else { - "is not a bookmark here" + stringRes(R.string.bookmark_absence_indicator) } val icon = @@ -175,13 +175,13 @@ fun BookmarkManagementOptions( if (isBookmarkInList) { Icon( imageVector = Icons.Filled.BookmarkRemove, - contentDescription = "Remove bookmark from list", + contentDescription = stringRes(R.string.bookmark_remove_action_desc), tint = MaterialTheme.colorScheme.onErrorContainer, ) } else { Icon( imageVector = Icons.Filled.BookmarkAdd, - contentDescription = "Add bookmark to list", + contentDescription = stringRes(R.string.bookmark_add_action_desc), tint = MaterialTheme.colorScheme.onPrimary, ) } @@ -193,7 +193,7 @@ fun BookmarkManagementOptions( ) { DropdownMenuItem( text = { - Text(text = "Add as public bookmark") + Text(text = stringRes(R.string.public_bookmark_add_action_label)) }, onClick = { onAddBookmark(false) @@ -202,7 +202,7 @@ fun BookmarkManagementOptions( ) DropdownMenuItem( text = { - Text(text = "Add as private bookmark") + Text(text = stringRes(R.string.private_bookmark_add_action_label)) }, onClick = { onAddBookmark(true) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt index e7c9c6da5..04d0be2c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt @@ -79,7 +79,7 @@ private fun ListManagementView( Scaffold( modifier = modifier, topBar = { - TopBarWithBackButton(caption = "Bookmark this post", nav::popBack) + TopBarWithBackButton(caption = stringRes(R.string.post_bookmark_management_title), nav::popBack) }, floatingActionButton = { NewListButton { } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 72d08e497..6282089f3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -408,6 +408,16 @@ Public Articles Public Articles(%1$s) Manage %1$s in Bookmark Lists + Bookmark this post + Bookmark this article + is a public bookmark here + is a private bookmark here + is not a bookmark here + Remove bookmark from list + Add bookmark to list + Add as public bookmark + Add as private bookmark + Remove from Bookmark List Wallet Connect Service Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible From 0f21c7293679de494c4bd2461d4aefee5604c97c Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Thu, 27 Nov 2025 07:08:08 +0100 Subject: [PATCH 21/23] Bookmark group metadata edit screen, with VM and string resources. Add methods to LabeledBookmarkListEvent and LabeledBookmarkListsState. Support image urls in LabeledBookmarkList. --- .../LabeledBookmarkList.kt | 3 +- .../LabeledBookmarkListsState.kt | 30 +++ .../metadata/BookmarkGroupMetadataScreen.kt | 247 ++++++++++++++++++ .../BookmarkGroupMetadataViewModel.kt | 188 +++++++++++++ amethyst/src/main/res/values/strings.xml | 4 + .../LabeledBookmarkListEvent.kt | 7 +- .../labeledBookmarkList/TagArrayBuilderExt.kt | 6 + 7 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt index ac33fb0dd..74333236f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt @@ -30,11 +30,10 @@ data class LabeledBookmarkList( val identifier: String, val title: String, val description: String?, + val image: String?, val privateBookmarks: Set = emptySet(), val publicBookmarks: Set = emptySet(), ) { - // TODO: Add methods for LInk and Hashtag, after their implementation. - val privatePostBookmarks = privateBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark } val publicPostBookmarks = publicBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt index 6d980d63a..e753fc751 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -30,9 +30,13 @@ import com.vitorpamplona.amethyst.model.events import com.vitorpamplona.amethyst.model.filter import com.vitorpamplona.amethyst.model.updateFlow import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.name import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -85,6 +89,7 @@ class LabeledBookmarkListsState( identifier = dTag(), title = nameOrTitle() ?: dTag(), description = description(), + image = image(), privateBookmarks = privateBookmarks(signer)?.toSet() ?: emptySet(), publicBookmarks = publicBookmarks().toSet(), ) @@ -103,6 +108,8 @@ class LabeledBookmarkListsState( it.identifier == bookmarkListId } + fun getBookmarkList(dTag: String) = listFeedFlow.value.getList(bookmarkListId = dTag) + fun DeletionEvent.hasAnyDeletedBookmarkLists() = deleteAddressesWithKind(LabeledBookmarkListEvent.KIND) || deletesAnyEventIn(labeledBookmarkListEventIds.value) fun hasItemInNoteList(notes: Set): Boolean = @@ -146,6 +153,7 @@ class LabeledBookmarkListsState( suspend fun addLabeledBookmarkList( listName: String, listDescription: String? = null, + listImage: String? = null, firstBookmark: BookmarkIdTag? = null, isBookmarkPrivate: Boolean = false, account: Account, @@ -154,6 +162,7 @@ class LabeledBookmarkListsState( LabeledBookmarkListEvent.create( name = listName, description = listDescription, + image = listImage, publicBookmarks = if (!isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(), privateBookmarks = if (isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(), signer = account.signer, @@ -161,6 +170,27 @@ class LabeledBookmarkListsState( account.sendMyPublicAndPrivateOutbox(newList) } + suspend fun updateMetadata( + listName: String?, + listDescription: String?, + listImage: String?, + bookmarkList: LabeledBookmarkList, + account: Account, + ) { + val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier) + + val template = + listEvent.update { + if (listName != null) name(listName) + if (listDescription != null) description(listDescription) + if (listImage != null) image(listImage) + } + + val newList = signer.sign(template) + + account.sendMyPublicAndPrivateOutbox(newList) + } + suspend fun renameBookmarkList( newName: String, bookmarkList: LabeledBookmarkList, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt new file mode 100644 index 000000000..7025a70ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt @@ -0,0 +1,247 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions + +@Composable +fun BookmarkGroupMetadataScreen( + bookmarkGroupIdentifier: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + val bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel = viewModel() + bookmarkGroupInfoViewModel.init(accountViewModel) + + if (bookmarkGroupIdentifier != null) { + LaunchedEffect(bookmarkGroupInfoViewModel) { + bookmarkGroupInfoViewModel.load(bookmarkGroupIdentifier) + } + } else { + LaunchedEffect(bookmarkGroupInfoViewModel) { + bookmarkGroupInfoViewModel.new() + } + } + + BookmarkGroupMetadataScaffold(bookmarkGroupInfoViewModel, accountViewModel, nav) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BookmarkGroupMetadataScaffold( + bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + BookmarkGroupMetadataTopBar( + bookmarkGroupInfoViewModel = bookmarkGroupInfoViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) { pad -> + LazyColumn( + Modifier + .fillMaxSize() + .padding( + start = 10.dp, + end = 10.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad) + .imePadding(), + ) { + item { + SettingsCategory( + R.string.bookmark_list_edit_sub_title, + R.string.bookmark_list_explainer, + SettingsCategoryFirstModifier, + ) + + ListName(bookmarkGroupInfoViewModel) + + Spacer(modifier = DoubleVertSpacer) + + Picture(bookmarkGroupInfoViewModel, accountViewModel) + + Spacer(modifier = DoubleVertSpacer) + + Description(bookmarkGroupInfoViewModel) + } + } + } +} + +@Composable +fun BookmarkGroupMetadataTopBar( + bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (bookmarkGroupInfoViewModel.isNewList) { + CreatingTopBar( + titleRes = R.string.bookmark_list_creation_screen_title, + isActive = bookmarkGroupInfoViewModel::canPost, + onCancel = { + bookmarkGroupInfoViewModel.clear() + nav.popBack() + }, + onPost = { + try { + bookmarkGroupInfoViewModel.createOrUpdate() + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } else { + SavingTopBar( + titleRes = R.string.follow_set_edit_list_metadata, + isActive = bookmarkGroupInfoViewModel::canPost, + onCancel = { + bookmarkGroupInfoViewModel.clear() + nav.popBack() + }, + onPost = { + try { + bookmarkGroupInfoViewModel.createOrUpdate() + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } +} + +@Composable +private fun Description(bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.follow_set_creation_desc_label)) }, + modifier = Modifier.fillMaxWidth(), + value = bookmarkGroupInfoViewModel.description.value, + onValueChange = { bookmarkGroupInfoViewModel.description.value = it }, + placeholder = { + Text( + text = stringRes(R.string.about_us), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + minLines = 3, + ) +} + +@Composable +private fun Picture( + bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel, + accountViewModel: AccountViewModel, +) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.optional_picture_url)) }, + modifier = Modifier.fillMaxWidth(), + value = bookmarkGroupInfoViewModel.picture.value, + onValueChange = { bookmarkGroupInfoViewModel.picture.value = it }, + placeholder = { + Text( + text = "http://mygroup.com/logo.jpg", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { + val context = LocalContext.current + SelectSingleFromGallery( + isUploading = bookmarkGroupInfoViewModel.isUploadingImageForPicture, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 2.dp), + ) { + bookmarkGroupInfoViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast) + } + }, + ) +} + +@Composable +private fun ListName(bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.follow_set_creation_name_label)) }, + modifier = Modifier.fillMaxWidth(), + value = bookmarkGroupInfoViewModel.name.value, + onValueChange = { bookmarkGroupInfoViewModel.name.value = it }, + placeholder = { + Text( + text = stringRes(R.string.follow_set_copy_name_label), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt new file mode 100644 index 000000000..9a345e783 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +@Stable +class BookmarkGroupMetadataViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel + private lateinit var account: Account + + var bookmarkGroup by mutableStateOf(null) + val isNewList by derivedStateOf { bookmarkGroup == null } + + val name = mutableStateOf(TextFieldValue()) + val picture = mutableStateOf(TextFieldValue()) + val description = mutableStateOf(TextFieldValue()) + + var isUploadingImageForPicture by mutableStateOf(false) + + val canPost by derivedStateOf { + name.value.text.isNotBlank() + } + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun new() { + bookmarkGroup = null + clear() + } + + fun load(dTag: String) { + bookmarkGroup = account.labeledBookmarkLists.getBookmarkList(dTag) + name.value = TextFieldValue(bookmarkGroup?.title ?: "") + picture.value = TextFieldValue(bookmarkGroup?.image ?: "") + description.value = TextFieldValue(bookmarkGroup?.description ?: "") + } + + fun isNewChannel() = bookmarkGroup == null + + fun createOrUpdate() { + accountViewModel.launchSigner { + val bookmarkGroup = bookmarkGroup + if (bookmarkGroup == null) { + accountViewModel.account.labeledBookmarkLists.addLabeledBookmarkList( + listName = name.value.text, + listDescription = description.value.text, + listImage = picture.value.text, + account = accountViewModel.account, + ) + } else { + accountViewModel.account.labeledBookmarkLists.updateMetadata( + listName = name.value.text, + listDescription = description.value.text, + listImage = picture.value.text, + bookmarkList = bookmarkGroup, + account = accountViewModel.account, + ) + } + + clear() + } + } + + fun clear() { + name.value = TextFieldValue() + picture.value = TextFieldValue() + description.value = TextFieldValue() + } + + fun uploadForPicture( + uri: SelectedMedia, + context: Context, + onError: (String, String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + context, + onUploading = { isUploadingImageForPicture = it }, + onUploaded = { picture.value = TextFieldValue(it) }, + onError = onError, + ) + } + } + + private suspend fun upload( + galleryUri: SelectedMedia, + context: Context, + onUploading: (Boolean) -> Unit, + onUploaded: (String) -> Unit, + onError: (String, String) -> Unit, + ) { + onUploading(true) + + val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) + + try { + val result = + if (account.settings.defaultFileServer.type == ServerType.NIP96) { + Nip96Uploader().upload( + uri = compResult.uri, + contentType = compResult.contentType, + size = compResult.size, + alt = null, + sensitiveContent = null, + serverBaseUrl = account.settings.defaultFileServer.baseUrl, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + onProgress = {}, + httpAuth = account::createHTTPAuthorization, + context = context, + ) + } else { + BlossomUploader().upload( + uri = compResult.uri, + contentType = compResult.contentType, + size = compResult.size, + alt = null, + sensitiveContent = null, + serverBaseUrl = account.settings.defaultFileServer.baseUrl, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = account::createBlossomUploadAuth, + context = context, + ) + } + + if (result.url != null) { + onUploading(false) + onUploaded(result.url) + } else { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) + } + } catch (_: SignerExceptions.ReadOnlyException) { + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload)) + } catch (e: Exception) { + if (e is CancellationException) throw e + onUploading(false) + onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6282089f3..e09c8d68a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -117,6 +117,7 @@ Channel Name My Awesome Group Picture Url + Picture Url(Optional) Description Description not found "About us.. " @@ -391,6 +392,8 @@ Bookmark Lists Icon for bookmark list + New Bookmark List + Bookmark List Metadata Clone Bookmark List Broadcast Bookmark List Delete Bookmark List @@ -418,6 +421,7 @@ Add as public bookmark Add as private bookmark Remove from Bookmark List + Bookmark lists metadata can be seen by anyone on Nostr. Only your private members are encrypted. Wallet Connect Service Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt index 7fe79140d..73b5684f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip51Lists.replaceAll import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -75,6 +76,8 @@ class LabeledBookmarkListEvent( fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + fun countBookmarks() = tags.count(BookmarkIdTag::isTagged) fun publicBookmarks(): List = tags.mapNotNull(BookmarkIdTag::parse) @@ -261,6 +264,7 @@ class LabeledBookmarkListEvent( suspend fun create( name: String = "", description: String? = null, + image: String? = null, publicBookmarks: List = emptyList(), privateBookmarks: List = emptyList(), dTag: String = Uuid.random().toString(), @@ -269,7 +273,8 @@ class LabeledBookmarkListEvent( ): LabeledBookmarkListEvent { val template = build(name, publicBookmarks, privateBookmarks, signer, dTag, createdAt) { - if (description != null) addUnique(DescriptionTag.assemble(description)) + if (description != null) description(description) + if (image != null) image(image) } return signer.sign(template) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt index f0f91e669..8f42feca5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt @@ -22,8 +22,14 @@ package com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag +import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag import com.vitorpamplona.quartz.nip51Lists.tags.NameTag fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) fun TagArrayBuilder.bookmarks(bookmarks: List) = addAll(bookmarks.map { it.toTagArray() }) + +fun TagArrayBuilder.description(listDescription: String) = addUnique(DescriptionTag.assemble(listDescription)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) From cd0a0405a97e540832acbb2215b296806f60e907 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Thu, 27 Nov 2025 07:19:04 +0100 Subject: [PATCH 22/23] Add Bookmark group metadata edit to routes/nav. Make refactors to make use of it. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../bookmarkgroups/list/BookmarkGroupItem.kt | 169 +----------------- .../list/ListOfBookmarkGroupsFeedView.kt | 8 +- .../list/ListOfBookmarkGroupsScreen.kt | 59 ++---- .../ArticleBookmarkListManagementScreen.kt | 2 +- .../PostBookmarkListManagementScreen.kt | 2 +- 7 files changed, 31 insertions(+), 215 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index a382d4d73..b1557de3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawe import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata.BookmarkGroupMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen @@ -145,6 +146,7 @@ fun AppNavigation( composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } composableFromEndArgs { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } + composableFromBottomArgs { BookmarkGroupMetadataScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index ecf3d700a..a30068dbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -56,6 +56,10 @@ sealed class Route { val bookmarkType: BookmarkType, ) : Route() + @Serializable data class BookmarkGroupMetadataEdit( + val dTag: String? = null, + ) : Route() + @Serializable data class PostBookmarkManagement( val postId: String, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt index 6f2916cb1..787241b0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt @@ -51,12 +51,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R @@ -79,8 +76,8 @@ fun BookmarkGroupItem( modifier: Modifier = Modifier, bookmarkList: LabeledBookmarkList, onClick: (bookmarkItemType: BookmarkType) -> Unit, - onRename: (String) -> Unit, - onDescriptionChange: (String?) -> Unit, + onRename: () -> Unit, + onDescriptionChange: () -> Unit, onClone: (customName: String?, customDescription: String?) -> Unit, onDelete: () -> Unit, ) { @@ -252,8 +249,8 @@ private fun BookmarkGroupOptionsButton( modifier: Modifier = Modifier, bookmarkGroupName: String, bookmarkGroupDescription: String?, - onGroupRename: (String) -> Unit, - onGroupDescriptionChange: (String?) -> Unit, + onGroupRename: () -> Unit, + onGroupDescriptionChange: () -> Unit, onGroupCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit, onGroupDelete: () -> Unit, ) { @@ -283,17 +280,12 @@ private fun GroupOptionsMenu( isExpanded: Boolean, groupName: String, groupDescription: String?, - onGroupRename: (String) -> Unit, - onGroupDescriptionChange: (String?) -> Unit, + onGroupRename: () -> Unit, + onGroupDescriptionChange: () -> Unit, onGroupClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit, ) { - val isRenameDialogOpen = remember { mutableStateOf(false) } - val renameString = remember { mutableStateOf("") } - - val isDescriptionModDialogOpen = remember { mutableStateOf(false) } - val isCopyDialogOpen = remember { mutableStateOf(false) } val optionalCloneName = remember { mutableStateOf(null) } val optionalCloneDescription = remember { mutableStateOf(null) } @@ -307,7 +299,7 @@ private fun GroupOptionsMenu( Text(text = stringRes(R.string.follow_set_rename_btn_label)) }, onClick = { - isRenameDialogOpen.value = true + onGroupRename() onDismiss() }, ) @@ -316,7 +308,7 @@ private fun GroupOptionsMenu( Text(text = stringRes(R.string.follow_set_desc_modify_label)) }, onClick = { - isDescriptionModDialogOpen.value = true + onGroupDescriptionChange() onDismiss() }, ) @@ -339,28 +331,6 @@ private fun GroupOptionsMenu( ) } - if (isRenameDialogOpen.value) { - GroupRenameDialog( - currentName = groupName, - newName = renameString.value, - onStringRenameChange = { - renameString.value = it - }, - onDismissDialog = { isRenameDialogOpen.value = false }, - onGroupRename = { - onGroupRename(renameString.value) - }, - ) - } - - if (isDescriptionModDialogOpen.value) { - GroupModifyDescriptionDialog( - currentDescription = groupDescription, - onDismissDialog = { isDescriptionModDialogOpen.value = false }, - onModifyDescription = onGroupDescriptionChange, - ) - } - if (isCopyDialogOpen.value) { GroupCloneDialog( optionalNewName = optionalCloneName.value, @@ -379,129 +349,6 @@ private fun GroupOptionsMenu( } } -@Composable -private fun GroupRenameDialog( - modifier: Modifier = Modifier, - currentName: String, - newName: String, - onStringRenameChange: (String) -> Unit, - onDismissDialog: () -> Unit, - onGroupRename: (String) -> Unit, -) { - val renameIndicator = - buildAnnotatedString { - append(stringRes(R.string.follow_set_rename_dialog_indicator_first_part) + " ") - withStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - fontStyle = FontStyle.Normal, - fontSize = 15.sp, - ), - ) { - append("\"" + currentName + "\"") - } - append(" " + stringRes(R.string.follow_set_rename_dialog_indicator_second_part)) - } - - AlertDialog( - onDismissRequest = onDismissDialog, - title = { - Text(text = stringRes(R.string.follow_set_rename_btn_label)) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(Size5dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = renameIndicator, - fontSize = 15.sp, - fontWeight = FontWeight.Light, - fontStyle = FontStyle.Italic, - ) - TextField( - value = newName, - onValueChange = onStringRenameChange, - ) - } - }, - confirmButton = { - Button( - onClick = { - onGroupRename(newName) - onDismissDialog() - }, - ) { Text(text = stringRes(R.string.rename)) } - }, - dismissButton = { - Button(onClick = onDismissDialog) { Text(text = stringRes(R.string.cancel)) } - }, - ) -} - -@Composable -private fun GroupModifyDescriptionDialog( - modifier: Modifier = Modifier, - currentDescription: String?, - onDismissDialog: () -> Unit, - onModifyDescription: (String?) -> Unit, -) { - val updatedDescription = remember { mutableStateOf(null) } - - val modifyIndicatorLabel = - if (currentDescription == null) { - stringRes(R.string.follow_set_empty_desc_label) - } else { - buildAnnotatedString { - append(stringRes(R.string.follow_set_current_desc_label) + " ") - withStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - fontStyle = FontStyle.Normal, - fontSize = 15.sp, - ), - ) { - append("\"" + currentDescription + "\"") - } - }.text - } - - AlertDialog( - onDismissRequest = onDismissDialog, - title = { - Text(text = stringRes(R.string.follow_set_desc_modify_label)) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(Size5dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = modifyIndicatorLabel, - fontSize = 15.sp, - fontWeight = FontWeight.Light, - fontStyle = FontStyle.Italic, - ) - TextField( - value = updatedDescription.value ?: "", - onValueChange = { updatedDescription.value = it }, - ) - } - }, - confirmButton = { - Button( - onClick = { - onModifyDescription(updatedDescription.value) - onDismissDialog() - }, - ) { Text(text = stringRes(R.string.follow_set_desc_modify_btn_label)) } - }, - dismissButton = { - Button(onClick = onDismissDialog) { Text(text = stringRes(R.string.cancel)) } - }, - ) -} - @Composable private fun GroupCloneDialog( modifier: Modifier = Modifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index bffb9e4ed..9e603d023 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -49,8 +49,8 @@ import kotlinx.coroutines.flow.StateFlow fun ListOfBookmarkGroupsFeedView( groupListFeedSource: StateFlow>, onOpenItem: (String, BookmarkType) -> Unit, - onRenameItem: (targetBookmarkGroup: LabeledBookmarkList, newName: String) -> Unit, - onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList, newDescription: String?) -> Unit, + onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, + onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, onItemClone: (bookmarkGroup: LabeledBookmarkList, customName: String?, customDesc: String?) -> Unit, onDeleteItem: (bookmarkGroup: LabeledBookmarkList) -> Unit, ) { @@ -71,8 +71,8 @@ fun ListOfBookmarkGroupsFeedView( modifier = Modifier.fillMaxSize().animateItem(), bookmarkList = groupItem, onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) }, - onRename = { onRenameItem(groupItem, it) }, - onDescriptionChange = { onItemDescriptionChange(groupItem, it) }, + onRename = { onRenameItem(groupItem) }, + onDescriptionChange = { onItemDescriptionChange(groupItem) }, onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) }, onDelete = { onDeleteItem(groupItem) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index 2bac37b44..8903b1b48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -32,8 +32,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList @@ -52,35 +50,15 @@ fun ListOfBookmarkGroupsScreen( ) { ListOfBookmarkGroupsFeed( listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, - addBookmarkGroup = { title, description -> - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.addLabeledBookmarkList( - listName = title, - listDescription = description, - account = accountViewModel.account, - ) - } - }, + addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, openBookmarkGroup = { identifier, bookmarkType -> nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) }, - renameBookmarkGroup = { bookmarkGroup, newName -> - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.renameBookmarkList( - newName = newName, - bookmarkList = bookmarkGroup, - account = accountViewModel.account, - ) - } + renameBookmarkGroup = { bookmarkGroup -> + nav.nav(Route.BookmarkGroupMetadataEdit(bookmarkGroup.identifier)) }, - changeBookmarkGroupDescription = { bookmarkGroup, newDescription -> - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.modifyListDescription( - newDescription = newDescription, - bookmarkList = bookmarkGroup, - account = accountViewModel.account, - ) - } + changeBookmarkGroupDescription = { bookmarkGroup -> + nav.nav(Route.BookmarkGroupMetadataEdit(bookmarkGroup.identifier)) }, cloneBookmarkGroup = { bookmarkGroup, customName, customDesc -> accountViewModel.launchSigner { @@ -107,10 +85,10 @@ fun ListOfBookmarkGroupsScreen( @Composable fun ListOfBookmarkGroupsFeed( listSource: StateFlow>, - addBookmarkGroup: (title: String, description: String?) -> Unit, + addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, - renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList, newName: String) -> Unit, - changeBookmarkGroupDescription: (bookmarkGroup: LabeledBookmarkList, newDescription: String?) -> Unit, + renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, + changeBookmarkGroupDescription: (bookmarkGroup: LabeledBookmarkList) -> Unit, cloneBookmarkGroup: (bookmarkGroup: LabeledBookmarkList, customName: String?, customDesc: String?) -> Unit, deleteBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, nav: INav, @@ -120,7 +98,7 @@ fun ListOfBookmarkGroupsFeed( TopBarWithBackButton(caption = stringRes(R.string.bookmark_lists), nav::popBack) }, floatingActionButton = { - BookmarkGroupFabAndMenu(onAddGroup = addBookmarkGroup) + BookmarkGroupFab(onAddGroup = addBookmarkGroup) }, ) { paddingValues -> Column( @@ -143,9 +121,7 @@ fun ListOfBookmarkGroupsFeed( } @Composable -fun BookmarkGroupFabAndMenu(onAddGroup: (name: String, description: String?) -> Unit) { - val isSetAdditionDialogOpen = remember { mutableStateOf(false) } - +fun BookmarkGroupFab(onAddGroup: () -> Unit) { ExtendedFloatingActionButton( text = { Text(text = stringRes(R.string.follow_set_create_btn_label)) @@ -156,21 +132,8 @@ fun BookmarkGroupFabAndMenu(onAddGroup: (name: String, description: String?) -> contentDescription = null, ) }, - onClick = { - isSetAdditionDialogOpen.value = true - }, + onClick = onAddGroup, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, ) - - if (isSetAdditionDialogOpen.value) { - NewBookmarkGroupCreationDialog( - onDismiss = { - isSetAdditionDialogOpen.value = false - }, - onCreateGroup = { name, description -> - onAddGroup(name, description) - }, - ) - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt index d47880432..82f697fb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt @@ -83,7 +83,7 @@ private fun ListManagementView( TopBarWithBackButton(caption = stringRes(R.string.article_bookmark_management_title), nav::popBack) }, floatingActionButton = { - NewListButton { } + NewListButton { nav.nav(Route.BookmarkGroupMetadataEdit()) } }, ) { contentPadding -> Column( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt index 04d0be2c3..74fbcd494 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt @@ -82,7 +82,7 @@ private fun ListManagementView( TopBarWithBackButton(caption = stringRes(R.string.post_bookmark_management_title), nav::popBack) }, floatingActionButton = { - NewListButton { } + NewListButton { nav.nav(Route.BookmarkGroupMetadataEdit()) } }, ) { contentPadding -> Column( From 7c4ad3abb7f102bb35a9250a2ced498ff56540ba Mon Sep 17 00:00:00 2001 From: greenart7c3 <115044884+greenart7c3@users.noreply.github.com> Date: Fri, 28 Nov 2025 05:43:52 -0300 Subject: [PATCH 23/23] Fix default permissions --- .../api/foreground/intents/requests/LoginRequest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt index 9da2f0f3c..8626e4099 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt @@ -35,7 +35,7 @@ class LoginRequest { Permission(CommandType.NIP04_ENCRYPT), Permission(CommandType.NIP04_DECRYPT), Permission(CommandType.NIP44_DECRYPT), - Permission(CommandType.NIP44_DECRYPT), + Permission(CommandType.NIP44_ENCRYPT), Permission(CommandType.DECRYPT_ZAP_EVENT), )