diff --git a/src/App.tsx b/src/App.tsx index d88f1b5..9d341a4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import './index.css' import { Toaster } from '@/components/ui/sonner' import { BookmarksProvider } from '@/providers/BookmarksProvider' import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' +import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' import { FeedProvider } from '@/providers/FeedProvider' import { FollowListProvider } from '@/providers/FollowListProvider' @@ -24,32 +25,34 @@ export default function App(): JSX.Element { - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index a4bbc68..a4448fb 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -5,6 +5,7 @@ import { isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' +import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/providers/UserTrustProvider' @@ -44,6 +45,7 @@ const NoteList = forwardRef( const { startLogin } = useNostr() const { isUserTrusted } = useUserTrust() const { mutePubkeys } = useMuteList() + const { isEventDeleted } = useDeletedEvent() const [events, setEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) const [hasMore, setHasMore] = useState(true) @@ -58,6 +60,7 @@ const NoteList = forwardRef( const idSet = new Set() return events.slice(0, showCount).filter((evt) => { + if (isEventDeleted(evt)) return false if (hideReplies && isReplyNoteEvent(evt)) return false if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false @@ -68,12 +71,13 @@ const NoteList = forwardRef( idSet.add(id) return true }) - }, [events, hideReplies, hideUntrustedNotes, showCount]) + }, [events, hideReplies, hideUntrustedNotes, showCount, isEventDeleted]) const filteredNewEvents = useMemo(() => { const idSet = new Set() return newEvents.filter((event: Event) => { + if (isEventDeleted(event)) return false if (hideReplies && isReplyNoteEvent(event)) return false if (hideUntrustedNotes && !isUserTrusted(event.pubkey)) return false if (filterMutedNotes && mutePubkeys.includes(event.pubkey)) return false @@ -87,7 +91,7 @@ const NoteList = forwardRef( idSet.add(id) return true }) - }, [newEvents, hideReplies, hideUntrustedNotes, filterMutedNotes, mutePubkeys]) + }, [newEvents, hideReplies, hideUntrustedNotes, filterMutedNotes, mutePubkeys, isEventDeleted]) const scrollToTop = (behavior: ScrollBehavior = 'instant') => { setTimeout(() => { diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index f573b4c..97f3452 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -6,7 +6,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import { Bell, BellOff, Code, Copy, Link, Mail, SatelliteDish, Server } from 'lucide-react' +import { Bell, BellOff, Code, Copy, Link, Mail, SatelliteDish, Server, Trash2 } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -45,7 +45,7 @@ export function useMenuActions({ isSmallScreen }: UseMenuActionsProps) { const { t } = useTranslation() - const { pubkey, relayList } = useNostr() + const { pubkey, relayList, attemptDelete } = useNostr() const { relaySets, favoriteRelays } = useFavoriteRelays() const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeys } = useMuteList() const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event]) @@ -235,6 +235,19 @@ export function useMenuActions({ } } + if (pubkey && event.pubkey === pubkey) { + actions.push({ + icon: Trash2, + label: t('Try deleting this note'), + onClick: () => { + closeDrawer() + attemptDelete(event) + }, + className: 'text-destructive focus:text-destructive', + separator: true + }) + } + return actions }, [ t, diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index e6c7350..acd22f4 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -367,6 +367,8 @@ export default { 'Remember my choice': 'تذكر اختياري', Apply: 'تطبيق', Reset: 'إعادة تعيين', - 'Share something on this Relay': 'شارك شيئاً على هذا الريلاي' + 'Share something on this Relay': 'شارك شيئاً على هذا الريلاي', + 'Try deleting this note': 'حاول حذف هذه الملاحظة', + 'Deletion request sent to {{count}} relays': 'تم إرسال طلب الحذف إلى {{count}} ريلايات' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 80522f7..5c50635 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -375,6 +375,8 @@ export default { 'Remember my choice': 'Meine Auswahl merken', Apply: 'Anwenden', Reset: 'Zurücksetzen', - 'Share something on this Relay': 'Teile etwas auf diesem Relay' + 'Share something on this Relay': 'Teile etwas auf diesem Relay', + 'Try deleting this note': 'Versuche, diese Notiz zu löschen', + 'Deletion request sent to {{count}} relays': 'Löschanfrage an {{count}} Relays gesendet' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 387563b..8a078b0 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -366,6 +366,8 @@ export default { 'Remember my choice': 'Remember my choice', Apply: 'Apply', Reset: 'Reset', - 'Share something on this Relay': 'Share something on this Relay' + 'Share something on this Relay': 'Share something on this Relay', + 'Try deleting this note': 'Try deleting this note', + 'Deletion request sent to {{count}} relays': 'Deletion request sent to {{count}} relays' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 1750e30..fe04447 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -371,6 +371,9 @@ export default { 'Remember my choice': 'Recordar mi elección', Apply: 'Aplicar', Reset: 'Restablecer', - 'Share something on this Relay': 'Comparte algo en este relé' + 'Share something on this Relay': 'Comparte algo en este relé', + 'Try deleting this note': 'Intenta eliminar esta nota', + 'Deletion request sent to {{count}} relays': + 'Solicitud de eliminación enviada a {{count}} relés' } } diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index d730f1a..1ae274e 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -368,6 +368,8 @@ export default { 'Remember my choice': 'انتخاب من را به خاطر بسپار', Apply: 'اعمال', Reset: 'بازنشانی', - 'Share something on this Relay': 'در این رله چیزی به اشتراک بگذارید' + 'Share something on this Relay': 'در این رله چیزی به اشتراک بگذارید', + 'Try deleting this note': 'سعی کنید این یادداشت را حذف کنید', + 'Deletion request sent to {{count}} relays': 'درخواست حذف به {{count}} رله ارسال شد' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 5728c43..2cf28b6 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -373,6 +373,8 @@ export default { 'Remember my choice': 'Se souvenir de mon choix', Apply: 'Appliquer', Reset: 'Réinitialiser', - 'Share something on this Relay': 'Partager quelque chose sur ce relais' + 'Share something on this Relay': 'Partager quelque chose sur ce relais', + 'Try deleting this note': 'Essayez de supprimer cette note', + 'Deletion request sent to {{count}} relays': 'Demande de suppression envoyée à {{count}} relais' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 9f645b2..cc1b6f7 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -371,6 +371,9 @@ export default { 'Remember my choice': 'Ricorda la mia scelta', Apply: 'Applica', Reset: 'Reimposta', - 'Share something on this Relay': 'Condividi qualcosa su questo Relay' + 'Share something on this Relay': 'Condividi qualcosa su questo Relay', + 'Try deleting this note': 'Prova a eliminare questa nota', + 'Deletion request sent to {{count}} relays': + 'Richiesta di eliminazione inviata a {{count}} relays' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 5e4de18..884bed8 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -368,6 +368,9 @@ export default { 'Remember my choice': '選択を記憶', Apply: '適用', Reset: 'リセット', - 'Share something on this Relay': 'このリレーで何かを共有する' + 'Share something on this Relay': 'このリレーで何かを共有する', + 'Try deleting this note': 'このノートを削除してみてください', + 'Deletion request sent to {{count}} relays': + '削除リクエストが{{count}}個のリレーに送信されました' } } diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 5684323..3787590 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -368,6 +368,8 @@ export default { 'Remember my choice': '내 선택 기억하기', Apply: '적용', Reset: '초기화', - 'Share something on this Relay': '이 릴레이에서 무언가를 공유하세요' + 'Share something on this Relay': '이 릴레이에서 무언가를 공유하세요', + 'Try deleting this note': '이 노트를 삭제해 보세요', + 'Deletion request sent to {{count}} relays': '삭제 요청이 {{count}}개의 릴레이로 전송되었습니다' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index f06ce12..82835d1 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -372,6 +372,9 @@ export default { 'Remember my choice': 'Zapamiętaj mój wybór', Apply: 'Zastosuj', Reset: 'Resetuj', - 'Share something on this Relay': 'Udostępnij coś na tym przekaźniku' + 'Share something on this Relay': 'Udostępnij coś na tym przekaźniku', + 'Try deleting this note': 'Spróbuj usunąć ten wpis', + 'Deletion request sent to {{count}} relays': + 'Żądanie usunięcia wysłane do {{count}} przekaźników' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 90390a6..2dcec61 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -369,6 +369,8 @@ export default { 'Remember my choice': 'Lembrar minha escolha', Apply: 'Aplicar', Reset: 'Redefinir', - 'Share something on this Relay': 'Compartilhe algo neste Relay' + 'Share something on this Relay': 'Compartilhe algo neste Relay', + 'Try deleting this note': 'Tente excluir esta nota', + 'Deletion request sent to {{count}} relays': 'Pedido de exclusão enviado para {{count}} relays' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index 02c101f..c329215 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -371,6 +371,9 @@ export default { 'Remember my choice': 'Lembrar a minha escolha', Apply: 'Aplicar', Reset: 'Repor', - 'Share something on this Relay': 'Partilhe algo neste Relay' + 'Share something on this Relay': 'Partilhe algo neste Relay', + 'Try deleting this note': 'Tente eliminar esta nota', + 'Deletion request sent to {{count}} relays': + 'Pedido de eliminação enviado para {{count}} relays' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index f3314e8..e14bdc4 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -372,6 +372,8 @@ export default { 'Remember my choice': 'Запомнить мой выбор', Apply: 'Применить', Reset: 'Сбросить', - 'Share something on this Relay': 'Поделиться чем-то на этом релее' + 'Share something on this Relay': 'Поделиться чем-то на этом релее', + 'Try deleting this note': 'Попробуйте удалить эту заметку', + 'Deletion request sent to {{count}} relays': 'Запрос на удаление отправлен на {{count}} релеев' } } diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 17c7e09..1ec9aca 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -365,6 +365,8 @@ export default { 'Remember my choice': 'จำการเลือกของฉัน', Apply: 'ใช้', Reset: 'รีเซ็ต', - 'Share something on this Relay': 'แชร์บางอย่างบนรีเลย์นี้' + 'Share something on this Relay': 'แชร์บางอย่างบนรีเลย์นี้', + 'Try deleting this note': 'ลองลบโน้ตนี้ดู', + 'Deletion request sent to {{count}} relays': 'คำขอลบถูกส่งไปยังรีเลย์ {{count}} รายการ' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index b3ba4fd..5af5760 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -363,6 +363,8 @@ export default { 'Remember my choice': '记住我的选择', Apply: '应用', Reset: '重置', - 'Share something on this Relay': '在此服务器上分享点什么' + 'Share something on this Relay': '在此服务器上分享点什么', + 'Try deleting this note': '尝试删除此笔记', + 'Deletion request sent to {{count}} relays': '删除请求已发送到 {{count}} 个服务器' } } diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index e4b02d6..7506a63 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -416,6 +416,22 @@ export function createPollResponseDraftEvent( } } +export function createDeletionRequestDraftEvent(event: Event): TDraftEvent { + const tags: string[][] = [buildKTag(event.kind)] + if (isReplaceableEvent(event.kind)) { + tags.push(['a', getReplaceableCoordinateFromEvent(event)]) + } else { + tags.push(['e', event.id]) + } + + return { + kind: kinds.EventDeletion, + content: 'Request for deletion of the event.', + tags, + created_at: dayjs().unix() + } +} + function generateImetaTags(imageUrls: string[]) { return imageUrls .map((imageUrl) => { diff --git a/src/providers/DeletedEventProvider.tsx b/src/providers/DeletedEventProvider.tsx new file mode 100644 index 0000000..a4ac026 --- /dev/null +++ b/src/providers/DeletedEventProvider.tsx @@ -0,0 +1,43 @@ +import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' +import { NostrEvent } from 'nostr-tools' +import { createContext, useCallback, useContext, useState } from 'react' + +type TDeletedEventContext = { + addDeletedEvent: (event: NostrEvent) => void + isEventDeleted: (event: NostrEvent) => boolean +} + +const DeletedEventContext = createContext(undefined) + +export const useDeletedEvent = () => { + const context = useContext(DeletedEventContext) + if (!context) { + throw new Error('useDeletedEvent must be used within a DeletedEventProvider') + } + return context +} + +export function DeletedEventProvider({ children }: { children: React.ReactNode }) { + const [deletedEventKeys, setDeletedEventKeys] = useState>(new Set()) + + const isEventDeleted = useCallback( + (event: NostrEvent) => { + return deletedEventKeys.has(getKey(event)) + }, + [deletedEventKeys] + ) + + const addDeletedEvent = (event: NostrEvent) => { + setDeletedEventKeys((prev) => new Set(prev).add(getKey(event))) + } + + return ( + + {children} + + ) +} + +function getKey(event: NostrEvent) { + return isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id +} diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index d263768..8a567c1 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1,12 +1,13 @@ import LoginDialog from '@/components/LoginDialog' import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { + createDeletionRequestDraftEvent, createFollowListDraftEvent, createMuteListDraftEvent, createRelayListDraftEvent, createSeenNotificationsAtDraftEvent } from '@/lib/draft-event' -import { getLatestEvent, getReplaceableEventIdentifier } from '@/lib/event' +import { getLatestEvent, getReplaceableEventIdentifier, isProtectedEvent } from '@/lib/event' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { formatPubkey, isValidPubkey, pubkeyToNpub } from '@/lib/pubkey' import client from '@/services/client.service' @@ -28,6 +29,7 @@ import { Nip07Signer } from './nip-07.signer' import { NostrConnectionSigner } from './nostrConnection.signer' import { NpubSigner } from './npub.signer' import { NsecSigner } from './nsec.signer' +import { useDeletedEvent } from '../DeletedEventProvider' type TPublishOptions = { specifiedRelayUrls?: string[] @@ -62,6 +64,7 @@ type TNostrContext = { * Default publish the event to current relays, user's write relays and additional relays */ publish: (draftEvent: TDraftEvent, options?: TPublishOptions) => Promise + attemptDelete: (targetEvent: Event) => Promise signHttpAuth: (url: string, method: string) => Promise signEvent: (draftEvent: TDraftEvent) => Promise nip04Encrypt: (pubkey: string, plainText: string) => Promise @@ -91,6 +94,7 @@ export const useNostr = () => { export function NostrProvider({ children }: { children: React.ReactNode }) { const { t } = useTranslation() + const { addDeletedEvent } = useDeletedEvent() const [accounts, setAccounts] = useState( storage.getAccounts().map((act) => ({ pubkey: act.pubkey, signerType: act.signerType })) ) @@ -587,10 +591,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { return event as VerifiedEvent } - const publish = async ( - draftEvent: TDraftEvent, - { specifiedRelayUrls, additionalRelayUrls }: TPublishOptions = {} - ) => { + const publish = async (draftEvent: TDraftEvent, options: TPublishOptions = {}) => { if (!account || !signer || account.signerType === 'npub') { throw new Error('You need to login first') } @@ -610,56 +611,32 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } - const _additionalRelayUrls: string[] = additionalRelayUrls ?? [] - if ( - !specifiedRelayUrls?.length && - ![kinds.Contacts, kinds.Mutelist].includes(draftEvent.kind) - ) { - const mentions: string[] = [] - draftEvent.tags.forEach(([tagName, tagValue]) => { - if ( - ['p', 'P'].includes(tagName) && - !!tagValue && - isValidPubkey(tagValue) && - !mentions.includes(tagValue) - ) { - mentions.push(tagValue) - } - }) - if (mentions.length > 0) { - const relayLists = await client.fetchRelayLists(mentions) - relayLists.forEach((relayList) => { - _additionalRelayUrls.push(...relayList.read.slice(0, 4)) - }) - } + const relays = await determineTargetRelays(event, options) + + await client.publishEvent(relays, event) + return event + } + + const attemptDelete = async (targetEvent: Event) => { + if (!signer) { + throw new Error(t('You need to login first')) } - if ( - [ - kinds.RelayList, - kinds.Contacts, - ExtendedKind.FAVORITE_RELAYS, - ExtendedKind.BLOSSOM_SERVER_LIST - ].includes(draftEvent.kind) - ) { - _additionalRelayUrls.push(...BIG_RELAY_URLS) + if (account?.pubkey !== targetEvent.pubkey) { + throw new Error(t('You can only delete your own notes')) } - let relays: string[] - if (specifiedRelayUrls?.length) { - relays = specifiedRelayUrls - } else { - const relayList = await client.fetchRelayList(event.pubkey) - relays = (relayList?.write.slice(0, 10) ?? []).concat( - Array.from(new Set(_additionalRelayUrls)) ?? [] - ) - } + const deletionRequest = await signEvent(createDeletionRequestDraftEvent(targetEvent)) - if (!relays.length) { - relays.push(...BIG_RELAY_URLS) - } + const seenOn = client.getSeenEventRelayUrls(targetEvent.id) + const relays = await determineTargetRelays(targetEvent, { + specifiedRelayUrls: isProtectedEvent(targetEvent) ? seenOn : undefined, + additionalRelayUrls: seenOn + }) - await client.publishEvent(relays, event) - return event + await client.publishEvent(relays, deletionRequest) + + addDeletedEvent(targetEvent) + toast.success(t('Deletion request sent to {{count}} relays', { count: relays.length })) } const signHttpAuth = async (url: string, method: string, content = '') => { @@ -779,6 +756,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { npubLogin, removeAccount, publish, + attemptDelete, signHttpAuth, nip04Encrypt, nip04Decrypt, @@ -799,3 +777,55 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { ) } + +async function determineTargetRelays( + event: Event, + { specifiedRelayUrls, additionalRelayUrls }: TPublishOptions = {} +) { + const _additionalRelayUrls: string[] = additionalRelayUrls ?? [] + if (!specifiedRelayUrls?.length && ![kinds.Contacts, kinds.Mutelist].includes(event.kind)) { + const mentions: string[] = [] + event.tags.forEach(([tagName, tagValue]) => { + if ( + ['p', 'P'].includes(tagName) && + !!tagValue && + isValidPubkey(tagValue) && + !mentions.includes(tagValue) + ) { + mentions.push(tagValue) + } + }) + if (mentions.length > 0) { + const relayLists = await client.fetchRelayLists(mentions) + relayLists.forEach((relayList) => { + _additionalRelayUrls.push(...relayList.read.slice(0, 4)) + }) + } + } + if ( + [ + kinds.RelayList, + kinds.Contacts, + ExtendedKind.FAVORITE_RELAYS, + ExtendedKind.BLOSSOM_SERVER_LIST + ].includes(event.kind) + ) { + _additionalRelayUrls.push(...BIG_RELAY_URLS) + } + + let relays: string[] + if (specifiedRelayUrls?.length) { + relays = specifiedRelayUrls + } else { + const relayList = await client.fetchRelayList(event.pubkey) + relays = (relayList?.write.slice(0, 10) ?? []).concat( + Array.from(new Set(_additionalRelayUrls)) ?? [] + ) + } + + if (!relays.length) { + relays.push(...BIG_RELAY_URLS) + } + + return relays +}