Compare commits
3 Commits
3a56464926
...
10501b64bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
10501b64bd
|
|||
|
7607f27013
|
|||
|
8cc579e271
|
@@ -7,10 +7,13 @@ import Icon from '#components/icon';
|
||||
import UserMenu from '#components/user-menu';
|
||||
import SearchBox from '#components/search-box';
|
||||
import CategoryChips from '#components/category-chips';
|
||||
import { and } from 'ember-truth-helpers';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@service settings;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@tracked isUserMenuOpen = false;
|
||||
@tracked searchQuery = '';
|
||||
|
||||
@@ -64,9 +67,19 @@ export default class AppHeaderComponent extends Component {
|
||||
aria-label="User Menu"
|
||||
{{on "click" this.toggleUserMenu}}
|
||||
>
|
||||
<div class="user-avatar-placeholder">
|
||||
<Icon @name="user" @size={{20}} @color="white" />
|
||||
</div>
|
||||
{{#if
|
||||
(and this.nostrAuth.isConnected this.nostrData.profile.picture)
|
||||
}}
|
||||
<img
|
||||
src={{this.nostrData.profile.picture}}
|
||||
class="user-avatar"
|
||||
alt="User Avatar"
|
||||
/>
|
||||
{{else}}
|
||||
<div class="user-avatar-placeholder">
|
||||
<Icon @name="user" @size={{20}} @color="white" />
|
||||
</div>
|
||||
{{/if}}
|
||||
</button>
|
||||
|
||||
{{#if this.isUserMenuOpen}}
|
||||
|
||||
@@ -6,13 +6,23 @@ import { on } from '@ember/modifier';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import Geohash from 'latlon-geohash';
|
||||
|
||||
function bufferToHex(buffer) {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export default class PlacePhotoUpload extends Component {
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@service nostrRelay;
|
||||
|
||||
@tracked photoUrl = '';
|
||||
@tracked photoType = 'image/jpeg';
|
||||
@tracked photoDim = '';
|
||||
@tracked status = '';
|
||||
@tracked error = '';
|
||||
@tracked isUploading = false;
|
||||
|
||||
get place() {
|
||||
return this.args.place || {};
|
||||
@@ -22,21 +32,105 @@ export default class PlacePhotoUpload extends Component {
|
||||
return this.place.title || 'this place';
|
||||
}
|
||||
|
||||
get blossomServer() {
|
||||
return this.nostrData.blossomServers[0] || 'https://nostr.build';
|
||||
}
|
||||
|
||||
@action
|
||||
async uploadPhoto(event) {
|
||||
event.preventDefault();
|
||||
async handleFileSelected(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
this.error = '';
|
||||
this.status = 'Uploading...';
|
||||
this.status = 'Preparing upload...';
|
||||
this.isUploading = true;
|
||||
this.photoType = file.type;
|
||||
|
||||
try {
|
||||
// Mock upload
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
this.photoUrl =
|
||||
'https://dummyimage.com/600x400/000/fff.jpg&text=Mock+Place+Photo';
|
||||
if (!this.nostrAuth.isConnected) {
|
||||
throw new Error('You must connect Nostr first.');
|
||||
}
|
||||
|
||||
// 1. Get image dimensions
|
||||
const dim = await new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(`${img.width}x${img.height}`);
|
||||
};
|
||||
img.onerror = () => resolve('');
|
||||
img.src = url;
|
||||
});
|
||||
this.photoDim = dim;
|
||||
|
||||
// 2. Read file & compute hash
|
||||
this.status = 'Computing hash...';
|
||||
const buffer = await file.arrayBuffer();
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
const payloadHash = bufferToHex(hashBuffer);
|
||||
|
||||
// 3. Create BUD-11 Auth Event
|
||||
this.status = 'Signing auth event...';
|
||||
let serverUrl = this.blossomServer;
|
||||
if (serverUrl.endsWith('/')) {
|
||||
serverUrl = serverUrl.slice(0, -1);
|
||||
}
|
||||
const uploadUrl = `${serverUrl}/upload`;
|
||||
|
||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const serverHostname = new URL(serverUrl).hostname;
|
||||
|
||||
const authTemplate = {
|
||||
kind: 24242,
|
||||
created_at: now,
|
||||
content: 'Upload photo for place',
|
||||
tags: [
|
||||
['t', 'upload'],
|
||||
['x', payloadHash],
|
||||
['expiration', String(now + 3600)],
|
||||
['server', serverHostname],
|
||||
],
|
||||
};
|
||||
|
||||
const authEvent = await factory.sign(authTemplate);
|
||||
const base64 = btoa(JSON.stringify(authEvent));
|
||||
const base64url = base64
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
const authHeader = `Nostr ${base64url}`;
|
||||
|
||||
// 4. Upload to Blossom
|
||||
this.status = `Uploading to ${serverUrl}...`;
|
||||
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'X-SHA-256': payloadHash,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upload failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
this.photoUrl = result.url;
|
||||
this.status = 'Photo uploaded! Ready to publish.';
|
||||
} catch (e) {
|
||||
this.error = 'Upload failed: ' + e.message;
|
||||
this.error = e.message;
|
||||
this.status = '';
|
||||
} finally {
|
||||
this.isUploading = false;
|
||||
if (event && event.target) {
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,13 +169,18 @@ export default class PlacePhotoUpload extends Component {
|
||||
tags.push(['g', Geohash.encode(lat, lon, 9)]);
|
||||
}
|
||||
|
||||
tags.push([
|
||||
const imeta = [
|
||||
'imeta',
|
||||
`url ${this.photoUrl}`,
|
||||
'm image/jpeg',
|
||||
'dim 600x400',
|
||||
`m ${this.photoType}`,
|
||||
'alt A photo of a place',
|
||||
]);
|
||||
];
|
||||
|
||||
if (this.photoDim) {
|
||||
imeta.splice(3, 0, `dim ${this.photoDim}`);
|
||||
}
|
||||
|
||||
tags.push(imeta);
|
||||
|
||||
// NIP-XX draft Place Photo event
|
||||
const template = {
|
||||
@@ -90,7 +189,6 @@ export default class PlacePhotoUpload extends Component {
|
||||
tags,
|
||||
};
|
||||
|
||||
// Ensure created_at is present before signing
|
||||
if (!template.created_at) {
|
||||
template.created_at = Math.floor(Date.now() / 1000);
|
||||
}
|
||||
@@ -99,7 +197,6 @@ export default class PlacePhotoUpload extends Component {
|
||||
await this.nostrRelay.publish(event);
|
||||
|
||||
this.status = 'Published successfully!';
|
||||
// Reset form
|
||||
this.photoUrl = '';
|
||||
} catch (e) {
|
||||
this.error = 'Failed to publish: ' + e.message;
|
||||
@@ -123,11 +220,15 @@ export default class PlacePhotoUpload extends Component {
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<form {{on "submit" this.uploadPhoto}}>
|
||||
<div>
|
||||
{{#if this.photoUrl}}
|
||||
<div class="preview-group">
|
||||
<p>Photo Preview:</p>
|
||||
<img src={{this.photoUrl}} alt="Preview" />
|
||||
<img
|
||||
src={{this.photoUrl}}
|
||||
alt="Preview"
|
||||
class="photo-preview-img"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -137,11 +238,20 @@ export default class PlacePhotoUpload extends Component {
|
||||
Publish Event (kind: 360)
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="submit" class="btn btn-secondary">
|
||||
Mock Upload Photo
|
||||
</button>
|
||||
<label for="photo-upload-input">Select Photo</label>
|
||||
<input
|
||||
id="photo-upload-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="file-input"
|
||||
disabled={{this.isUploading}}
|
||||
{{on "change" this.handleFileSelected}}
|
||||
/>
|
||||
{{#if this.isUploading}}
|
||||
<p>Uploading...</p>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export default class UserMenuComponent extends Component {
|
||||
@service storage;
|
||||
@service osmAuth;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
|
||||
@tracked isNostrConnectModalOpen = false;
|
||||
|
||||
@@ -135,7 +136,7 @@ export default class UserMenuComponent extends Component {
|
||||
<div class="account-status">
|
||||
{{#if this.nostrAuth.isConnected}}
|
||||
<strong title={{this.nostrAuth.pubkey}}>
|
||||
{{this.nostrAuth.pubkey}}
|
||||
{{this.nostrData.userDisplayName}}
|
||||
</strong>
|
||||
{{else}}
|
||||
Not connected
|
||||
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
|
||||
|
||||
export default class NostrAuthService extends Service {
|
||||
@service nostrRelay;
|
||||
@service nostrData;
|
||||
|
||||
@tracked pubkey = null;
|
||||
@tracked signerType = null; // 'extension' or 'connect'
|
||||
@@ -56,6 +57,7 @@ export default class NostrAuthService extends Service {
|
||||
this.pubkey = extensionPubkey;
|
||||
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||
}
|
||||
this.nostrData.loadProfile(this.pubkey);
|
||||
} catch (e) {
|
||||
console.warn('Failed to verify extension nostr pubkey, logging out', e);
|
||||
this.disconnect();
|
||||
@@ -112,6 +114,7 @@ export default class NostrAuthService extends Service {
|
||||
this.signerType = 'extension';
|
||||
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||
localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
|
||||
this.nostrData.loadProfile(this.pubkey);
|
||||
return this.pubkey;
|
||||
} catch (error) {
|
||||
console.error('Failed to get public key from extension:', error);
|
||||
@@ -207,6 +210,8 @@ export default class NostrAuthService extends Service {
|
||||
this._signerInstance.remote
|
||||
);
|
||||
|
||||
this.nostrData.loadProfile(this.pubkey);
|
||||
|
||||
return this.pubkey;
|
||||
} catch (error) {
|
||||
this.connectStatus = null;
|
||||
@@ -253,6 +258,7 @@ export default class NostrAuthService extends Service {
|
||||
if (pubkey !== this.pubkey) {
|
||||
throw new Error('Remote signer pubkey mismatch');
|
||||
}
|
||||
this.nostrData.loadProfile(this.pubkey);
|
||||
}
|
||||
|
||||
async signEvent(event) {
|
||||
@@ -266,6 +272,7 @@ export default class NostrAuthService extends Service {
|
||||
|
||||
async disconnect() {
|
||||
this.pubkey = null;
|
||||
this.nostrData?.loadProfile(null);
|
||||
this.signerType = null;
|
||||
this.connectStatus = null;
|
||||
this.connectUri = null;
|
||||
|
||||
158
app/services/nostr-data.js
Normal file
158
app/services/nostr-data.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import Service, { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { EventStore } from 'applesauce-core/event-store';
|
||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
|
||||
const BOOTSTRAP_RELAYS = [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
];
|
||||
|
||||
export default class NostrDataService extends Service {
|
||||
@service nostrRelay;
|
||||
@service nostrAuth;
|
||||
|
||||
store = new EventStore();
|
||||
|
||||
@tracked profile = null;
|
||||
@tracked mailboxes = null;
|
||||
@tracked blossomServers = [];
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_blossomSub = null;
|
||||
|
||||
_requestSub = null;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
// Feed events from the relay pool into the event store
|
||||
this.nostrRelay.pool.relays$.subscribe(() => {
|
||||
// Setup relay subscription tracking if needed, or we just rely on request()
|
||||
// which returns an Observable<NostrEvent>
|
||||
});
|
||||
}
|
||||
|
||||
async loadProfile(pubkey) {
|
||||
if (!pubkey) return;
|
||||
|
||||
// Reset state
|
||||
this.profile = null;
|
||||
this.mailboxes = null;
|
||||
this.blossomServers = [];
|
||||
|
||||
this._cleanupSubscriptions();
|
||||
|
||||
const relays = new Set(BOOTSTRAP_RELAYS);
|
||||
|
||||
// Try to get extension relays
|
||||
if (typeof window.nostr !== 'undefined' && window.nostr.getRelays) {
|
||||
try {
|
||||
const extRelays = await window.nostr.getRelays();
|
||||
for (const url of Object.keys(extRelays)) {
|
||||
relays.add(url);
|
||||
}
|
||||
} catch {
|
||||
console.warn('Failed to get NIP-07 relays');
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = Array.from(relays);
|
||||
|
||||
// Request events and dump them into the store
|
||||
this._requestSub = this.nostrRelay.pool
|
||||
.request(relayList, [
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error fetching profile events:', err);
|
||||
},
|
||||
});
|
||||
|
||||
// Setup models to track state reactively
|
||||
this._profileSub = this.store
|
||||
.model(ProfileModel, pubkey)
|
||||
.subscribe((profileContent) => {
|
||||
this.profile = profileContent;
|
||||
});
|
||||
|
||||
this._mailboxesSub = this.store
|
||||
.model(MailboxesModel, pubkey)
|
||||
.subscribe((mailboxesData) => {
|
||||
this.mailboxes = mailboxesData;
|
||||
});
|
||||
|
||||
this._blossomSub = this.store
|
||||
.replaceable(10063, pubkey)
|
||||
.subscribe((event) => {
|
||||
if (event && event.tags) {
|
||||
this.blossomServers = event.tags
|
||||
.filter((t) => t[0] === 'server' && t[1])
|
||||
.map((t) => t[1]);
|
||||
} else {
|
||||
this.blossomServers = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get userDisplayName() {
|
||||
if (this.profile) {
|
||||
if (this.profile.nip05) {
|
||||
return this.profile.nip05;
|
||||
}
|
||||
if (this.profile.displayName || this.profile.display_name) {
|
||||
return this.profile.displayName || this.profile.display_name;
|
||||
}
|
||||
if (this.profile.name) {
|
||||
return this.profile.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to npub
|
||||
if (this.nostrAuth.pubkey) {
|
||||
try {
|
||||
const npub = npubEncode(this.nostrAuth.pubkey);
|
||||
return `${npub.slice(0, 9)}...${npub.slice(-4)}`;
|
||||
} catch {
|
||||
return this.nostrAuth.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Not connected';
|
||||
}
|
||||
|
||||
_cleanupSubscriptions() {
|
||||
if (this._requestSub) {
|
||||
this._requestSub.unsubscribe();
|
||||
this._requestSub = null;
|
||||
}
|
||||
if (this._profileSub) {
|
||||
this._profileSub.unsubscribe();
|
||||
this._profileSub = null;
|
||||
}
|
||||
if (this._mailboxesSub) {
|
||||
this._mailboxesSub.unsubscribe();
|
||||
this._mailboxesSub = null;
|
||||
}
|
||||
if (this._blossomSub) {
|
||||
this._blossomSub.unsubscribe();
|
||||
this._blossomSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this._cleanupSubscriptions();
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,9 @@ body {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
@@ -190,7 +193,21 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-preview-img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* User Menu Popover */
|
||||
|
||||
Reference in New Issue
Block a user