feat: implement NIP-B0 Web Bookmarking (kind 39701)
Add support for NIP-B0 web bookmarks with a complete protocol implementation in Quartz and a full UI in Amethyst for adding, editing, browsing, and deleting web bookmarks. Quartz: - WebBookmarkEvent (kind 39701) as an addressable event - Tag builder/parser extensions for title, published_at, hashtags - Registered in EventFactory Amethyst: - WebBookmarksScreen with FAB to add, and per-card edit/delete/open - WebBookmarkEditDialog for add/edit with URL, title, description, tags - WebBookmarkFeedFilter querying addressable notes by kind + pubkey - Account.sendWebBookmark() and Account.deleteWebBookmark() - LocalCache.consume() for WebBookmarkEvent - Drawer navigation entry with Language icon - Route.WebBookmarks and navigation registration https://claude.ai/code/session_01UzfLJttwuJzovtb8HX5F9n
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 }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
@@ -1818,6 +1818,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>
|
||||||
@@ -1936,4 +1937,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>
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
Reference in New Issue
Block a user