diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000..91e25cb2 --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -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 = ({ + isOpen, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + onConfirm, + onCancel, + variant = 'warning' +}) => { + if (!isOpen) return null + + return ( +
+
e.stopPropagation()}> +
+ +
+

{title}

+

{message}

+
+ + +
+
+
+ ) +} + +export default ConfirmDialog + diff --git a/src/components/HighlightItem.tsx b/src/components/HighlightItem.tsx index 82a5fc90..91cc7f03 100644 --- a/src/components/HighlightItem.tsx +++ b/src/components/HighlightItem.tsx @@ -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 = ({ @@ -32,12 +36,17 @@ export const HighlightItem: React.FC = ({ onHighlightClick, relayPool, eventStore, - onHighlightUpdate + onHighlightUpdate, + onHighlightDelete }) => { const itemRef = useRef(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 = ({ 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 ( + <>
= ({
)} + {canDelete && ( +
+ +
+ )}
@@ -301,6 +363,18 @@ export const HighlightItem: React.FC = ({
+ + + ) } diff --git a/src/components/HighlightsPanel.tsx b/src/components/HighlightsPanel.tsx index 4322999f..e93af712 100644 --- a/src/components/HighlightsPanel.tsx +++ b/src/components/HighlightsPanel.tsx @@ -72,6 +72,11 @@ export const HighlightsPanel: React.FC = ({ ) } + 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 = ({ relayPool={relayPool} eventStore={eventStore} onHighlightUpdate={handleHighlightUpdate} + onHighlightDelete={handleHighlightDelete} /> ))} diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 1c5802d1..b6f0f1b4 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -63,6 +63,11 @@ const Me: React.FC = ({ relayPool }) => { loadHighlights() }, [relayPool, activeAccount]) + const handleHighlightDelete = (highlightId: string) => { + // Remove highlight from local state + setHighlights(prev => prev.filter(h => h.id !== highlightId)) + } + if (loading) { return (
@@ -102,6 +107,7 @@ const Me: React.FC = ({ relayPool }) => { key={highlight.id} highlight={highlight} relayPool={relayPool} + onHighlightDelete={handleHighlightDelete} /> ))}
diff --git a/src/index.css b/src/index.css index 7f399c1b..c3c9a534 100644 --- a/src/index.css +++ b/src/index.css @@ -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; + } +} diff --git a/src/services/deletionService.ts b/src/services/deletionService.ts new file mode 100644 index 00000000..39517f00 --- /dev/null +++ b/src/services/deletionService.ts @@ -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 { + 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 +} +