feat: add tags support to web bookmarks per NIP-B0

- Added tags input field to bookmark modal (comma-separated)
- Updated createWebBookmark to accept tags array
- Tags are added as 't' tags per NIP-B0 spec
- Added published_at tag with current timestamp
- Moved description to content field (per spec, not summary tag)
- d tag now uses URL without scheme (host + path + search + hash)
- Added helper text to explain tag formatting
- Styled form-helper-text for better UX
This commit is contained in:
Gigi
2025-10-08 09:51:33 +01:00
parent d452f96f79
commit a0b98231b7
4 changed files with 63 additions and 15 deletions

View File

@@ -5,13 +5,14 @@ import IconButton from './IconButton'
interface AddBookmarkModalProps {
onClose: () => void
onSave: (url: string, title?: string, description?: string) => Promise<void>
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
}
const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave }) => {
const [url, setUrl] = useState('')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [tagsInput, setTagsInput] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -34,10 +35,18 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
try {
setIsSaving(true)
// Parse tags from comma-separated input
const tags = tagsInput
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0)
await onSave(
url.trim(),
title.trim() || undefined,
description.trim() || undefined
description.trim() || undefined,
tags.length > 0 ? tags : undefined
)
onClose()
} catch (err) {
@@ -100,6 +109,21 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
/>
</div>
<div className="form-group">
<label htmlFor="bookmark-tags">Tags</label>
<input
id="bookmark-tags"
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="comma, separated, tags"
disabled={isSaving}
/>
<div className="form-helper-text">
Separate tags with commas (e.g., "nostr, web3, article")
</div>
</div>
{error && (
<div className="modal-error">{error}</div>
)}

View File

@@ -55,12 +55,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
}
const handleSaveBookmark = async (url: string, title?: string, description?: string) => {
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
if (!activeAccount || !relayPool) {
throw new Error('Please login to create bookmarks')
}
await createWebBookmark(url, title, description, activeAccount, relayPool, RELAYS)
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
// Refresh bookmarks after creating
if (onRefresh) {

View File

@@ -2280,6 +2280,13 @@ body {
min-height: 80px;
}
.form-helper-text {
margin-top: 0.25rem;
font-size: 0.8rem;
color: #999;
line-height: 1.4;
}
.modal-error {
padding: 0.75rem;
background: rgba(220, 53, 69, 0.1);

View File

@@ -7,7 +7,8 @@ import { NostrEvent } from 'nostr-tools'
* Creates a web bookmark event (NIP-B0, kind:39701)
* @param url The URL to bookmark
* @param title Optional title for the bookmark
* @param description Optional description
* @param description Optional description (goes in content field)
* @param bookmarkTags Optional array of tags/hashtags
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @param relays The relays to publish to
@@ -17,6 +18,7 @@ export async function createWebBookmark(
url: string,
title: string | undefined,
description: string | undefined,
bookmarkTags: string[] | undefined,
account: IAccount,
relayPool: RelayPool,
relays: string[]
@@ -25,34 +27,49 @@ export async function createWebBookmark(
throw new Error('URL is required for web bookmark')
}
// Validate URL format
// Validate URL format and extract the URL without scheme for d tag
let parsedUrl: URL
try {
new URL(url)
parsedUrl = new URL(url)
} catch {
throw new Error('Invalid URL format')
}
// d tag should be URL without scheme (as per NIP-B0)
const dTagValue = parsedUrl.host + parsedUrl.pathname + parsedUrl.search + parsedUrl.hash
const factory = new EventFactory({ signer: account })
const now = Math.floor(Date.now() / 1000)
// Build tags according to NIP-B0
const tags: string[][] = [
['d', url], // URL as identifier
['d', dTagValue], // URL without scheme as identifier
]
if (title) {
tags.push(['title', title])
// Add published_at tag (current timestamp)
tags.push(['published_at', now.toString()])
// Add title tag if provided
if (title && title.trim()) {
tags.push(['title', title.trim()])
}
if (description) {
tags.push(['summary', description])
// Add t tags for each bookmark tag/hashtag
if (bookmarkTags && bookmarkTags.length > 0) {
bookmarkTags.forEach(tag => {
const trimmedTag = tag.trim()
if (trimmedTag) {
tags.push(['t', trimmedTag])
}
})
}
// Create the event
// Create the event with description in content field (as per NIP-B0)
const draft = await factory.create(async () => ({
kind: 39701, // NIP-B0 web bookmark
content: '',
content: description?.trim() || '',
tags,
created_at: Math.floor(Date.now() / 1000)
created_at: now
}))
// Sign the event