Connect Nostr via mobile app
This commit is contained in:
@@ -515,7 +515,10 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.osmUrl}}
|
||||
</div>
|
||||
|
||||
{{#if this.osmUrl}}
|
||||
<div class="meta-info">
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="camera" />
|
||||
<span>
|
||||
@@ -524,9 +527,8 @@ export default class PlaceDetails extends Component {
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.isPhotoUploadModalOpen}}
|
||||
|
||||
@@ -3,13 +3,17 @@ import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import Icon from '#components/icon';
|
||||
import { on } from '@ember/modifier';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { eq } from 'ember-truth-helpers';
|
||||
import Modal from './modal';
|
||||
|
||||
export default class UserMenuComponent extends Component {
|
||||
@service storage;
|
||||
@service osmAuth;
|
||||
|
||||
@service nostrAuth;
|
||||
|
||||
@tracked isNostrConnectModalOpen = false;
|
||||
|
||||
@action
|
||||
connectRS() {
|
||||
this.args.onClose();
|
||||
@@ -33,9 +37,31 @@ export default class UserMenuComponent extends Component {
|
||||
}
|
||||
|
||||
@action
|
||||
async connectNostr() {
|
||||
openNostrConnectModal() {
|
||||
this.isNostrConnectModalOpen = true;
|
||||
}
|
||||
|
||||
@action
|
||||
closeNostrConnectModal() {
|
||||
this.isNostrConnectModalOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
async connectNostrExtension() {
|
||||
try {
|
||||
await this.nostrAuth.login();
|
||||
await this.nostrAuth.login('extension');
|
||||
this.closeNostrConnectModal();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async connectNostrApp() {
|
||||
try {
|
||||
await this.nostrAuth.login('connect');
|
||||
this.closeNostrConnectModal();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(e.message);
|
||||
@@ -47,6 +73,10 @@ export default class UserMenuComponent extends Component {
|
||||
this.nostrAuth.logout();
|
||||
}
|
||||
|
||||
get hasExtension() {
|
||||
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="user-menu-popover">
|
||||
<ul class="account-list">
|
||||
@@ -124,7 +154,7 @@ export default class UserMenuComponent extends Component {
|
||||
<button
|
||||
class="btn-text text-primary"
|
||||
type="button"
|
||||
{{on "click" this.connectNostr}}
|
||||
{{on "click" this.openNostrConnectModal}}
|
||||
>Connect</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -140,5 +170,49 @@ export default class UserMenuComponent extends Component {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{#if this.isNostrConnectModalOpen}}
|
||||
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||
<div class="nostr-connect-modal">
|
||||
<h2>Connect with Nostr</h2>
|
||||
|
||||
<div class="nostr-connect-options">
|
||||
{{#if this.hasExtension}}
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
{{on "click" this.connectNostrExtension}}
|
||||
>
|
||||
Browser Extension (nos2x, Alby)
|
||||
</button>
|
||||
{{else}}
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
disabled
|
||||
title="No Nostr extension found in your browser."
|
||||
>
|
||||
Browser Extension (Not Found)
|
||||
</button>
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
{{on "click" this.connectNostrApp}}
|
||||
>
|
||||
Mobile Signer App (Amber, etc.)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{#if (eq this.nostrAuth.connectStatus "waiting")}}
|
||||
<div class="alert alert-info nostr-connect-status">
|
||||
<p>Waiting for you to approve the connection in your mobile signer
|
||||
app...</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</Modal>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -1,64 +1,224 @@
|
||||
import Service from '@ember/service';
|
||||
import Service, { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { ExtensionSigner } from 'applesauce-signers';
|
||||
import {
|
||||
ExtensionSigner,
|
||||
NostrConnectSigner,
|
||||
PrivateKeySigner,
|
||||
} from 'applesauce-signers';
|
||||
|
||||
const STORAGE_KEY = 'marco:nostr_pubkey';
|
||||
const STORAGE_KEY_TYPE = 'marco:nostr_signer_type'; // 'extension' | 'connect'
|
||||
const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key';
|
||||
const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey';
|
||||
const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay';
|
||||
|
||||
export default class NostrAuthService extends Service {
|
||||
@service nostrRelay;
|
||||
|
||||
@tracked pubkey = null;
|
||||
@tracked signerType = null; // 'extension' or 'connect'
|
||||
|
||||
// Track NostrConnect state for the UI
|
||||
@tracked connectStatus = null; // null | 'waiting' | 'connected'
|
||||
@tracked connectUri = null; // For displaying a QR code if needed
|
||||
|
||||
_signerInstance = null;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
const type = localStorage.getItem(STORAGE_KEY_TYPE);
|
||||
if (saved) {
|
||||
this.pubkey = saved;
|
||||
this.signerType = type || 'extension';
|
||||
this._verifyPubkey();
|
||||
}
|
||||
}
|
||||
|
||||
async _verifyPubkey() {
|
||||
if (typeof window.nostr === 'undefined') {
|
||||
this.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const signer = new ExtensionSigner();
|
||||
const extensionPubkey = await signer.getPublicKey();
|
||||
|
||||
if (extensionPubkey !== this.pubkey) {
|
||||
this.pubkey = extensionPubkey;
|
||||
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||
if (this.signerType === 'extension') {
|
||||
if (typeof window.nostr === 'undefined') {
|
||||
this.logout();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const signer = new ExtensionSigner();
|
||||
const extensionPubkey = await signer.getPublicKey();
|
||||
if (extensionPubkey !== this.pubkey) {
|
||||
this.pubkey = extensionPubkey;
|
||||
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to verify extension nostr pubkey, logging out', e);
|
||||
this.logout();
|
||||
}
|
||||
} else if (this.signerType === 'connect') {
|
||||
try {
|
||||
await this._initConnectSigner();
|
||||
} catch (e) {
|
||||
console.warn('Failed to verify connect nostr pubkey, logging out', e);
|
||||
this.logout();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to verify nostr pubkey, logging out', e);
|
||||
this.logout();
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return !!this.pubkey;
|
||||
return (
|
||||
!!this.pubkey &&
|
||||
(this.signerType === 'extension'
|
||||
? typeof window.nostr !== 'undefined'
|
||||
: true)
|
||||
);
|
||||
}
|
||||
|
||||
get signer() {
|
||||
if (typeof window.nostr !== 'undefined') {
|
||||
if (this._signerInstance) return this._signerInstance;
|
||||
|
||||
if (
|
||||
this.signerType === 'extension' &&
|
||||
typeof window.nostr !== 'undefined'
|
||||
) {
|
||||
return new ExtensionSigner();
|
||||
}
|
||||
|
||||
if (this.signerType === 'connect') {
|
||||
// Must be initialized async due to the connect handshakes
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async login() {
|
||||
if (typeof window.nostr === 'undefined') {
|
||||
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
|
||||
async login(type = 'extension') {
|
||||
if (type === 'extension') {
|
||||
if (typeof window.nostr === 'undefined') {
|
||||
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
|
||||
}
|
||||
|
||||
try {
|
||||
this._signerInstance = new ExtensionSigner();
|
||||
this.pubkey = await this._signerInstance.getPublicKey();
|
||||
this.signerType = 'extension';
|
||||
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||
localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
|
||||
return this.pubkey;
|
||||
} catch (error) {
|
||||
console.error('Failed to get public key from extension:', error);
|
||||
throw error;
|
||||
}
|
||||
} else if (type === 'connect') {
|
||||
this.connectStatus = 'waiting';
|
||||
|
||||
try {
|
||||
// Generate or retrieve a local ephemeral keypair
|
||||
let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
|
||||
let localSigner;
|
||||
if (localKeyHex) {
|
||||
localSigner = PrivateKeySigner.fromKey(localKeyHex);
|
||||
} else {
|
||||
localSigner = new PrivateKeySigner();
|
||||
// Store the raw Uint8Array as hex string
|
||||
localKeyHex = Array.from(localSigner.key)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex);
|
||||
}
|
||||
|
||||
// We use a specific relay for the connection handshake.
|
||||
const relay = 'wss://relay.damus.io';
|
||||
localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay);
|
||||
|
||||
this._signerInstance = new NostrConnectSigner({
|
||||
pool: this.nostrRelay.pool,
|
||||
relays: [relay],
|
||||
signer: localSigner,
|
||||
onAuth: async (url) => {
|
||||
// NIP-46 auth callback. Normally the signer app does this natively via notification.
|
||||
// But if it requires an explicit browser window:
|
||||
if (
|
||||
confirm(
|
||||
`Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
|
||||
)
|
||||
) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Set the uri for display (e.g., to redirect via intent)
|
||||
this.connectUri = this._signerInstance.getNostrConnectURI({
|
||||
name: 'Marco',
|
||||
url: window.location.origin,
|
||||
description: 'A privacy-respecting maps application.',
|
||||
icons: [],
|
||||
});
|
||||
|
||||
// Trigger the deep link intent immediately for the user
|
||||
window.location.href = this.connectUri;
|
||||
|
||||
// Start listening to the relay
|
||||
await this._signerInstance.open();
|
||||
|
||||
// Wait for the remote signer to reply with their pubkey
|
||||
await this._signerInstance.waitForSigner();
|
||||
|
||||
// Once connected, get the actual user pubkey
|
||||
this.pubkey = await this._signerInstance.getPublicKey();
|
||||
this.signerType = 'connect';
|
||||
this.connectStatus = 'connected';
|
||||
|
||||
// Save connection state
|
||||
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||
localStorage.setItem(STORAGE_KEY_TYPE, 'connect');
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_CONNECT_REMOTE_PUBKEY,
|
||||
this._signerInstance.remote
|
||||
);
|
||||
|
||||
return this.pubkey;
|
||||
} catch (error) {
|
||||
this.connectStatus = null;
|
||||
console.error('Failed to connect via Nostr Connect:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _initConnectSigner() {
|
||||
const localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
|
||||
const remotePubkey = localStorage.getItem(
|
||||
STORAGE_KEY_CONNECT_REMOTE_PUBKEY
|
||||
);
|
||||
const relay =
|
||||
localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || 'wss://relay.damus.io';
|
||||
|
||||
if (!localKeyHex || !remotePubkey) {
|
||||
throw new Error('Missing Nostr Connect local state.');
|
||||
}
|
||||
|
||||
try {
|
||||
this.pubkey = await this.signer.getPublicKey();
|
||||
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||
return this.pubkey;
|
||||
} catch (error) {
|
||||
console.error('Failed to get public key from extension:', error);
|
||||
throw error;
|
||||
const localSigner = PrivateKeySigner.fromKey(localKeyHex);
|
||||
|
||||
this._signerInstance = new NostrConnectSigner({
|
||||
pool: this.nostrRelay.pool,
|
||||
relays: [relay],
|
||||
signer: localSigner,
|
||||
remote: remotePubkey,
|
||||
onAuth: async (url) => {
|
||||
if (
|
||||
confirm(
|
||||
`Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}`
|
||||
)
|
||||
) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await this._signerInstance.open();
|
||||
// Validate we can still get the pubkey from the remote signer
|
||||
const pubkey = await this._signerInstance.getPublicKey();
|
||||
if (pubkey !== this.pubkey) {
|
||||
throw new Error('Remote signer pubkey mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +231,23 @@ export default class NostrAuthService extends Service {
|
||||
return await this.signer.signEvent(event);
|
||||
}
|
||||
|
||||
logout() {
|
||||
async logout() {
|
||||
this.pubkey = null;
|
||||
this.signerType = null;
|
||||
this.connectStatus = null;
|
||||
this.connectUri = null;
|
||||
if (
|
||||
this._signerInstance &&
|
||||
typeof this._signerInstance.close === 'function'
|
||||
) {
|
||||
await this._signerInstance.close();
|
||||
}
|
||||
this._signerInstance = null;
|
||||
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(STORAGE_KEY_TYPE);
|
||||
localStorage.removeItem(STORAGE_KEY_CONNECT_LOCAL_KEY);
|
||||
localStorage.removeItem(STORAGE_KEY_CONNECT_REMOTE_PUBKEY);
|
||||
localStorage.removeItem(STORAGE_KEY_CONNECT_RELAY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1378,6 +1378,22 @@ button.create-place {
|
||||
}
|
||||
}
|
||||
|
||||
/* Nostr Connect */
|
||||
.nostr-connect-modal h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.nostr-connect-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.nostr-connect-status {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user