diff --git a/app/components/app-header.gjs b/app/components/app-header.gjs index cbc771c..91ac29e 100644 --- a/app/components/app-header.gjs +++ b/app/components/app-header.gjs @@ -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}} > -
- -
+ {{#if + (and this.nostrAuth.isConnected this.nostrData.profile.picture) + }} + User Avatar + {{else}} +
+ +
+ {{/if}} {{#if this.isUserMenuOpen}} diff --git a/app/components/user-menu.gjs b/app/components/user-menu.gjs index 93be100..6b92d66 100644 --- a/app/components/user-menu.gjs +++ b/app/components/user-menu.gjs @@ -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 {
{{#if this.nostrAuth.isConnected}} - {{this.nostrAuth.pubkey}} + {{this.nostrData.userDisplayName}} {{else}} Not connected diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js index ccbb4b4..cce17d4 100644 --- a/app/services/nostr-auth.js +++ b/app/services/nostr-auth.js @@ -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; diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js new file mode 100644 index 0000000..adf99e9 --- /dev/null +++ b/app/services/nostr-data.js @@ -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 + }); + } + + 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(); + } +} diff --git a/app/styles/app.css b/app/styles/app.css index 17591ee..d5891b3 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -190,7 +190,16 @@ 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; +} } /* User Menu Popover */