feat: add explicit protected event toggle in post editor

Add a Switch control next to the relay selector for users to explicitly
enable/disable NIP-70 protected events. Auto-enables when relay
conditions suggest it (no optimal relays), but respects user override.
Includes a help popover explaining protected events with a note that
not all relays support them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
codytseng 2026-03-19 23:03:56 +08:00
parent bd6340bbc7
commit 6cca3969ce
20 changed files with 96 additions and 34 deletions

View file

@ -14,11 +14,14 @@ import { useNostr } from '@/providers/NostrProvider'
import postEditorCache from '@/services/post-editor-cache.service'
import threadService from '@/services/thread.service'
import { TPollCreateData } from '@/types'
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
import { CircleHelp, ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Switch } from '@/components/ui/switch'
import EmojiPickerDialog from '../EmojiPickerDialog'
import Mentions from './Mentions'
import PollEditor from './PollEditor'
@ -67,6 +70,18 @@ export default function PostContent({
relays: []
})
const [minPow, setMinPow] = useState(0)
const userDismissedProtected = useRef(false)
const handleProtectedSuggestionChange = useCallback((suggested: boolean) => {
if (suggested && !userDismissedProtected.current) {
setIsProtectedEvent(true)
}
}, [])
const handleProtectedToggle = useCallback((checked: boolean) => {
if (!checked) {
userDismissedProtected.current = true
}
setIsProtectedEvent(checked)
}, [])
const isFirstRender = useRef(true)
const canPost = useMemo(() => {
return (
@ -257,12 +272,39 @@ export default function PostContent({
</div>
))}
{!isPoll && (
<PostRelaySelector
setIsProtectedEvent={setIsProtectedEvent}
setAdditionalRelayUrls={setAdditionalRelayUrls}
parentEvent={parentEvent}
openFrom={openFrom}
/>
<div className="flex items-center gap-3">
<div className="min-w-0">
<PostRelaySelector
onProtectedSuggestionChange={handleProtectedSuggestionChange}
setAdditionalRelayUrls={setAdditionalRelayUrls}
parentEvent={parentEvent}
openFrom={openFrom}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Switch
id="protected-event"
checked={isProtectedEvent}
onCheckedChange={handleProtectedToggle}
/>
<Label
htmlFor="protected-event"
className="cursor-pointer text-xs text-muted-foreground"
>
{t('Protected')}
</Label>
<Popover>
<PopoverTrigger asChild>
<button type="button" className="flex shrink-0">
<CircleHelp className="!size-3.5 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="text-sm">
{t('Protected event hint')}
</PopoverContent>
</Popover>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">

View file

@ -38,12 +38,12 @@ type TPostTargetItem =
export default function PostRelaySelector({
parentEvent,
openFrom,
setIsProtectedEvent,
onProtectedSuggestionChange,
setAdditionalRelayUrls
}: {
parentEvent?: NostrEvent
openFrom?: string[]
setIsProtectedEvent: Dispatch<SetStateAction<boolean>>
onProtectedSuggestionChange: (suggested: boolean) => void
setAdditionalRelayUrls: Dispatch<SetStateAction<string[]>>
}) {
const { t } = useTranslation()
@ -108,7 +108,8 @@ export default function PostRelaySelector({
}, [openFrom, parentEventSeenOnRelays])
useEffect(() => {
const isProtectedEvent = postTargetItems.every((item) => item.type !== 'optimalRelays')
const shouldProtect =
postTargetItems.length > 0 && postTargetItems.every((item) => item.type !== 'optimalRelays')
const relayUrls = postTargetItems.flatMap((item) => {
if (item.type === 'relay') {
return [item.url]
@ -119,7 +120,7 @@ export default function PostRelaySelector({
return []
})
setIsProtectedEvent(isProtectedEvent)
onProtectedSuggestionChange(shouldProtect)
setAdditionalRelayUrls(relayUrls)
}, [postTargetItems])
@ -208,10 +209,10 @@ export default function PostRelaySelector({
return (
<>
<div className="flex items-center gap-2">
<Label>{t('Post to')}</Label>
<Label className="shrink-0">{t('Post to')}</Label>
<Button
variant="outline"
className="max-w-fit flex-1 justify-start px-2"
className="min-w-0 max-w-fit justify-start px-2"
onClick={() => setIsDrawerOpen(true)}
>
<div className="truncate">{description}</div>
@ -235,9 +236,9 @@ export default function PostRelaySelector({
return (
<DropdownMenu>
<div className="flex items-center gap-2">
<Label>{t('Post to')}</Label>
<Label className="shrink-0">{t('Post to')}</Label>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="max-w-fit flex-1 justify-start px-2">
<Button variant="outline" className="min-w-0 max-w-fit justify-start px-2">
<div className="truncate">{description}</div>
</Button>
</DropdownMenuTrigger>

View file

@ -673,6 +673,7 @@ export default {
'Automatically replay videos when they end': 'إعادة تشغيل مقاطع الفيديو تلقائيًا عند انتهائها',
'Relays used for searching notes (NIP-50)': 'الريلايات المستخدمة للبحث عن الملاحظات (NIP-50)',
'Protected event (NIP-70)': 'حدث محمي (NIP-70)',
'Protected': 'محمي'
'Protected': 'محمي',
'Protected event hint': 'الأحداث المحمية (NIP-70) لا يمكن نشرها إلا من قبل المؤلف. سترفض الخوادم هذه الأحداث من أطراف ثالثة، مما يمنع الآخرين من إعادة بث محتواك. ملاحظة: لا تدعم جميع الخوادم الأحداث المحمية.'
}
}

View file

@ -697,6 +697,7 @@ export default {
'Automatically replay videos when they end': 'Videos automatisch wiederholen, wenn sie enden',
'Relays used for searching notes (NIP-50)': 'Relays für die Notizsuche (NIP-50)',
'Protected event (NIP-70)': 'Geschütztes Ereignis (NIP-70)',
'Protected': 'Geschützt'
'Protected': 'Geschützt',
'Protected event hint': 'Geschützte Ereignisse (NIP-70) können nur vom Autor veröffentlicht werden. Relays lehnen diese Ereignisse von Dritten ab und verhindern so, dass andere Ihre Inhalte weiterverbreiten. Hinweis: Nicht alle Relays unterstützen geschützte Ereignisse.'
}
}

View file

@ -679,6 +679,8 @@ export default {
'Automatically replay videos when they end': 'Automatically replay videos when they end',
'Relays used for searching notes (NIP-50)': 'Relays used for searching notes (NIP-50)',
'Protected event (NIP-70)': 'Protected event (NIP-70)',
'Protected': 'Protected'
'Protected': 'Protected',
'Protected event hint':
'Protected events (NIP-70) can only be published by the author. Relays will reject these events from third parties, preventing others from rebroadcasting your content. Note: not all relays support protected events.'
}
}

View file

@ -690,6 +690,7 @@ export default {
'Automatically replay videos when they end': 'Reproducir automáticamente los videos cuando terminen',
'Relays used for searching notes (NIP-50)': 'Relés utilizados para buscar notas (NIP-50)',
'Protected event (NIP-70)': 'Evento protegido (NIP-70)',
'Protected': 'Protegido'
'Protected': 'Protegido',
'Protected event hint': 'Los eventos protegidos (NIP-70) solo pueden ser publicados por el autor. Los relés rechazarán estos eventos de terceros, evitando que otros redistribuyan tu contenido. Nota: no todos los relés admiten eventos protegidos.'
}
}

View file

@ -685,6 +685,7 @@ export default {
'Automatically replay videos when they end': 'پخش خودکار ویدیوها پس از پایان',
'Relays used for searching notes (NIP-50)': 'رله‌هایی که برای جستجوی یادداشت‌ها استفاده می‌شوند (NIP-50)',
'Protected event (NIP-70)': 'رویداد محافظت‌شده (NIP-70)',
'Protected': 'محافظت‌شده'
'Protected': 'محافظت‌شده',
'Protected event hint': 'رویدادهای محافظت‌شده (NIP-70) فقط توسط نویسنده قابل انتشار هستند. رله‌ها این رویدادها را از اشخاص ثالث رد می‌کنند و از بازنشر محتوای شما توسط دیگران جلوگیری می‌کنند. توجه: همه رله‌ها از رویدادهای محافظت‌شده پشتیبانی نمی‌کنند.'
}
}

View file

@ -694,6 +694,7 @@ export default {
'Automatically replay videos when they end': 'Rejouer automatiquement les vidéos à la fin',
'Relays used for searching notes (NIP-50)': 'Relais utilisés pour rechercher des notes (NIP-50)',
'Protected event (NIP-70)': 'Événement protégé (NIP-70)',
'Protected': 'Protégé'
'Protected': 'Protégé',
'Protected event hint': 'Les événements protégés (NIP-70) ne peuvent être publiés que par l\'auteur. Les relais rejetteront ces événements provenant de tiers, empêchant les autres de rediffuser votre contenu. Remarque : tous les relais ne prennent pas en charge les événements protégés.'
}
}

View file

@ -685,6 +685,7 @@ export default {
'Automatically replay videos when they end': 'वीडियो समाप्त होने पर स्वचालित रूप से दोबारा चलाएं',
'Relays used for searching notes (NIP-50)': 'नोट्स खोजने के लिए उपयोग किए जाने वाले रिले (NIP-50)',
'Protected event (NIP-70)': 'संरक्षित इवेंट (NIP-70)',
'Protected': 'संरक्षित'
'Protected': 'संरक्षित',
'Protected event hint': 'संरक्षित इवेंट (NIP-70) केवल लेखक द्वारा प्रकाशित किए जा सकते हैं। रिले तीसरे पक्ष से इन इवेंट को अस्वीकार कर देंगे, जिससे दूसरों को आपकी सामग्री को पुनः प्रसारित करने से रोका जा सके। नोट: सभी रिले संरक्षित इवेंट का समर्थन नहीं करते।'
}
}

View file

@ -679,6 +679,7 @@ export default {
'Automatically replay videos when they end': 'Videók automatikus újrajátszása, amikor véget érnek',
'Relays used for searching notes (NIP-50)': 'Jegyzetek kereséséhez használt csomópontok (NIP-50)',
'Protected event (NIP-70)': 'Védett esemény (NIP-70)',
'Protected': 'Védett'
'Protected': 'Védett',
'Protected event hint': 'A védett eseményeket (NIP-70) csak a szerző teheti közzé. A csomópontok elutasítják ezeket az eseményeket harmadik felektől, megakadályozva, hogy mások újraközvetítsék a tartalmadat. Megjegyzés: nem minden csomópont támogatja a védett eseményeket.'
}
}

View file

@ -690,6 +690,7 @@ export default {
'Automatically replay videos when they end': 'Riprodurre automaticamente i video quando terminano',
'Relays used for searching notes (NIP-50)': 'Relay utilizzati per cercare le note (NIP-50)',
'Protected event (NIP-70)': 'Evento protetto (NIP-70)',
'Protected': 'Protetto'
'Protected': 'Protetto',
'Protected event hint': 'Gli eventi protetti (NIP-70) possono essere pubblicati solo dall\'autore. I relay rifiuteranno questi eventi da terze parti, impedendo ad altri di ridiffondere i tuoi contenuti. Nota: non tutti i relay supportano gli eventi protetti.'
}
}

View file

@ -685,6 +685,7 @@ export default {
'Automatically replay videos when they end': 'ビデオ終了時に自動的にリプレイする',
'Relays used for searching notes (NIP-50)': 'ノート検索に使用するリレー (NIP-50)',
'Protected event (NIP-70)': '保護されたイベント (NIP-70)',
'Protected': '保護'
'Protected': '保護',
'Protected event hint': '保護されたイベントNIP-70は作成者のみが公開できます。リレーは第三者からのこれらのイベントを拒否し、他者によるコンテンツの再配信を防ぎます。 注意:すべてのリレーが保護されたイベントに対応しているわけではありません。'
}
}

View file

@ -679,6 +679,7 @@ export default {
'Automatically replay videos when they end': '비디오가 끝나면 자동으로 다시 재생',
'Relays used for searching notes (NIP-50)': '노트 검색에 사용되는 릴레이 (NIP-50)',
'Protected event (NIP-70)': '보호된 이벤트 (NIP-70)',
'Protected': '보호됨'
'Protected': '보호됨',
'Protected event hint': '보호된 이벤트(NIP-70)는 작성자만 게시할 수 있습니다. 릴레이는 제3자의 이벤트를 거부하여 다른 사람이 콘텐츠를 재배포하는 것을 방지합니다. 참고: 모든 릴레이가 보호된 이벤트를 지원하는 것은 아닙니다.'
}
}

View file

@ -691,6 +691,7 @@ export default {
'Automatically replay videos when they end': 'Automatycznie powtarzaj filmy po zakończeniu',
'Relays used for searching notes (NIP-50)': 'Przekaźniki używane do wyszukiwania notatek (NIP-50)',
'Protected event (NIP-70)': 'Chronione zdarzenie (NIP-70)',
'Protected': 'Chronione'
'Protected': 'Chronione',
'Protected event hint': 'Chronione zdarzenia (NIP-70) mogą być publikowane tylko przez autora. Przekaźniki odrzucą te zdarzenia od osób trzecich, uniemożliwiając innym retransmisję Twoich treści. Uwaga: nie wszystkie przekaźniki obsługują chronione zdarzenia.'
}
}

View file

@ -688,6 +688,7 @@ export default {
'Automatically replay videos when they end': 'Reproduzir automaticamente os vídeos quando terminarem',
'Relays used for searching notes (NIP-50)': 'Relays usados para buscar notas (NIP-50)',
'Protected event (NIP-70)': 'Evento protegido (NIP-70)',
'Protected': 'Protegido'
'Protected': 'Protegido',
'Protected event hint': 'Eventos protegidos (NIP-70) só podem ser publicados pelo autor. Os relays rejeitarão esses eventos de terceiros, impedindo que outros retransmitam seu conteúdo. Nota: nem todos os relays suportam eventos protegidos.'
}
}

View file

@ -691,6 +691,7 @@ export default {
'Automatically replay videos when they end': 'Reproduzir automaticamente os vídeos quando terminarem',
'Relays used for searching notes (NIP-50)': 'Relés usados para pesquisar notas (NIP-50)',
'Protected event (NIP-70)': 'Evento protegido (NIP-70)',
'Protected': 'Protegido'
'Protected': 'Protegido',
'Protected event hint': 'Eventos protegidos (NIP-70) só podem ser publicados pelo autor. Os relés rejeitarão estes eventos de terceiros, impedindo que outros retransmitam o seu conteúdo. Nota: nem todos os relés suportam eventos protegidos.'
}
}

View file

@ -690,6 +690,7 @@ export default {
'Automatically replay videos when they end': 'Автоматически воспроизводить видео заново после окончания',
'Relays used for searching notes (NIP-50)': 'Ретрансляторы для поиска заметок (NIP-50)',
'Protected event (NIP-70)': 'Защищённое событие (NIP-70)',
'Protected': 'Защищённый'
'Protected': 'Защищённый',
'Protected event hint': 'Защищённые события (NIP-70) могут быть опубликованы только автором. Ретрансляторы отклонят эти события от третьих лиц, предотвращая повторную трансляцию вашего контента. Примечание: не все ретрансляторы поддерживают защищённые события.'
}
}

View file

@ -675,6 +675,7 @@ export default {
'Automatically replay videos when they end': 'เล่นวิดีโอซ้ำอัตโนมัติเมื่อจบ',
'Relays used for searching notes (NIP-50)': 'รีเลย์ที่ใช้สำหรับค้นหาโน้ต (NIP-50)',
'Protected event (NIP-70)': 'เหตุการณ์ที่ได้รับการป้องกัน (NIP-70)',
'Protected': 'ป้องกัน'
'Protected': 'ป้องกัน',
'Protected event hint': 'เหตุการณ์ที่ได้รับการป้องกัน (NIP-70) สามารถเผยแพร่ได้โดยผู้เขียนเท่านั้น รีเลย์จะปฏิเสธเหตุการณ์เหล่านี้จากบุคคลที่สาม ป้องกันไม่ให้ผู้อื่นเผยแพร่เนื้อหาของคุณซ้ำ หมายเหตุ: รีเลย์บางแห่งไม่รองรับเหตุการณ์ที่ได้รับการป้องกัน'
}
}

View file

@ -657,6 +657,7 @@ export default {
'Automatically replay videos when they end': '影片播放結束後自動重新播放',
'Relays used for searching notes (NIP-50)': '用於搜尋筆記的伺服器 (NIP-50)',
'Protected event (NIP-70)': '受保護的事件 (NIP-70)',
'Protected': '受保護'
'Protected': '受保護',
'Protected event hint': '受保護的事件NIP-70只能由作者發布。伺服器將拒絕來自第三方的這些事件防止他人轉播你的內容。 注意:並非所有伺服器都支持受保護的事件。'
}
}

View file

@ -662,6 +662,7 @@ export default {
'Automatically replay videos when they end': '视频播放结束后自动重新播放',
'Relays used for searching notes (NIP-50)': '用于搜索笔记的服务器 (NIP-50)',
'Protected event (NIP-70)': '受保护的事件 (NIP-70)',
'Protected': '受保护'
'Protected': '受保护',
'Protected event hint': '受保护的事件NIP-70只能由作者发布。服务器将拒绝来自第三方的这些事件防止他人转播你的内容。 注意:并非所有服务器都支持受保护的事件。'
}
}