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.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
@@ -1006,6 +1007,31 @@ class Account(
|
||||
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?) {
|
||||
if (event == null) return
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
|
||||
@@ -206,6 +206,7 @@ import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
@@ -1433,6 +1434,12 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: WebBookmarkEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CalendarDateSlotEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -3209,6 +3216,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
is VideoShortEvent -> consume(event, relay, wasVerified)
|
||||
is VoiceEvent -> consume(event, relay, wasVerified)
|
||||
is VoiceReplyEvent -> consume(event, relay, wasVerified)
|
||||
is WebBookmarkEvent -> consume(event, relay, wasVerified)
|
||||
is WikiNoteEvent -> consume(event, relay, wasVerified)
|
||||
is PaymentTargetsEvent -> consume(event, relay, wasVerified)
|
||||
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.nip03Timestamp.OtsResolverBuilder
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -91,9 +92,11 @@ class AccountCacheState(
|
||||
val cached = accounts.value[signer.pubKey]
|
||||
if (cached != null) return cached
|
||||
|
||||
val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME)
|
||||
|
||||
return Account(
|
||||
settings = accountSettings,
|
||||
signer = signer,
|
||||
signer = signerWithClientTag,
|
||||
geolocationFlow = geolocationFlow,
|
||||
nwcFilterAssembler = nwcFilterAssembler,
|
||||
otsResolverBuilder = otsResolverBuilder,
|
||||
@@ -122,4 +125,8 @@ class AccountCacheState(
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CLIENT_TAG_NAME = "Amethyst"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ object ScrollStateKeys {
|
||||
const val DISCOVER_CHATS = "DiscoverChatsFeed"
|
||||
|
||||
const val SEARCH_SCREEN = "SearchFeed"
|
||||
|
||||
const val WEB_BOOKMARKS = "WebBookmarksFeed"
|
||||
}
|
||||
|
||||
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.WalletSendScreen
|
||||
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.uriToRoute
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
@@ -200,6 +201,7 @@ fun AppNavigation(
|
||||
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) }
|
||||
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) }
|
||||
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Settings> { SettingsScreen(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.Drafts
|
||||
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.Sync
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -458,6 +459,14 @@ fun ListContent(
|
||||
route = Route.BookmarkGroups,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.web_bookmarks,
|
||||
icon = Icons.Outlined.Language,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.WebBookmarks,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.drafts,
|
||||
icon = Icons.Outlined.Drafts,
|
||||
|
||||
@@ -96,6 +96,8 @@ sealed class Route {
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object WebBookmarks : Route()
|
||||
|
||||
@Serializable object Drafts : Route()
|
||||
|
||||
@Serializable object AllSettings : Route()
|
||||
|
||||
+2
@@ -328,6 +328,7 @@ private enum class FeedGroup(
|
||||
HASHTAGS(R.string.feed_group_hashtags),
|
||||
COMMUNITIES(R.string.feed_group_communities),
|
||||
LISTS(R.string.feed_group_lists),
|
||||
RELAYS(R.string.feed_group_relays),
|
||||
}
|
||||
|
||||
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 CommunityName -> FeedGroup.COMMUNITIES
|
||||
is PeopleListName -> FeedGroup.LISTS
|
||||
is RelayName -> FeedGroup.RELAYS
|
||||
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.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class AccountFeedContentStates(
|
||||
@@ -76,6 +77,8 @@ class AccountFeedContentStates(
|
||||
|
||||
val drafts = FeedContentState(DraftEventsFeedFilter(account), scope, LocalCache)
|
||||
|
||||
val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache)
|
||||
|
||||
suspend fun init() {
|
||||
notificationSummary.initializeSuspend()
|
||||
}
|
||||
@@ -104,6 +107,8 @@ class AccountFeedContentStates(
|
||||
notificationSummary.invalidateInsertData(newNotes)
|
||||
|
||||
drafts.updateFeedWith(newNotes)
|
||||
|
||||
webBookmarks.updateFeedWith(newNotes)
|
||||
}
|
||||
|
||||
fun deleteNotes(newNotes: Set<Note>) {
|
||||
@@ -130,6 +135,8 @@ class AccountFeedContentStates(
|
||||
notificationSummary.invalidateInsertData(newNotes)
|
||||
|
||||
drafts.deleteFromFeed(newNotes)
|
||||
|
||||
webBookmarks.deleteFromFeed(newNotes)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
|
||||
+2
@@ -269,6 +269,7 @@ import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -658,6 +659,7 @@ private fun kindDisplayName(kind: Int): Int =
|
||||
VideoShortEvent.KIND -> R.string.kind_shorts
|
||||
VoiceEvent.KIND -> R.string.kind_voice_msg
|
||||
VoiceReplyEvent.KIND -> R.string.kind_voice_reply
|
||||
WebBookmarkEvent.KIND -> R.string.kind_web_bookmark
|
||||
WikiNoteEvent.KIND -> R.string.kind_wiki
|
||||
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_communities">Communities</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="private_message">Private Message</string>
|
||||
@@ -1819,6 +1820,7 @@
|
||||
<string name="kind_shorts">Shorts</string>
|
||||
<string name="kind_voice_msg">Voice Msg</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="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>
|
||||
@@ -1937,4 +1939,20 @@
|
||||
<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_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>
|
||||
|
||||
+6
-1
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -62,7 +63,7 @@ class DesktopIAccount(
|
||||
val dmSendTracker: DmSendTracker,
|
||||
private val scope: CoroutineScope,
|
||||
) : IAccount {
|
||||
override val signer: NostrSigner = accountState.signer
|
||||
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
|
||||
|
||||
override val pubKey: String = accountState.pubKeyHex
|
||||
|
||||
@@ -221,4 +222,8 @@ class DesktopIAccount(
|
||||
}
|
||||
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.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
|
||||
@@ -321,6 +322,7 @@ class EventFactory {
|
||||
VideoShortEvent.KIND -> VideoShortEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
VoiceEvent.KIND -> VoiceEvent(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)
|
||||
else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig)
|
||||
} 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