Compare commits

...

3 Commits

6 changed files with 331 additions and 25 deletions

View File

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

View File

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

View File

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

View File

@@ -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
View 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();
}
}

View File

@@ -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 */