no message
This commit is contained in:
@@ -213,6 +213,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
|||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||||
|
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||||
import com.vitorpamplona.quartz.utils.DualCase
|
import com.vitorpamplona.quartz.utils.DualCase
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import com.vitorpamplona.quartz.utils.containsAny
|
import com.vitorpamplona.quartz.utils.containsAny
|
||||||
@@ -1006,6 +1007,31 @@ class Account(
|
|||||||
client.send(event, computeRelayListToBroadcast(event))
|
client.send(event, computeRelayListToBroadcast(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun sendWebBookmark(
|
||||||
|
url: String,
|
||||||
|
title: String?,
|
||||||
|
description: String,
|
||||||
|
hashtags: List<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
val template = WebBookmarkEvent.build(url, title, description, tags = hashtags)
|
||||||
|
val signedEvent = signer.sign(template)
|
||||||
|
|
||||||
|
cache.justConsumeMyOwnEvent(signedEvent)
|
||||||
|
client.send(signedEvent, computeRelayListToBroadcast(signedEvent))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteWebBookmark(event: WebBookmarkEvent) {
|
||||||
|
if (!isWriteable()) return
|
||||||
|
|
||||||
|
val template = DeletionEvent.build(listOf(event))
|
||||||
|
val signedEvent = signer.sign(template)
|
||||||
|
|
||||||
|
cache.justConsumeMyOwnEvent(signedEvent)
|
||||||
|
client.send(signedEvent, computeRelayListToBroadcast(signedEvent))
|
||||||
|
}
|
||||||
|
|
||||||
fun sendMyPublicAndPrivateOutbox(event: Event?) {
|
fun sendMyPublicAndPrivateOutbox(event: Event?) {
|
||||||
if (event == null) return
|
if (event == null) return
|
||||||
cache.justConsumeMyOwnEvent(event)
|
cache.justConsumeMyOwnEvent(event)
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
|
|||||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||||
|
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||||
import com.vitorpamplona.quartz.utils.DualCase
|
import com.vitorpamplona.quartz.utils.DualCase
|
||||||
@@ -1433,6 +1434,12 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
wasVerified: Boolean,
|
wasVerified: Boolean,
|
||||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
|
|
||||||
|
fun consume(
|
||||||
|
event: WebBookmarkEvent,
|
||||||
|
relay: NormalizedRelayUrl?,
|
||||||
|
wasVerified: Boolean,
|
||||||
|
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
|
|
||||||
private fun consume(
|
private fun consume(
|
||||||
event: CalendarDateSlotEvent,
|
event: CalendarDateSlotEvent,
|
||||||
relay: NormalizedRelayUrl?,
|
relay: NormalizedRelayUrl?,
|
||||||
@@ -3209,6 +3216,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
is VideoShortEvent -> consume(event, relay, wasVerified)
|
is VideoShortEvent -> consume(event, relay, wasVerified)
|
||||||
is VoiceEvent -> consume(event, relay, wasVerified)
|
is VoiceEvent -> consume(event, relay, wasVerified)
|
||||||
is VoiceReplyEvent -> consume(event, relay, wasVerified)
|
is VoiceReplyEvent -> consume(event, relay, wasVerified)
|
||||||
|
is WebBookmarkEvent -> consume(event, relay, wasVerified)
|
||||||
is WikiNoteEvent -> consume(event, relay, wasVerified)
|
is WikiNoteEvent -> consume(event, relay, wasVerified)
|
||||||
is PaymentTargetsEvent -> consume(event, relay, wasVerified)
|
is PaymentTargetsEvent -> consume(event, relay, wasVerified)
|
||||||
else -> Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}").let { false }
|
else -> Log.w("Event Not Supported", "From ${relay?.url}: ${event.toJson()}").let { false }
|
||||||
|
|||||||
+8
-1
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
|||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
|
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
|
||||||
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
|
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
|
||||||
|
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -91,9 +92,11 @@ class AccountCacheState(
|
|||||||
val cached = accounts.value[signer.pubKey]
|
val cached = accounts.value[signer.pubKey]
|
||||||
if (cached != null) return cached
|
if (cached != null) return cached
|
||||||
|
|
||||||
|
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME)
|
||||||
|
|
||||||
return Account(
|
return Account(
|
||||||
settings = accountSettings,
|
settings = accountSettings,
|
||||||
signer = signer,
|
signer = signerWithClientTag,
|
||||||
geolocationFlow = geolocationFlow,
|
geolocationFlow = geolocationFlow,
|
||||||
nwcFilterAssembler = nwcFilterAssembler,
|
nwcFilterAssembler = nwcFilterAssembler,
|
||||||
otsResolverBuilder = otsResolverBuilder,
|
otsResolverBuilder = otsResolverBuilder,
|
||||||
@@ -122,4 +125,8 @@ class AccountCacheState(
|
|||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CLIENT_TAG_NAME = "Amethyst"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ object ScrollStateKeys {
|
|||||||
const val DISCOVER_CHATS = "DiscoverChatsFeed"
|
const val DISCOVER_CHATS = "DiscoverChatsFeed"
|
||||||
|
|
||||||
const val SEARCH_SCREEN = "SearchFeed"
|
const val SEARCH_SCREEN = "SearchFeed"
|
||||||
|
|
||||||
|
const val WEB_BOOKMARKS = "WebBookmarksFeed"
|
||||||
}
|
}
|
||||||
|
|
||||||
object PagerStateKeys {
|
object PagerStateKeys {
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.WebBookmarksScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
|
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
|
||||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
@@ -200,6 +201,7 @@ fun AppNavigation(
|
|||||||
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) }
|
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) }
|
||||||
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) }
|
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) }
|
||||||
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
|
||||||
|
|||||||
+9
@@ -51,6 +51,7 @@ import androidx.compose.material.icons.outlined.AccountBalanceWallet
|
|||||||
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||||
import androidx.compose.material.icons.outlined.Drafts
|
import androidx.compose.material.icons.outlined.Drafts
|
||||||
import androidx.compose.material.icons.outlined.GroupAdd
|
import androidx.compose.material.icons.outlined.GroupAdd
|
||||||
|
import androidx.compose.material.icons.outlined.Language
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material.icons.outlined.Sync
|
import androidx.compose.material.icons.outlined.Sync
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -458,6 +459,14 @@ fun ListContent(
|
|||||||
route = Route.BookmarkGroups,
|
route = Route.BookmarkGroups,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NavigationRow(
|
||||||
|
title = R.string.web_bookmarks,
|
||||||
|
icon = Icons.Outlined.Language,
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
nav = nav,
|
||||||
|
route = Route.WebBookmarks,
|
||||||
|
)
|
||||||
|
|
||||||
NavigationRow(
|
NavigationRow(
|
||||||
title = R.string.drafts,
|
title = R.string.drafts,
|
||||||
icon = Icons.Outlined.Drafts,
|
icon = Icons.Outlined.Drafts,
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ sealed class Route {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable object WebBookmarks : Route()
|
||||||
|
|
||||||
@Serializable object Drafts : Route()
|
@Serializable object Drafts : Route()
|
||||||
|
|
||||||
@Serializable object AllSettings : Route()
|
@Serializable object AllSettings : Route()
|
||||||
|
|||||||
+2
@@ -328,6 +328,7 @@ private enum class FeedGroup(
|
|||||||
HASHTAGS(R.string.feed_group_hashtags),
|
HASHTAGS(R.string.feed_group_hashtags),
|
||||||
COMMUNITIES(R.string.feed_group_communities),
|
COMMUNITIES(R.string.feed_group_communities),
|
||||||
LISTS(R.string.feed_group_lists),
|
LISTS(R.string.feed_group_lists),
|
||||||
|
RELAYS(R.string.feed_group_relays),
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<FeedGroup, List<IndexedFeedDefinition>> {
|
private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<FeedGroup, List<IndexedFeedDefinition>> {
|
||||||
@@ -337,6 +338,7 @@ private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<Fe
|
|||||||
is HashtagName -> FeedGroup.HASHTAGS
|
is HashtagName -> FeedGroup.HASHTAGS
|
||||||
is CommunityName -> FeedGroup.COMMUNITIES
|
is CommunityName -> FeedGroup.COMMUNITIES
|
||||||
is PeopleListName -> FeedGroup.LISTS
|
is PeopleListName -> FeedGroup.LISTS
|
||||||
|
is RelayName -> FeedGroup.RELAYS
|
||||||
else -> FeedGroup.FEEDS
|
else -> FeedGroup.FEEDS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
class AccountFeedContentStates(
|
class AccountFeedContentStates(
|
||||||
@@ -76,6 +77,8 @@ class AccountFeedContentStates(
|
|||||||
|
|
||||||
val drafts = FeedContentState(DraftEventsFeedFilter(account), scope, LocalCache)
|
val drafts = FeedContentState(DraftEventsFeedFilter(account), scope, LocalCache)
|
||||||
|
|
||||||
|
val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache)
|
||||||
|
|
||||||
suspend fun init() {
|
suspend fun init() {
|
||||||
notificationSummary.initializeSuspend()
|
notificationSummary.initializeSuspend()
|
||||||
}
|
}
|
||||||
@@ -104,6 +107,8 @@ class AccountFeedContentStates(
|
|||||||
notificationSummary.invalidateInsertData(newNotes)
|
notificationSummary.invalidateInsertData(newNotes)
|
||||||
|
|
||||||
drafts.updateFeedWith(newNotes)
|
drafts.updateFeedWith(newNotes)
|
||||||
|
|
||||||
|
webBookmarks.updateFeedWith(newNotes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteNotes(newNotes: Set<Note>) {
|
fun deleteNotes(newNotes: Set<Note>) {
|
||||||
@@ -130,6 +135,8 @@ class AccountFeedContentStates(
|
|||||||
notificationSummary.invalidateInsertData(newNotes)
|
notificationSummary.invalidateInsertData(newNotes)
|
||||||
|
|
||||||
drafts.deleteFromFeed(newNotes)
|
drafts.deleteFromFeed(newNotes)
|
||||||
|
|
||||||
|
webBookmarks.deleteFromFeed(newNotes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
|
|||||||
+2
@@ -269,6 +269,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
|||||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||||
|
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
@@ -658,6 +659,7 @@ private fun kindDisplayName(kind: Int): Int =
|
|||||||
VideoShortEvent.KIND -> R.string.kind_shorts
|
VideoShortEvent.KIND -> R.string.kind_shorts
|
||||||
VoiceEvent.KIND -> R.string.kind_voice_msg
|
VoiceEvent.KIND -> R.string.kind_voice_msg
|
||||||
VoiceReplyEvent.KIND -> R.string.kind_voice_reply
|
VoiceReplyEvent.KIND -> R.string.kind_voice_reply
|
||||||
|
WebBookmarkEvent.KIND -> R.string.kind_web_bookmark
|
||||||
WikiNoteEvent.KIND -> R.string.kind_wiki
|
WikiNoteEvent.KIND -> R.string.kind_wiki
|
||||||
else -> -1
|
else -> -1
|
||||||
}
|
}
|
||||||
|
|||||||
+398
@@ -0,0 +1,398 @@
|
|||||||
|
/*
|
||||||
|
* 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.webBookmarks
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||||
|
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||||
|
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||||
|
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||||
|
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WebBookmarksScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
RenderWebBookmarksScreen(accountViewModel.feedStates.webBookmarks, accountViewModel, nav)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RenderWebBookmarksScreen(
|
||||||
|
feedState: FeedContentState,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
WatchLifecycleAndUpdateModel(feedState)
|
||||||
|
|
||||||
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showAddDialog) {
|
||||||
|
WebBookmarkEditDialog(
|
||||||
|
onDismiss = { showAddDialog = false },
|
||||||
|
onSave = { url, title, description, tags ->
|
||||||
|
accountViewModel.launchSigner {
|
||||||
|
accountViewModel.account.sendWebBookmark(url, title, description, tags)
|
||||||
|
}
|
||||||
|
showAddDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisappearingScaffold(
|
||||||
|
isInvertedLayout = false,
|
||||||
|
topBar = {
|
||||||
|
ShorterTopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(text = stringRes(id = R.string.web_bookmarks))
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = nav::popBack) {
|
||||||
|
ArrowBackIcon()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { showAddDialog = true },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(R.string.web_bookmark_add_title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(it).fillMaxHeight()) {
|
||||||
|
RefresheableBox(feedState) {
|
||||||
|
SaveableFeedState(feedState, ScrollStateKeys.WEB_BOOKMARKS) { listState ->
|
||||||
|
RenderFeedContentState(
|
||||||
|
feedContentState = feedState,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
listState = listState,
|
||||||
|
nav = nav,
|
||||||
|
routeForLastRead = null,
|
||||||
|
onLoaded = { WebBookmarksFeedLoaded(it, listState, accountViewModel, nav) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WebBookmarksFeedLoaded(
|
||||||
|
loaded: FeedState.Loaded,
|
||||||
|
listState: LazyListState,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = FeedPadding,
|
||||||
|
state = listState,
|
||||||
|
) {
|
||||||
|
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
|
||||||
|
WebBookmarkCard(item, accountViewModel, nav)
|
||||||
|
|
||||||
|
HorizontalDivider(thickness = DividerThickness)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WebBookmarkCard(
|
||||||
|
note: Note,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val event = note.event as? WebBookmarkEvent ?: return
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
|
var showEditDialog by remember { mutableStateOf(false) }
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showEditDialog) {
|
||||||
|
WebBookmarkEditDialog(
|
||||||
|
initialUrl = event.url(),
|
||||||
|
initialTitle = event.title() ?: "",
|
||||||
|
initialDescription = event.description(),
|
||||||
|
initialTags = event.hashtags().joinToString(", "),
|
||||||
|
onDismiss = { showEditDialog = false },
|
||||||
|
onSave = { url, title, description, tags ->
|
||||||
|
accountViewModel.launchSigner {
|
||||||
|
accountViewModel.account.sendWebBookmark(url, title, description, tags)
|
||||||
|
}
|
||||||
|
showEditDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.web_bookmark_delete)) },
|
||||||
|
text = { Text(stringResource(R.string.web_bookmark_delete_confirm)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
accountViewModel.launchSigner {
|
||||||
|
accountViewModel.account.deleteWebBookmark(event)
|
||||||
|
}
|
||||||
|
showDeleteDialog = false
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.yes))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteDialog = false }) {
|
||||||
|
Text(stringResource(R.string.no))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { uriHandler.openUri(event.url()) }
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = event.title() ?: event.url(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = event.url(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
IconButton(onClick = { uriHandler.openUri(event.url()) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.OpenInBrowser,
|
||||||
|
contentDescription = stringResource(R.string.web_bookmark_open_url),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showEditDialog = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = stringResource(R.string.web_bookmark_edit_title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showDeleteDialog = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.web_bookmark_delete),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.description().isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = event.description(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tags = event.hashtags()
|
||||||
|
if (tags.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row {
|
||||||
|
tags.forEach { tag ->
|
||||||
|
Text(
|
||||||
|
text = "#$tag",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WebBookmarkEditDialog(
|
||||||
|
initialUrl: String = "",
|
||||||
|
initialTitle: String = "",
|
||||||
|
initialDescription: String = "",
|
||||||
|
initialTags: String = "",
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (url: String, title: String?, description: String, tags: List<String>) -> Unit,
|
||||||
|
) {
|
||||||
|
var url by remember { mutableStateOf(initialUrl) }
|
||||||
|
var title by remember { mutableStateOf(initialTitle) }
|
||||||
|
var description by remember { mutableStateOf(initialDescription) }
|
||||||
|
var tags by remember { mutableStateOf(initialTags) }
|
||||||
|
|
||||||
|
val isEditing = initialUrl.isNotEmpty()
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isEditing) R.string.web_bookmark_edit_title else R.string.web_bookmark_add_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = url,
|
||||||
|
onValueChange = { url = it },
|
||||||
|
label = { Text(stringResource(R.string.web_bookmark_url_label)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.web_bookmark_url_placeholder)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = title,
|
||||||
|
onValueChange = { title = it },
|
||||||
|
label = { Text(stringResource(R.string.web_bookmark_title_label)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.web_bookmark_title_placeholder)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = description,
|
||||||
|
onValueChange = { description = it },
|
||||||
|
label = { Text(stringResource(R.string.web_bookmark_description_label)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.web_bookmark_description_placeholder)) },
|
||||||
|
maxLines = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tags,
|
||||||
|
onValueChange = { tags = it },
|
||||||
|
label = { Text(stringResource(R.string.web_bookmark_tags_label)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.web_bookmark_tags_placeholder)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (url.isNotBlank()) {
|
||||||
|
val normalizedUrl = if (!url.startsWith("http")) "https://$url" else url
|
||||||
|
val tagList = tags.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
onSave(normalizedUrl, title.ifBlank { null }, description, tagList)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = url.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.web_bookmark_save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* 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.webBookmarks.dal
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||||
|
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||||
|
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||||
|
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||||
|
|
||||||
|
class WebBookmarkFeedFilter(
|
||||||
|
val account: Account,
|
||||||
|
) : AdditiveFeedFilter<Note>() {
|
||||||
|
override fun feedKey(): String = account.userProfile().pubkeyHex + "/webBookmarks"
|
||||||
|
|
||||||
|
override fun applyFilter(newItems: Set<Note>): Set<Note> =
|
||||||
|
newItems.filterTo(HashSet()) {
|
||||||
|
acceptableEvent(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun feed(): List<Note> {
|
||||||
|
val bookmarks =
|
||||||
|
LocalCache.addressables.filterIntoSet(WebBookmarkEvent.KIND, account.userProfile().pubkeyHex) { _, note ->
|
||||||
|
acceptableEvent(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sort(bookmarks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptableEvent(it: Note): Boolean {
|
||||||
|
val noteEvent = it.event
|
||||||
|
return noteEvent is WebBookmarkEvent && noteEvent.pubKey == account.userProfile().pubkeyHex
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||||
|
}
|
||||||
@@ -1568,6 +1568,7 @@
|
|||||||
<string name="feed_group_hashtags">Hashtags</string>
|
<string name="feed_group_hashtags">Hashtags</string>
|
||||||
<string name="feed_group_communities">Communities</string>
|
<string name="feed_group_communities">Communities</string>
|
||||||
<string name="feed_group_lists">Lists</string>
|
<string name="feed_group_lists">Lists</string>
|
||||||
|
<string name="feed_group_relays">Relays</string>
|
||||||
|
|
||||||
<string name="temporary_account">Log off on device lock</string>
|
<string name="temporary_account">Log off on device lock</string>
|
||||||
<string name="private_message">Private Message</string>
|
<string name="private_message">Private Message</string>
|
||||||
@@ -1819,6 +1820,7 @@
|
|||||||
<string name="kind_shorts">Shorts</string>
|
<string name="kind_shorts">Shorts</string>
|
||||||
<string name="kind_voice_msg">Voice Msg</string>
|
<string name="kind_voice_msg">Voice Msg</string>
|
||||||
<string name="kind_voice_reply">Voice Reply</string>
|
<string name="kind_voice_reply">Voice Reply</string>
|
||||||
|
<string name="kind_web_bookmark">Web Bookmark</string>
|
||||||
<string name="kind_wiki">Wiki</string>
|
<string name="kind_wiki">Wiki</string>
|
||||||
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Start with a great feed by following the same people as someone you trust.</string>
|
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Start with a great feed by following the same people as someone you trust.</string>
|
||||||
<string name="import_follow_list">Import Follow List</string>
|
<string name="import_follow_list">Import Follow List</string>
|
||||||
@@ -1937,4 +1939,20 @@
|
|||||||
<string name="event_sync_date_filter_all_time">All time</string>
|
<string name="event_sync_date_filter_all_time">All time</string>
|
||||||
<string name="event_sync_date_filter_last_sync">Last sync: %1$s</string>
|
<string name="event_sync_date_filter_last_sync">Last sync: %1$s</string>
|
||||||
<string name="event_sync_date_filter_since_last_sync">Since Last Sync</string>
|
<string name="event_sync_date_filter_since_last_sync">Since Last Sync</string>
|
||||||
|
<string name="web_bookmarks">Web Bookmarks</string>
|
||||||
|
<string name="web_bookmarks_empty">No web bookmarks yet. Tap + to add one.</string>
|
||||||
|
<string name="web_bookmark_add_title">Add Web Bookmark</string>
|
||||||
|
<string name="web_bookmark_edit_title">Edit Web Bookmark</string>
|
||||||
|
<string name="web_bookmark_url_label">URL</string>
|
||||||
|
<string name="web_bookmark_url_placeholder">https://example.com</string>
|
||||||
|
<string name="web_bookmark_title_label">Title</string>
|
||||||
|
<string name="web_bookmark_title_placeholder">Bookmark title</string>
|
||||||
|
<string name="web_bookmark_description_label">Description</string>
|
||||||
|
<string name="web_bookmark_description_placeholder">A short description</string>
|
||||||
|
<string name="web_bookmark_tags_label">Tags (comma-separated)</string>
|
||||||
|
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
|
||||||
|
<string name="web_bookmark_save">Save</string>
|
||||||
|
<string name="web_bookmark_delete">Delete</string>
|
||||||
|
<string name="web_bookmark_delete_confirm">Delete this web bookmark?</string>
|
||||||
|
<string name="web_bookmark_open_url">Open URL</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+6
-1
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
|||||||
import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache
|
import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||||
|
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||||
import com.vitorpamplona.quartz.utils.DualCase
|
import com.vitorpamplona.quartz.utils.DualCase
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -62,7 +63,7 @@ class DesktopIAccount(
|
|||||||
val dmSendTracker: DmSendTracker,
|
val dmSendTracker: DmSendTracker,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
) : IAccount {
|
) : IAccount {
|
||||||
override val signer: NostrSigner = accountState.signer
|
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
|
||||||
|
|
||||||
override val pubKey: String = accountState.pubKeyHex
|
override val pubKey: String = accountState.pubKeyHex
|
||||||
|
|
||||||
@@ -221,4 +222,8 @@ class DesktopIAccount(
|
|||||||
}
|
}
|
||||||
chatroomList.addMessage(roomKey, note)
|
chatroomList.addMessage(roomKey, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CLIENT_TAG_NAME = "Amethyst"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.nip89AppHandlers.clientTag
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||||
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [NostrSigner] decorator that automatically appends a NIP-89 client tag
|
||||||
|
* to every event before delegating signing to the wrapped [inner] signer.
|
||||||
|
*
|
||||||
|
* This ensures all events produced by the application carry the client
|
||||||
|
* identification tag without requiring each call site to add it manually.
|
||||||
|
*
|
||||||
|
* Works with any signer implementation: internal, external (NIP-55/Amber),
|
||||||
|
* or remote (NIP-46/Bunker).
|
||||||
|
*/
|
||||||
|
class NostrSignerWithClientTag(
|
||||||
|
val inner: NostrSigner,
|
||||||
|
val clientTag: Array<String>,
|
||||||
|
) : NostrSigner(inner.pubKey) {
|
||||||
|
constructor(
|
||||||
|
inner: NostrSigner,
|
||||||
|
clientName: String,
|
||||||
|
) : this(inner, ClientTag.assemble(clientName))
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
inner: NostrSigner,
|
||||||
|
clientName: String,
|
||||||
|
addressId: String?,
|
||||||
|
relayHint: NormalizedRelayUrl?,
|
||||||
|
) : this(inner, ClientTag.assemble(clientName, addressId, relayHint))
|
||||||
|
|
||||||
|
override fun isWriteable(): Boolean = inner.isWriteable()
|
||||||
|
|
||||||
|
override suspend fun <T : Event> sign(
|
||||||
|
createdAt: Long,
|
||||||
|
kind: Int,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
): T = inner.sign(createdAt, kind, appendClientTag(tags), content)
|
||||||
|
|
||||||
|
override suspend fun nip04Encrypt(
|
||||||
|
plaintext: String,
|
||||||
|
toPublicKey: HexKey,
|
||||||
|
): String = inner.nip04Encrypt(plaintext, toPublicKey)
|
||||||
|
|
||||||
|
override suspend fun nip04Decrypt(
|
||||||
|
ciphertext: String,
|
||||||
|
fromPublicKey: HexKey,
|
||||||
|
): String = inner.nip04Decrypt(ciphertext, fromPublicKey)
|
||||||
|
|
||||||
|
override suspend fun nip44Encrypt(
|
||||||
|
plaintext: String,
|
||||||
|
toPublicKey: HexKey,
|
||||||
|
): String = inner.nip44Encrypt(plaintext, toPublicKey)
|
||||||
|
|
||||||
|
override suspend fun nip44Decrypt(
|
||||||
|
ciphertext: String,
|
||||||
|
fromPublicKey: HexKey,
|
||||||
|
): String = inner.nip44Decrypt(ciphertext, fromPublicKey)
|
||||||
|
|
||||||
|
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = inner.decryptZapEvent(event)
|
||||||
|
|
||||||
|
override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce)
|
||||||
|
|
||||||
|
override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport()
|
||||||
|
|
||||||
|
private fun appendClientTag(tags: Array<Array<String>>): Array<Array<String>> {
|
||||||
|
// Don't add if a client tag already exists
|
||||||
|
if (tags.any { it.size >= 2 && it[0] == ClientTag.TAG_NAME }) return tags
|
||||||
|
|
||||||
|
return tags + arrayOf(clientTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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.nipB0WebBookmarks
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
||||||
|
|
||||||
|
fun TagArrayBuilder<WebBookmarkEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<WebBookmarkEvent>.publishedAt(timestamp: Long) = addUnique(PublishedAtTag.assemble(timestamp))
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* 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.nipB0WebBookmarks
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
||||||
|
|
||||||
|
fun TagArray.webBookmarkTitle() = firstNotNullOfOrNull(TitleTag::parse)
|
||||||
|
|
||||||
|
fun TagArray.webBookmarkPublishedAt() = firstNotNullOfOrNull(PublishedAtTag::parse)
|
||||||
|
|
||||||
|
fun TagArray.webBookmarkHashtags() = hashtags()
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* 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.nipB0WebBookmarks
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
|
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.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||||
|
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class WebBookmarkEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: TagArray,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||||
|
RootScope {
|
||||||
|
fun url(): String {
|
||||||
|
val dTagValue = dTag()
|
||||||
|
return if (dTagValue.isNotEmpty()) "https://$dTagValue" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
|
||||||
|
|
||||||
|
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
|
||||||
|
|
||||||
|
fun hashtags() = tags.hashtags()
|
||||||
|
|
||||||
|
fun description() = content
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 39701
|
||||||
|
const val ALT_DESCRIPTION = "Web Bookmark"
|
||||||
|
|
||||||
|
fun urlToDTag(url: String): String =
|
||||||
|
url
|
||||||
|
.removePrefix("https://")
|
||||||
|
.removePrefix("http://")
|
||||||
|
.trimEnd('/')
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
url: String,
|
||||||
|
bookmarkTitle: String?,
|
||||||
|
description: String,
|
||||||
|
tags: List<String> = emptyList(),
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<WebBookmarkEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate<WebBookmarkEvent>(KIND, description, createdAt) {
|
||||||
|
dTag(urlToDTag(url))
|
||||||
|
alt(ALT_DESCRIPTION)
|
||||||
|
bookmarkTitle?.let { title(it) }
|
||||||
|
publishedAt(createdAt)
|
||||||
|
hashtags(tags)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,6 +156,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
|||||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||||
|
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||||
@@ -321,6 +322,7 @@ class EventFactory {
|
|||||||
VideoShortEvent.KIND -> VideoShortEvent(id, pubKey, createdAt, tags, content, sig)
|
VideoShortEvent.KIND -> VideoShortEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
VoiceEvent.KIND -> VoiceEvent(id, pubKey, createdAt, tags, content, sig)
|
VoiceEvent.KIND -> VoiceEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
VoiceReplyEvent.KIND -> VoiceReplyEvent(id, pubKey, createdAt, tags, content, sig)
|
VoiceReplyEvent.KIND -> VoiceReplyEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
WebBookmarkEvent.KIND -> WebBookmarkEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig)
|
else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig)
|
||||||
} as T
|
} as T
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip89AppHandlers.clientTag
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class NostrSignerWithClientTagTest {
|
||||||
|
private val keyPair = KeyPair()
|
||||||
|
private val innerSigner = NostrSignerInternal(keyPair)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addsClientTagToSignedEvent() =
|
||||||
|
runTest {
|
||||||
|
val signer = NostrSignerWithClientTag(innerSigner, "Amethyst")
|
||||||
|
|
||||||
|
val template =
|
||||||
|
eventTemplate<TextNoteEvent>(
|
||||||
|
kind = TextNoteEvent.KIND,
|
||||||
|
description = "Hello Nostr",
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = signer.sign<TextNoteEvent>(template)
|
||||||
|
|
||||||
|
assertTrue(event.verify())
|
||||||
|
|
||||||
|
val clientTags = event.tags.filter { it.size >= 2 && it[0] == "client" }
|
||||||
|
assertEquals(1, clientTags.size)
|
||||||
|
assertEquals("Amethyst", clientTags[0][1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotDuplicateExistingClientTag() =
|
||||||
|
runTest {
|
||||||
|
val signer = NostrSignerWithClientTag(innerSigner, "Amethyst")
|
||||||
|
|
||||||
|
val template =
|
||||||
|
eventTemplate<TextNoteEvent>(
|
||||||
|
kind = TextNoteEvent.KIND,
|
||||||
|
description = "Hello Nostr",
|
||||||
|
) {
|
||||||
|
client("OtherClient")
|
||||||
|
}
|
||||||
|
|
||||||
|
val event = signer.sign<TextNoteEvent>(template)
|
||||||
|
|
||||||
|
assertTrue(event.verify())
|
||||||
|
|
||||||
|
val clientTags = event.tags.filter { it.size >= 2 && it[0] == "client" }
|
||||||
|
assertEquals(1, clientTags.size)
|
||||||
|
assertEquals("OtherClient", clientTags[0][1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addsClientTagWithAddressAndRelay() =
|
||||||
|
runTest {
|
||||||
|
val signer =
|
||||||
|
NostrSignerWithClientTag(
|
||||||
|
innerSigner,
|
||||||
|
"Amethyst",
|
||||||
|
"31990:abc123:amethyst",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val template =
|
||||||
|
eventTemplate<TextNoteEvent>(
|
||||||
|
kind = TextNoteEvent.KIND,
|
||||||
|
description = "Hello Nostr",
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = signer.sign<TextNoteEvent>(template)
|
||||||
|
|
||||||
|
assertTrue(event.verify())
|
||||||
|
|
||||||
|
val clientTags = event.tags.filter { it.size >= 2 && it[0] == "client" }
|
||||||
|
assertEquals(1, clientTags.size)
|
||||||
|
assertEquals("Amethyst", clientTags[0][1])
|
||||||
|
assertEquals("31990:abc123:amethyst", clientTags[0][2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preservesSamePubKey() {
|
||||||
|
val signer = NostrSignerWithClientTag(innerSigner, "Amethyst")
|
||||||
|
assertEquals(innerSigner.pubKey, signer.pubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun delegatesIsWriteable() {
|
||||||
|
val signer = NostrSignerWithClientTag(innerSigner, "Amethyst")
|
||||||
|
assertEquals(innerSigner.isWriteable(), signer.isWriteable())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user