11// app/components/export-modal.ts — Export modal: reveal nsec, copy, QR, relay info
2-
32import { getPersona } from '../persona.js'
43import { personaColour } from './persona-picker.js'
54import { escapeHtml } from '../utils/escape.js'
65import { getState } from '../state.js'
76import { generateQR } from './qr.js'
7+ import { identityNodeLabel , identityNodeType } from '../types.js'
88import type { AppPersona } from '../types.js'
9-
109// ── Constants ──────────────────────────────────────────────────
11-
1210const CLIPBOARD_WIPE_MS = 30_000
1311const MODAL_ID = 'export-modal'
14-
12+ const STYLE_ID = 'export-modal-styles'
13+ const STYLES = `
14+ .export-modal::backdrop { background: rgba(0, 0, 0, 0.72); }
15+ .export-modal {
16+ width: min(42rem, calc(100vw - 2rem));
17+ max-width: 42rem;
18+ border: 1px solid var(--border);
19+ border-radius: 10px;
20+ background: var(--bg-surface);
21+ color: var(--text-primary);
22+ padding: 0;
23+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
24+ }
25+ .export-modal__content { padding: 1rem; display: grid; gap: 0.875rem; }
26+ .export-modal__close {
27+ justify-self: end; background: none; border: none; color: var(--text-muted);
28+ font-size: 1.5rem; cursor: pointer; line-height: 1; padding: 0;
29+ }
30+ .export-modal__title { margin: 0; font-size: 1.1rem; color: var(--text-bright); line-height: 1.4; }
31+ .export-modal__badge {
32+ display: inline-flex; width: 1.5rem; height: 1.5rem; border-radius: 999px;
33+ align-items: center; justify-content: center; color: #fff; font-size: 0.75rem; margin: 0 0.35rem;
34+ }
35+ .export-modal__context { display: grid; gap: 0.45rem; }
36+ .export-modal__context p, .export-modal__relays {
37+ margin: 0; font-size: 0.78rem; color: var(--text-secondary); line-height: 1.55;
38+ }
39+ .export-modal__nsec-wrap {
40+ position: relative; border: 1px solid var(--border); border-radius: 8px;
41+ background: var(--bg-deep); padding: 0.9rem; overflow: hidden;
42+ }
43+ .export-modal__label {
44+ display: block; font-size: 0.68rem; letter-spacing: 0.08em; text-transform: uppercase;
45+ color: var(--text-muted); margin-bottom: 0.5rem;
46+ }
47+ .export-modal__nsec {
48+ display: block; font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.55;
49+ word-break: break-all; white-space: pre-wrap; padding-right: 0.25rem;
50+ }
51+ .export-modal__reveal-overlay {
52+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
53+ background: rgba(10, 13, 18, 0.72); color: var(--text-bright); font-size: 0.82rem; cursor: pointer;
54+ }
55+ .export-modal__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
56+ .export-modal__qr { border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem; background: #fff; justify-self: center; }
57+ `
1558// ── Public API ─────────────────────────────────────────────────
16-
1759/**
1860 * Show an export modal for a persona's nsec, with context, reveal,
1961 * copy (nsec + npub), QR (npub only), and relay info.
2062 */
2163export function showExportModal ( persona : AppPersona ) : void {
22- // Remove any existing export modal
2364 document . getElementById ( MODAL_ID ) ?. remove ( )
24-
65+ ensureStyles ( )
2566 const colour = personaColour ( persona . name )
2667 const displayName = persona . displayName ?? persona . name
2768 const settings = getState ( ) . settings
28-
29- // Derive the full persona (with private key material)
69+ const nodeLabel = identityNodeLabel ( persona ) . toLowerCase ( )
70+ const isAccount = identityNodeType ( persona ) === 'account'
3071 const fullPersona = getPersona ( persona . name , persona . index )
3172 const nsec = fullPersona . identity . nsec
3273 const npub = fullPersona . identity . npub
33-
34- // Relay info
3574 const relays = persona . writeRelays ?. length
3675 ? persona . writeRelays
3776 : settings . defaultWriteRelays
38-
39- // ── Build dialog ───────────────────────────────────────────
40-
4177 const dialog = document . createElement ( 'dialog' )
4278 dialog . id = MODAL_ID
4379 dialog . className = 'modal export-modal'
44-
45- // All interpolated values are escaped via escapeHtml() — safe innerHTML usage
46- // consistent with the rest of the codebase (header.ts, persona-picker.ts, modal.ts)
4780 dialog . innerHTML = `
4881 <div class="export-modal__content">
4982 <button class="export-modal__close" type="button" aria-label="Close">×</button>
50-
5183 <h2 class="export-modal__title">
5284 Export nsec for
5385 <span class="export-modal__badge" style="background-color:${ colour } ">${ escapeHtml ( displayName . slice ( 0 , 1 ) . toUpperCase ( ) ) } </span>
5486 ${ escapeHtml ( displayName ) }
5587 </h2>
56-
5788 <div class="export-modal__context">
58- <p>This key gives full control of this persona . Only paste it into Nostr clients you trust.</p>
59- <p>If this key is compromised, you can rotate the persona from here — your other personas are unaffected. </p>
89+ <p>This key gives full control of this ${ escapeHtml ( nodeLabel ) } . Only paste it into Nostr clients you trust.</p>
90+ <p>${ isAccount ? 'Anonymous accounts are unlinkable by default unless you later choose to generate a proof.' : ' If this key is compromised, you can rotate this persona without affecting your other branches.' } </p>
6091 <p>Clipboard auto-clears after 30 seconds.</p>
6192 </div>
62-
6393 <div class="export-modal__nsec-wrap" id="export-nsec-wrap">
94+ <span class="export-modal__label">${ escapeHtml ( nodeLabel ) } nsec</span>
6495 <code class="export-modal__nsec" id="export-nsec-code" style="filter:blur(5px);user-select:none;">${ escapeHtml ( nsec ) } </code>
6596 <div class="export-modal__reveal-overlay" id="export-reveal-overlay">Click to reveal</div>
6697 </div>
67-
6898 <div class="export-modal__actions">
6999 <button class="btn btn--sm" id="export-copy-nsec" type="button">Copy nsec</button>
70100 <button class="btn btn--sm" id="export-copy-npub" type="button">Copy npub</button>
71101 <button class="btn btn--sm" id="export-toggle-qr" type="button">Show QR</button>
72102 </div>
73-
74103 <div class="export-modal__qr" id="export-qr-area" hidden></div>
75-
76104 <div class="export-modal__relays">
77- This persona publishes to: ${ relays . map ( ( r ) => `<code>${ escapeHtml ( r ) } </code>` ) . join ( ', ' ) || '<em>default relays</em>' }
105+ This ${ escapeHtml ( nodeLabel ) } publishes to: ${ relays . map ( ( r ) => `<code>${ escapeHtml ( r ) } </code>` ) . join ( ', ' ) || '<em>default relays</em>' }
78106 </div>
79107 </div>
80108 `
81-
82109 document . body . appendChild ( dialog )
83-
84- // ── Wire: close ────────────────────────────────────────────
85-
86- const closeBtn = dialog . querySelector < HTMLButtonElement > ( '.export-modal__close' )
87- closeBtn ?. addEventListener ( 'click' , ( ) => closeExportModal ( dialog ) )
88-
110+ dialog . querySelector < HTMLButtonElement > ( '.export-modal__close' ) ?. addEventListener ( 'click' , ( ) => closeExportModal ( dialog ) )
89111 dialog . addEventListener ( 'click' , ( e ) => {
90112 if ( e . target === dialog ) closeExportModal ( dialog )
91113 } )
92-
93114 dialog . addEventListener ( 'cancel' , ( ) => closeExportModal ( dialog ) )
94-
95- // ── Wire: reveal ───────────────────────────────────────────
96-
97115 const nsecWrap = dialog . querySelector < HTMLElement > ( '#export-nsec-wrap' )
98116 const nsecCode = dialog . querySelector < HTMLElement > ( '#export-nsec-code' )
99117 const overlay = dialog . querySelector < HTMLElement > ( '#export-reveal-overlay' )
100-
101118 nsecWrap ?. addEventListener ( 'click' , ( ) => {
102119 if ( ! nsecCode || ! overlay ) return
103120 nsecCode . style . filter = 'none'
104121 nsecCode . style . userSelect = 'all'
105122 overlay . hidden = true
106123 } )
107-
108- // ── Wire: copy nsec ────────────────────────────────────────
109-
110124 wireClipboardButton ( dialog , '#export-copy-nsec' , nsec , 'Copy nsec' )
111-
112- // ── Wire: copy npub ────────────────────────────────────────
113-
114125 wireClipboardButton ( dialog , '#export-copy-npub' , npub , 'Copy npub' )
115-
116- // ── Wire: QR toggle ────────────────────────────────────────
117-
118126 const qrBtn = dialog . querySelector < HTMLButtonElement > ( '#export-toggle-qr' )
119127 const qrArea = dialog . querySelector < HTMLElement > ( '#export-qr-area' )
120128 let qrVisible = false
121-
122129 qrBtn ?. addEventListener ( 'click' , ( ) => {
123130 if ( ! qrArea || ! qrBtn ) return
124131 qrVisible = ! qrVisible
125132 if ( qrVisible ) {
126- // generateQR returns sanitised SVG from qrcode-generator — safe innerHTML
127133 qrArea . innerHTML = generateQR ( npub )
128134 qrArea . hidden = false
129135 qrBtn . textContent = 'Hide QR'
@@ -133,15 +139,9 @@ export function showExportModal(persona: AppPersona): void {
133139 qrBtn . textContent = 'Show QR'
134140 }
135141 } )
136-
137- // ── Show ───────────────────────────────────────────────────
138-
139142 dialog . showModal ( )
140143}
141-
142144// ── Internal helpers ───────────────────────────────────────────
143-
144- /** Wire a clipboard-copy button with 30 s auto-wipe. */
145145function wireClipboardButton (
146146 dialog : HTMLElement ,
147147 selector : string ,
@@ -158,9 +158,14 @@ function wireClipboardButton(
158158 } catch { /* clipboard may be blocked */ }
159159 } )
160160}
161-
162- /** Close and remove the dialog from the DOM. */
163161function closeExportModal ( dialog : HTMLDialogElement ) : void {
164162 dialog . close ( )
165163 dialog . remove ( )
166164}
165+ function ensureStyles ( ) : void {
166+ if ( document . getElementById ( STYLE_ID ) ) return
167+ const style = document . createElement ( 'style' )
168+ style . id = STYLE_ID
169+ style . textContent = STYLES
170+ document . head . appendChild ( style )
171+ }
0 commit comments