Merge pull request #1948 from vitorpamplona/claude/add-web-bookmarks-qYwpU
feat: implement NIP-B0 Web Bookmarking (kind 39701)
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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
+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)
|
||||
}
|
||||
@@ -1820,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>
|
||||
@@ -1938,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>
|
||||
|
||||
+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
|
||||
|
||||
Reference in New Issue
Block a user