helpers.js raw
1 // helpers.js — nostr utils, crypto helpers, content parser
2
3 import { decode as nip19Decode } from 'https://esm.sh/nostr-tools@2.17.0/nip19'
4 import { schnorr } from 'https://esm.sh/@noble/curves@1.8.2/secp256k1'
5 import { bytesToHex } from 'https://esm.sh/@noble/hashes@1.7.2/utils'
6
7 // ─── SW communication ───────────────────────────────────────────────
8
9 export function send(msg) {
10 if (navigator.serviceWorker.controller) {
11 navigator.serviceWorker.controller.postMessage(msg)
12 }
13 }
14
15 // ─── extension mode crypto bridge ───────────────────────────────────
16
17 export async function handleCryptoRequest(type, reqId, pubkey, text) {
18 if (!window.nostr) {
19 send(['CRYPTO_RESULT', reqId, null, 'no extension'])
20 return
21 }
22 try {
23 let result
24 if (type === 'DECRYPT_NIP04') result = await window.nostr.nip04.decrypt(pubkey, text)
25 else if (type === 'ENCRYPT_NIP04') result = await window.nostr.nip04.encrypt(pubkey, text)
26 else if (type === 'DECRYPT_NIP44') result = await window.nostr.nip44.decrypt(pubkey, text)
27 else if (type === 'ENCRYPT_NIP44') result = await window.nostr.nip44.encrypt(pubkey, text)
28 send(['CRYPTO_RESULT', reqId, result, null])
29 } catch (err) {
30 send(['CRYPTO_RESULT', reqId, null, err.message])
31 }
32 }
33
34 // ─── nsec encryption (PBKDF2 + AES-256-GCM) ────────────────────────
35
36 async function deriveKeyPBKDF2(password, salt) {
37 const enc = new TextEncoder()
38 const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
39 return crypto.subtle.deriveKey(
40 { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
41 keyMaterial,
42 { name: 'AES-GCM', length: 256 },
43 false,
44 ['encrypt', 'decrypt']
45 )
46 }
47
48 export async function encryptNsec(nsec, password) {
49 const salt = crypto.getRandomValues(new Uint8Array(32))
50 const iv = crypto.getRandomValues(new Uint8Array(12))
51 const key = await deriveKeyPBKDF2(password, salt)
52 const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(nsec))
53 const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength)
54 combined.set(salt, 0)
55 combined.set(iv, salt.length)
56 combined.set(new Uint8Array(encrypted), salt.length + iv.length)
57 let binary = ''
58 for (let i = 0; i < combined.length; i++) binary += String.fromCharCode(combined[i])
59 return btoa(binary)
60 }
61
62 export async function decryptNsec(encryptedData, password) {
63 const combined = new Uint8Array(atob(encryptedData).split('').map((c) => c.charCodeAt(0)))
64 const salt = combined.slice(0, 32)
65 const iv = combined.slice(32, 44)
66 const ciphertext = combined.slice(44)
67 const key = await deriveKeyPBKDF2(password, salt)
68 const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
69 return new TextDecoder().decode(decrypted)
70 }
71
72 export function decodeNsec(nsec) {
73 const decoded = nip19Decode(nsec)
74 if (decoded.type !== 'nsec') throw new Error('not an nsec')
75 return decoded.data
76 }
77
78 export function pubkeyFromSecret(secretKeyBytes) {
79 return bytesToHex(schnorr.getPublicKey(secretKeyBytes))
80 }
81
82 // ─── nostr helpers ──────────────────────────────────────────────────
83
84 export const PROFILE_RELAYS = [
85 'wss://relay.damus.io', 'wss://relay.nostr.net', 'wss://nos.lol',
86 'wss://purplepag.es', 'wss://relay.snort.social', 'wss://relay.primal.net',
87 'wss://offchain.pub', 'wss://nostr.wine', 'wss://relay.noswhere.com',
88 'wss://nostr-pub.wellorder.net',
89 ]
90
91 export const DEFAULT_RELAYS = ['wss://relay.orly.dev', 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band']
92
93 export function profileRelays(userRelays) {
94 return [...new Set([...userRelays.slice(0, 3), ...PROFILE_RELAYS])]
95 }
96
97 export function shortId(hex) {
98 if (!hex) return '?'
99 return hex.slice(0, 8) + '...' + hex.slice(-4)
100 }
101
102 export function relativeTime(ts) {
103 const diff = Math.floor(Date.now() / 1000) - ts
104 if (diff < 60) return 'now'
105 if (diff < 3600) return Math.floor(diff / 60) + 'm'
106 if (diff < 86400) return Math.floor(diff / 3600) + 'h'
107 return Math.floor(diff / 86400) + 'd'
108 }
109
110 export function parseProfile(event) {
111 try { return JSON.parse(event.content) } catch { return {} }
112 }
113
114 // ─── content parser ─────────────────────────────────────────────────
115
116 const IMAGE_RE = /\.(jpe?g|png|gif|webp|svg)(\?[^\s]*)?$/i
117 const VIDEO_RE = /\.(mp4|webm|mov)(\?[^\s]*)?$/i
118
119 export function parseContent(text) {
120 if (!text) return [{ t: 'text', v: '' }]
121 const TOKEN = /(https?:\/\/[^\s<>"]+)|(nostr:(npub1|note1|nevent1|nprofile1|naddr1)[a-z0-9]+)/gi
122 const segments = []
123 let last = 0, m
124 while ((m = TOKEN.exec(text)) !== null) {
125 if (m.index > last) segments.push({ t: 'text', v: text.slice(last, m.index) })
126 if (m[1]) {
127 const url = m[1].replace(/[.,;:!?)]+$/, '')
128 segments.push(IMAGE_RE.test(url) ? { t: 'image', url } : VIDEO_RE.test(url) ? { t: 'video', url } : { t: 'link', url })
129 last = m.index + url.length
130 TOKEN.lastIndex = last
131 } else if (m[2]) {
132 const raw = m[2], bech32 = raw.slice(6)
133 try {
134 const d = nip19Decode(bech32)
135 if (d.type === 'npub') segments.push({ t: 'mention', pubkey: d.data })
136 else if (d.type === 'nprofile') segments.push({ t: 'mention', pubkey: d.data.pubkey, relays: d.data.relays })
137 else if (d.type === 'note') segments.push({ t: 'noteref', id: d.data })
138 else if (d.type === 'nevent') segments.push({ t: 'noteref', id: d.data.id, relays: d.data.relays })
139 else if (d.type === 'naddr') segments.push({ t: 'addrref', kind: d.data.kind, pubkey: d.data.pubkey, d: d.data.identifier, relays: d.data.relays })
140 else segments.push({ t: 'text', v: raw })
141 } catch { segments.push({ t: 'text', v: raw }) }
142 last = m.index + raw.length
143 TOKEN.lastIndex = last
144 }
145 }
146 if (last < text.length) segments.push({ t: 'text', v: text.slice(last) })
147 return segments
148 }
149
150 // ─── HTML escaping ──────────────────────────────────────────────────
151
152 export function esc(str) {
153 if (!str) return ''
154 return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
155 }
156