feat: add highlight deletion with confirmation dialog

- Create deletionService for NIP-09 kind:5 event deletion requests
- Add ConfirmDialog component for user confirmation before deletion
- Add subtle delete button to highlight items (trash icon)
- Only show delete button for user's own highlights
- Position delete button symmetrically opposite to relay indicator
- Add confirmation dialog to prevent accidental deletions
- Remove highlights from UI immediately after deletion request
- Style delete button with red hover color
- Add comprehensive confirmation dialog styling (danger/warning/info variants)

Implements NIP-09 Event Deletion Request.
Users can now delete their own highlights after confirming the action.
This commit is contained in:
Gigi
2025-10-11 08:38:22 +01:00
parent 6a6b8c4fad
commit 0adb8d6766
6 changed files with 370 additions and 2 deletions

View File

@@ -0,0 +1,56 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
interface ConfirmDialogProps {
isOpen: boolean
title: string
message: string
confirmText?: string
cancelText?: string
onConfirm: () => void
onCancel: () => void
variant?: 'danger' | 'warning' | 'info'
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
variant = 'warning'
}) => {
if (!isOpen) return null
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className={`confirm-dialog-icon ${variant}`}>
<FontAwesomeIcon icon={faExclamationTriangle} />
</div>
<h3 className="confirm-dialog-title">{title}</h3>
<p className="confirm-dialog-message">{message}</p>
<div className="confirm-dialog-actions">
<button
className="confirm-dialog-btn cancel"
onClick={onCancel}
>
{cancelText}
</button>
<button
className={`confirm-dialog-btn confirm ${variant}`}
onClick={onConfirm}
>
{confirmText}
</button>
</div>
</div>
</div>
)
}
export default ConfirmDialog

View File

@@ -1,15 +1,18 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
import ConfirmDialog from './ConfirmDialog'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -23,6 +26,7 @@ interface HighlightItemProps {
relayPool?: RelayPool | null
eventStore?: IEventStore | null
onHighlightUpdate?: (highlight: Highlight) => void
onHighlightDelete?: (highlightId: string) => void
}
export const HighlightItem: React.FC<HighlightItemProps> = ({
@@ -32,12 +36,17 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onHighlightClick,
relayPool,
eventStore,
onHighlightUpdate
onHighlightUpdate,
onHighlightDelete
}) => {
const itemRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const activeAccount = Hooks.useActiveAccount()
// Resolve the profile of the user who made the highlight
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
@@ -243,7 +252,51 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const relayIndicator = getRelayIndicatorInfo()
// Check if current user can delete this highlight
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteConfirm(true)
}
const handleConfirmDelete = async () => {
if (!activeAccount || !relayPool) {
console.warn('Cannot delete: no account or relay pool')
return
}
setIsDeleting(true)
setShowDeleteConfirm(false)
try {
await createDeletionRequest(
highlight.id,
9802, // kind for highlights
'Deleted by user',
activeAccount,
relayPool
)
console.log('✅ Highlight deletion request published')
// Notify parent to remove this highlight from the list
if (onHighlightDelete) {
onHighlightDelete(highlight.id)
}
} catch (error) {
console.error('Failed to delete highlight:', error)
} finally {
setIsDeleting(false)
}
}
const handleCancelDelete = () => {
setShowDeleteConfirm(false)
}
return (
<>
<div
ref={itemRef}
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
@@ -263,6 +316,15 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
</div>
)}
{canDelete && (
<div
className="highlight-delete-btn"
title="Delete highlight"
onClick={handleDeleteClick}
>
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
</div>
)}
</div>
<div className="highlight-content">
@@ -301,6 +363,18 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
</div>
</div>
</div>
<ConfirmDialog
isOpen={showDeleteConfirm}
title="Delete Highlight?"
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
confirmText="Delete"
cancelText="Cancel"
variant="danger"
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
)
}

View File

@@ -72,6 +72,11 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
)
}
const handleHighlightDelete = (highlightId: string) => {
// Remove highlight from local state
setLocalHighlights(prev => prev.filter(h => h.id !== highlightId))
}
const filteredHighlights = useFilteredHighlights({
highlights: localHighlights,
selectedUrl,
@@ -129,6 +134,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
relayPool={relayPool}
eventStore={eventStore}
onHighlightUpdate={handleHighlightUpdate}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>

View File

@@ -63,6 +63,11 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
loadHighlights()
}, [relayPool, activeAccount])
const handleHighlightDelete = (highlightId: string) => {
// Remove highlight from local state
setHighlights(prev => prev.filter(h => h.id !== highlightId))
}
if (loading) {
return (
<div className="explore-container">
@@ -102,6 +107,7 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
key={highlight.id}
highlight={highlight}
relayPool={relayPool}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>

View File

@@ -2010,6 +2010,27 @@ body.mobile-sidebar-open {
transform: scale(0.95);
}
.highlight-delete-btn {
position: absolute;
bottom: -2px;
right: 0;
font-size: 0.7rem;
color: #888;
opacity: 0.7;
transition: all 0.2s ease;
cursor: pointer;
}
.highlight-delete-btn:hover {
opacity: 1;
color: #ff4444;
transform: scale(1.1);
}
.highlight-delete-btn:active {
transform: scale(0.95);
}
/* Level-colored quote icon */
.highlight-item.level-mine .highlight-quote-icon {
color: var(--highlight-color-mine, #ffff00);
@@ -3241,3 +3262,160 @@ body.mobile-sidebar-open {
padding: 1rem;
}
}
/* Confirmation Dialog */
.confirm-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(4px);
}
.confirm-dialog {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
padding: 2rem;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.confirm-dialog-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
}
.confirm-dialog-icon.warning {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
border: 2px solid rgba(245, 158, 11, 0.3);
}
.confirm-dialog-icon.danger {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 2px solid rgba(239, 68, 68, 0.3);
}
.confirm-dialog-icon.info {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
border: 2px solid rgba(59, 130, 246, 0.3);
}
.confirm-dialog-title {
font-size: 1.25rem;
font-weight: 600;
color: #ddd;
margin: 0;
text-align: center;
}
.confirm-dialog-message {
font-size: 0.95rem;
color: #999;
margin: 0;
text-align: center;
line-height: 1.5;
}
.confirm-dialog-actions {
display: flex;
gap: 0.75rem;
width: 100%;
margin-top: 0.5rem;
}
.confirm-dialog-btn {
flex: 1;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.confirm-dialog-btn.cancel {
background: #2a2a2a;
color: #ddd;
border: 1px solid #444;
}
.confirm-dialog-btn.cancel:hover {
background: #333;
border-color: #555;
}
.confirm-dialog-btn.confirm {
color: #1a1a1a;
}
.confirm-dialog-btn.confirm.warning {
background: #f59e0b;
}
.confirm-dialog-btn.confirm.warning:hover {
background: #d97706;
}
.confirm-dialog-btn.confirm.danger {
background: #ef4444;
color: white;
}
.confirm-dialog-btn.confirm.danger:hover {
background: #dc2626;
}
.confirm-dialog-btn.confirm.info {
background: #3b82f6;
color: white;
}
.confirm-dialog-btn.confirm.info:hover {
background: #2563eb;
}
.confirm-dialog-btn:active {
transform: scale(0.98);
}
@media (max-width: 768px) {
.confirm-dialog {
padding: 1.5rem;
max-width: 90%;
}
.confirm-dialog-icon {
width: 50px;
height: 50px;
font-size: 1.5rem;
}
.confirm-dialog-title {
font-size: 1.1rem;
}
.confirm-dialog-message {
font-size: 0.9rem;
}
}

View File

@@ -0,0 +1,48 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { RELAYS } from '../config/relays'
/**
* Creates a kind:5 event deletion request (NIP-09)
* @param eventId The ID of the event to delete
* @param eventKind The kind of the event being deleted
* @param reason Optional reason for deletion
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed deletion request event
*/
export async function createDeletionRequest(
eventId: string,
eventKind: number,
reason: string | undefined,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const tags: string[][] = [
['e', eventId],
['k', eventKind.toString()]
]
const draft = await factory.create(async () => ({
kind: 5, // Event Deletion Request
content: reason || '',
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
return signed
}