From 629a308b797ccab0e9a3c8164a071cc9d22bbf1d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A2u=20Cao?=
Date: Sun, 19 Apr 2026 14:44:51 +0400
Subject: [PATCH] Connect Nostr via mobile app
---
app/components/place-details.gjs | 8 +-
app/components/user-menu.gjs | 82 ++++++++++-
app/services/nostr-auth.js | 235 +++++++++++++++++++++++++++----
app/styles/app.css | 16 +++
4 files changed, 304 insertions(+), 37 deletions(-)
diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs
index a665da0..df4d88b 100644
--- a/app/components/place-details.gjs
+++ b/app/components/place-details.gjs
@@ -515,7 +515,10 @@ export default class PlaceDetails extends Component {
{{/if}}
- {{#if this.osmUrl}}
+
+
+ {{#if this.osmUrl}}
+
+ {{/if}}
{{#if this.isPhotoUploadModalOpen}}
diff --git a/app/components/user-menu.gjs b/app/components/user-menu.gjs
index c1bac0f..84b9f85 100644
--- a/app/components/user-menu.gjs
+++ b/app/components/user-menu.gjs
@@ -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';
+ }
+
@@ -140,5 +170,49 @@ export default class UserMenuComponent extends Component {
+
+ {{#if this.isNostrConnectModalOpen}}
+
+
+
Connect with Nostr
+
+
+ {{#if this.hasExtension}}
+
+ {{else}}
+
+ {{/if}}
+
+
+
+
+ {{#if (eq this.nostrAuth.connectStatus "waiting")}}
+
+
Waiting for you to approve the connection in your mobile signer
+ app...
+
+ {{/if}}
+
+
+ {{/if}}
}
diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js
index a90e971..5c10564 100644
--- a/app/services/nostr-auth.js
+++ b/app/services/nostr-auth.js
@@ -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);
}
}
diff --git a/app/styles/app.css b/app/styles/app.css
index 8a27b66..4610463 100644
--- a/app/styles/app.css
+++ b/app/styles/app.css
@@ -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;