WIP Add Nostr auth

This commit is contained in:
2026-04-18 18:36:09 +04:00
parent 2268a607d5
commit f875fc1877
6 changed files with 1110 additions and 3 deletions

View File

@@ -0,0 +1,179 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
export default class PlacePhotoUpload extends Component {
@service nostrAuth;
@service nostrRelay;
@tracked photoUrl = '';
@tracked osmId = '';
@tracked geohash = '';
@tracked status = '';
@tracked error = '';
@action
async login() {
try {
this.error = '';
await this.nostrAuth.login();
} catch (e) {
this.error = e.message;
}
}
@action
async uploadPhoto(event) {
event.preventDefault();
this.error = '';
this.status = 'Uploading...';
try {
// Mock upload
await new Promise((resolve) => setTimeout(resolve, 1000));
this.photoUrl =
'https://dummyimage.com/600x400/000/fff.jpg&text=Mock+Place+Photo';
this.status = 'Photo uploaded! Ready to publish.';
} catch (e) {
this.error = 'Upload failed: ' + e.message;
this.status = '';
}
}
@action
async publish() {
if (!this.nostrAuth.isConnected) {
this.error = 'You must connect Nostr first.';
return;
}
if (!this.photoUrl || !this.osmId || !this.geohash) {
this.error = 'Please provide an OSM ID, Geohash, and upload a photo.';
return;
}
this.status = 'Publishing event...';
this.error = '';
try {
const factory = new EventFactory({ signer: this.nostrAuth.signer });
// NIP-XX draft Place Photo event
const template = {
kind: 360,
content: '',
tags: [
['i', `osm:node:${this.osmId}`],
['g', this.geohash],
[
'imeta',
`url ${this.photoUrl}`,
'm image/jpeg',
'dim 600x400',
'alt A photo of a place',
],
],
};
// Ensure created_at is present before signing
if (!template.created_at) {
template.created_at = Math.floor(Date.now() / 1000);
}
const event = await factory.sign(template);
await this.nostrRelay.publish(event);
this.status = 'Published successfully!';
// Reset form
this.photoUrl = '';
this.osmId = '';
this.geohash = '';
} catch (e) {
this.error = 'Failed to publish: ' + e.message;
this.status = '';
}
}
@action updateOsmId(e) {
this.osmId = e.target.value;
}
@action updateGeohash(e) {
this.geohash = e.target.value;
}
<template>
<div class="place-photo-upload">
<h2>Add Place Photo</h2>
{{#if this.error}}
<div class="alert alert-error">
{{this.error}}
</div>
{{/if}}
{{#if this.status}}
<div class="alert alert-info">
{{this.status}}
</div>
{{/if}}
{{#if this.nostrAuth.isConnected}}
<div class="connected-status">
<strong>Connected:</strong>
{{this.nostrAuth.pubkey}}
</div>
<form {{on "submit" this.uploadPhoto}}>
<div class="form-group">
<label>
OSM Node ID
<input
type="text"
value={{this.osmId}}
{{on "input" this.updateOsmId}}
placeholder="e.g. 123456"
/>
</label>
</div>
<div class="form-group">
<label>
Geohash
<input
type="text"
value={{this.geohash}}
{{on "input" this.updateGeohash}}
placeholder="e.g. thrrn5"
/>
</label>
</div>
{{#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}}
</div>
</template>
}

View File

@@ -8,6 +8,8 @@ export default class UserMenuComponent extends Component {
@service storage; @service storage;
@service osmAuth; @service osmAuth;
@service nostrAuth;
@action @action
connectRS() { connectRS() {
this.args.onClose(); this.args.onClose();
@@ -30,6 +32,21 @@ export default class UserMenuComponent extends Component {
this.osmAuth.logout(); this.osmAuth.logout();
} }
@action
async connectNostr() {
try {
await this.nostrAuth.login();
} catch (e) {
console.error(e);
alert(e.message);
}
}
@action
disconnectNostr() {
this.nostrAuth.logout();
}
<template> <template>
<div class="user-menu-popover"> <div class="user-menu-popover">
<ul class="account-list"> <ul class="account-list">
@@ -91,15 +108,34 @@ export default class UserMenuComponent extends Component {
</div> </div>
</li> </li>
<li class="account-item disabled"> <li class="account-item">
<div class="account-header"> <div class="account-header">
<div class="account-info"> <div class="account-info">
<Icon @name="zap" @size={{18}} /> <Icon @name="zap" @size={{18}} />
<span>Nostr</span> <span>Nostr</span>
</div> </div>
{{#if this.nostrAuth.isConnected}}
<button
class="btn-text text-danger"
type="button"
{{on "click" this.disconnectNostr}}
>Disconnect</button>
{{else}}
<button
class="btn-text text-primary"
type="button"
{{on "click" this.connectNostr}}
>Connect</button>
{{/if}}
</div> </div>
<div class="account-status"> <div class="account-status">
Coming soon {{#if this.nostrAuth.isConnected}}
<strong title={{this.nostrAuth.pubkey}}>
{{this.nostrAuth.pubkey}}
</strong>
{{else}}
Not connected
{{/if}}
</div> </div>
</li> </li>
</ul> </ul>

View File

@@ -0,0 +1,60 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { ExtensionSigner } from 'applesauce-signers';
const STORAGE_KEY = 'marco:nostr_pubkey';
export default class NostrAuthService extends Service {
@tracked pubkey = null;
signer = null;
constructor() {
super(...arguments);
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
this.pubkey = saved;
}
}
get isConnected() {
return !!this.pubkey;
}
async login() {
if (typeof window.nostr === 'undefined') {
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
}
if (!this.signer) {
this.signer = new ExtensionSigner();
}
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;
}
}
async signEvent(event) {
if (!this.signer) {
if (this.pubkey && typeof window.nostr !== 'undefined') {
this.signer = new ExtensionSigner();
} else {
throw new Error(
'Not connected or extension missing. Please connect Nostr again.'
);
}
}
return await this.signer.signEvent(event);
}
logout() {
this.pubkey = null;
this.signer = null;
localStorage.removeItem(STORAGE_KEY);
}
}

View File

@@ -0,0 +1,25 @@
import Service from '@ember/service';
import { RelayPool } from 'applesauce-relay';
export default class NostrRelayService extends Service {
pool = new RelayPool();
// For Phase 1, we hardcode the local relay
relays = ['ws://127.0.0.1:7777'];
async publish(event) {
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
// and automatically handles reconnecting and retrying.
const responses = await this.pool.publish(this.relays, event);
// Check if at least one relay accepted the event
const success = responses.some((res) => res.ok);
if (!success) {
throw new Error(
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
);
}
return responses;
}
}

View File

@@ -103,8 +103,13 @@
}, },
"dependencies": { "dependencies": {
"@waysidemapping/pinhead": "^15.20.0", "@waysidemapping/pinhead": "^15.20.0",
"applesauce-core": "^5.2.0",
"applesauce-factory": "^4.0.0",
"applesauce-relay": "^5.2.0",
"applesauce-signers": "^5.2.0",
"ember-concurrency": "^5.2.0", "ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0", "ember-lifeline": "^7.0.0",
"oauth2-pkce": "^2.1.3" "oauth2-pkce": "^2.1.3",
"rxjs": "^7.8.2"
} }
} }

802
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff