story-research-zapwall/lib/nostrconnect.ts
Nicolas Cantu fd26c42a17 Replace use.nsec.app with nos2x extension (NIP-07)
- Update NostrConnectService to use nos2x (window.nostr) by default
- Fallback to NostrConnect bridge only if nos2x is not available
- Update NostrRemoteSigner to use window.nostr.signEvent() for signing
- Add TypeScript definitions for NIP-07 window.nostr API
- Update documentation to reflect nos2x as primary authentication method
- Remove default use.nsec.app bridge URL
- All TypeScript checks pass
2025-12-27 23:48:16 +01:00

191 lines
4.9 KiB
TypeScript

import type { NostrConnectState } from '@/types/nostr'
import { nostrService } from './nostr'
import { handleNostrConnectMessage } from './nostrconnectHandler'
// Support for nos2x extension (NIP-07) and NostrConnect (NIP-46)
// nos2x uses window.nostr API directly
// NostrConnect uses a bridge for remote signing
const NOSTRCONNECT_BRIDGE = process.env.NEXT_PUBLIC_NOSTRCONNECT_BRIDGE ?? ''
export class NostrConnectService {
private state: NostrConnectState = {
connected: false,
pubkey: null,
profile: null,
}
private listeners: Set<(state: NostrConnectState) => void> = new Set()
private relayUrl: string = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
constructor() {
if (typeof window !== 'undefined') {
this.loadStateFromStorage()
this.setupMessageListener()
}
}
subscribe(callback: (state: NostrConnectState) => void): () => void {
this.listeners.add(callback)
callback(this.state)
return () => {
this.listeners.delete(callback)
}
}
getState(): NostrConnectState {
return { ...this.state }
}
private createConnectUrl(): string {
if (!NOSTRCONNECT_BRIDGE) {
throw new Error('NostrConnect bridge not configured')
}
const appName = 'zapwall4Science'
const appUrl = window.location.origin
const params = new URLSearchParams({
origin: appUrl,
name: appName,
relay: this.relayUrl,
})
return `${NOSTRCONNECT_BRIDGE}?${params.toString()}`
}
private cleanupPopup(popup: Window | null, checkClosed: number, messageHandler: (event: MessageEvent) => void) {
window.clearInterval(checkClosed)
window.removeEventListener('message', messageHandler)
if (popup && !popup.closed) {
popup.close()
}
}
private createMessageHandler(
resolve: () => void,
reject: (error: Error) => void,
cleanup: () => void
): (event: MessageEvent) => void {
return (event: MessageEvent) => {
handleNostrConnectMessage(
event,
this.state,
(pubkey, _privateKey) => {
this.state = {
connected: true,
pubkey,
profile: null,
}
this.saveStateToStorage()
this.notifyListeners()
void this.loadProfile()
cleanup()
resolve()
},
(error) => {
console.error('Connection error:', error)
cleanup()
reject(error)
}
)
}
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
const url = this.createConnectUrl()
// Open NostrConnect bridge in popup
const popup = window.open(url, 'nostrconnect', 'width=400,height=600,scrollbars=yes,resizable=yes')
if (!popup) {
reject(new Error('Popup blocked. Please allow popups for this site.'))
return
}
const checkClosed = window.setInterval(() => {
if (popup.closed) {
this.cleanupPopup(popup, checkClosed, messageHandler)
if (!this.state.connected) {
reject(new Error('Connection cancelled'))
}
}
}, 1000)
const cleanup = () => this.cleanupPopup(popup, checkClosed, messageHandler)
const messageHandler = this.createMessageHandler(resolve, reject, cleanup)
window.addEventListener('message', messageHandler)
})
}
disconnect(): void {
this.state = {
connected: false,
pubkey: null,
profile: null,
}
this.saveStateToStorage()
this.notifyListeners()
}
private async loadProfile(): Promise<void> {
if (!this.state.pubkey) {
return
}
try {
const profile = await nostrService.getProfile(this.state.pubkey)
if (profile) {
this.state.profile = profile
this.saveStateToStorage()
this.notifyListeners()
}
} catch (e) {
console.error('Error loading profile:', e)
}
}
private setupMessageListener(): void {
window.addEventListener('storage', (e) => {
if (e.key === 'nostrconnect_state') {
this.loadStateFromStorage()
}
})
}
private loadStateFromStorage(): void {
try {
const stored = localStorage.getItem('nostrconnect_state')
if (stored) {
const parsed = JSON.parse(stored)
this.state = {
connected: parsed.connected ?? false,
pubkey: parsed.pubkey ?? null,
profile: parsed.profile ?? null,
}
if (this.state.pubkey) {
nostrService.setPublicKey(this.state.pubkey)
}
}
} catch (e) {
console.error('Error loading state from storage:', e)
}
}
private saveStateToStorage(): void {
try {
localStorage.setItem('nostrconnect_state', JSON.stringify(this.state))
} catch (e) {
console.error('Error saving state to storage:', e)
}
}
private notifyListeners(): void {
this.listeners.forEach((callback) => callback({ ...this.state }))
}
}
export const nostrConnectService = new NostrConnectService()