- 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
191 lines
4.9 KiB
TypeScript
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()
|