Compare commits

...

3 Commits

7 changed files with 373 additions and 87 deletions

View File

@@ -0,0 +1,81 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { on } from '@ember/modifier';
import { eq } from 'ember-truth-helpers';
export default class NostrConnectComponent extends Component {
@service nostrAuth;
get hasExtension() {
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
}
@action
async connectExtension() {
try {
await this.nostrAuth.login('extension');
if (this.args.onConnect) {
this.args.onConnect();
}
} catch (e) {
console.error(e);
alert(e.message);
}
}
@action
async connectApp() {
try {
await this.nostrAuth.login('connect');
if (this.args.onConnect) {
this.args.onConnect();
}
} catch (e) {
console.error(e);
alert(e.message);
}
}
<template>
<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.connectExtension}}
>
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.connectApp}}
>
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>
</template>
}

View File

@@ -10,6 +10,7 @@ import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload';
import NostrConnect from './nostr-connect';
import Modal from './modal';
import { tracked } from '@glimmer/tracking';
@@ -17,16 +18,22 @@ import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@service storage;
@service nostrAuth;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false;
@action
openPhotoUploadModal(e) {
if (e) {
e.preventDefault();
}
this.isPhotoUploadModalOpen = true;
if (!this.nostrAuth.isConnected) {
this.isNostrConnectModalOpen = true;
} else {
this.isPhotoUploadModalOpen = true;
}
}
@action
@@ -34,6 +41,17 @@ export default class PlaceDetails extends Component {
this.isPhotoUploadModalOpen = false;
}
@action
closeNostrConnectModal() {
this.isNostrConnectModalOpen = false;
}
@action
onNostrConnected() {
this.isNostrConnectModalOpen = false;
this.isPhotoUploadModalOpen = true;
}
get isSaved() {
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
}
@@ -515,7 +533,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 +545,8 @@ export default class PlaceDetails extends Component {
</a>
</span>
</p>
{{/if}}
</div>
</div>
{{/if}}
</div>
{{#if this.isPhotoUploadModalOpen}}
@@ -534,5 +554,11 @@ export default class PlaceDetails extends Component {
<PlacePhotoUpload @place={{this.saveablePlace}} />
</Modal>
{{/if}}
{{#if this.isNostrConnectModalOpen}}
<Modal @onClose={{this.closeNostrConnectModal}}>
<NostrConnect @onConnect={{this.onNostrConnected}} />
</Modal>
{{/if}}
</template>
}

View File

@@ -22,16 +22,6 @@ export default class PlacePhotoUpload extends Component {
return this.place.title || 'this place';
}
@action
async login() {
try {
this.error = '';
await this.nostrAuth.login();
} catch (e) {
this.error = e.message;
}
}
@action
async uploadPhoto(event) {
event.preventDefault();
@@ -133,36 +123,25 @@ export default class PlacePhotoUpload extends Component {
</div>
{{/if}}
{{#if this.nostrAuth.isConnected}}
<div class="connected-status">
<strong>Connected:</strong>
{{this.nostrAuth.pubkey}}
</div>
<form {{on "submit" this.uploadPhoto}}>
{{#if this.photoUrl}}
<div class="preview-group">
<p>Photo Preview:</p>
<img src={{this.photoUrl}} alt="Preview" />
</div>
<button
type="button"
class="btn btn-primary"
{{on "click" this.publish}}
>
Publish Event (kind: 360)
</button>
{{else}}
<button type="submit" class="btn btn-secondary">
Mock Upload Photo
</button>
{{/if}}
</form>
{{else}}
<button type="button" class="btn btn-primary" {{on "click" this.login}}>
Connect Nostr Extension
</button>
{{/if}}
<form {{on "submit" this.uploadPhoto}}>
{{#if this.photoUrl}}
<div class="preview-group">
<p>Photo Preview:</p>
<img src={{this.photoUrl}} alt="Preview" />
</div>
<button
type="button"
class="btn btn-primary"
{{on "click" this.publish}}
>
Publish Event (kind: 360)
</button>
{{else}}
<button type="submit" class="btn btn-secondary">
Mock Upload Photo
</button>
{{/if}}
</form>
</div>
</template>
}

View File

@@ -1,15 +1,19 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { inject as service } from '@ember/service';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';
import Modal from './modal';
import NostrConnect from './nostr-connect';
export default class UserMenuComponent extends Component {
@service storage;
@service osmAuth;
@service nostrAuth;
@tracked isNostrConnectModalOpen = false;
@action
connectRS() {
this.args.onClose();
@@ -33,13 +37,13 @@ export default class UserMenuComponent extends Component {
}
@action
async connectNostr() {
try {
await this.nostrAuth.login();
} catch (e) {
console.error(e);
alert(e.message);
}
openNostrConnectModal() {
this.isNostrConnectModalOpen = true;
}
@action
closeNostrConnectModal() {
this.isNostrConnectModalOpen = false;
}
@action
@@ -124,7 +128,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 +144,11 @@ export default class UserMenuComponent extends Component {
</li>
</ul>
</div>
{{#if this.isNostrConnectModalOpen}}
<Modal @onClose={{this.closeNostrConnectModal}}>
<NostrConnect @onConnect={{this.closeNostrConnectModal}} />
</Modal>
{{/if}}
</template>
}

View File

@@ -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.nsec.app';
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.nsec.app';
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);
}
}

View File

@@ -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;

View File

@@ -2,6 +2,7 @@
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import camera from 'feather-icons/dist/icons/camera.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
@@ -39,7 +40,6 @@ import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_fo
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
@@ -235,7 +235,6 @@ const FILLED_ICONS = [
'cup-and-saucer',
'coffee-bean',
'shopping-basket',
'camera',
'person-sleeping-in-bed',
'loading-ring',
'nostrich',