Load user profile from Nostr, display name and avatar

This commit is contained in:
2026-04-20 13:37:05 +04:00
parent 3a56464926
commit 8cc579e271
5 changed files with 193 additions and 5 deletions

View File

@@ -7,10 +7,13 @@ import Icon from '#components/icon';
import UserMenu from '#components/user-menu'; import UserMenu from '#components/user-menu';
import SearchBox from '#components/search-box'; import SearchBox from '#components/search-box';
import CategoryChips from '#components/category-chips'; import CategoryChips from '#components/category-chips';
import { and } from 'ember-truth-helpers';
export default class AppHeaderComponent extends Component { export default class AppHeaderComponent extends Component {
@service storage; @service storage;
@service settings; @service settings;
@service nostrAuth;
@service nostrData;
@tracked isUserMenuOpen = false; @tracked isUserMenuOpen = false;
@tracked searchQuery = ''; @tracked searchQuery = '';
@@ -64,9 +67,19 @@ export default class AppHeaderComponent extends Component {
aria-label="User Menu" aria-label="User Menu"
{{on "click" this.toggleUserMenu}} {{on "click" this.toggleUserMenu}}
> >
<div class="user-avatar-placeholder"> {{#if
<Icon @name="user" @size={{20}} @color="white" /> (and this.nostrAuth.isConnected this.nostrData.profile.picture)
</div> }}
<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> </button>
{{#if this.isUserMenuOpen}} {{#if this.isUserMenuOpen}}

View File

@@ -11,6 +11,7 @@ export default class UserMenuComponent extends Component {
@service storage; @service storage;
@service osmAuth; @service osmAuth;
@service nostrAuth; @service nostrAuth;
@service nostrData;
@tracked isNostrConnectModalOpen = false; @tracked isNostrConnectModalOpen = false;
@@ -135,7 +136,7 @@ export default class UserMenuComponent extends Component {
<div class="account-status"> <div class="account-status">
{{#if this.nostrAuth.isConnected}} {{#if this.nostrAuth.isConnected}}
<strong title={{this.nostrAuth.pubkey}}> <strong title={{this.nostrAuth.pubkey}}>
{{this.nostrAuth.pubkey}} {{this.nostrData.userDisplayName}}
</strong> </strong>
{{else}} {{else}}
Not connected Not connected

View File

@@ -16,6 +16,7 @@ const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app';
export default class NostrAuthService extends Service { export default class NostrAuthService extends Service {
@service nostrRelay; @service nostrRelay;
@service nostrData;
@tracked pubkey = null; @tracked pubkey = null;
@tracked signerType = null; // 'extension' or 'connect' @tracked signerType = null; // 'extension' or 'connect'
@@ -56,6 +57,7 @@ export default class NostrAuthService extends Service {
this.pubkey = extensionPubkey; this.pubkey = extensionPubkey;
localStorage.setItem(STORAGE_KEY, this.pubkey); localStorage.setItem(STORAGE_KEY, this.pubkey);
} }
this.nostrData.loadProfile(this.pubkey);
} catch (e) { } catch (e) {
console.warn('Failed to verify extension nostr pubkey, logging out', e); console.warn('Failed to verify extension nostr pubkey, logging out', e);
this.disconnect(); this.disconnect();
@@ -112,6 +114,7 @@ export default class NostrAuthService extends Service {
this.signerType = 'extension'; this.signerType = 'extension';
localStorage.setItem(STORAGE_KEY, this.pubkey); localStorage.setItem(STORAGE_KEY, this.pubkey);
localStorage.setItem(STORAGE_KEY_TYPE, 'extension'); localStorage.setItem(STORAGE_KEY_TYPE, 'extension');
this.nostrData.loadProfile(this.pubkey);
return this.pubkey; return this.pubkey;
} catch (error) { } catch (error) {
console.error('Failed to get public key from extension:', 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._signerInstance.remote
); );
this.nostrData.loadProfile(this.pubkey);
return this.pubkey; return this.pubkey;
} catch (error) { } catch (error) {
this.connectStatus = null; this.connectStatus = null;
@@ -253,6 +258,7 @@ export default class NostrAuthService extends Service {
if (pubkey !== this.pubkey) { if (pubkey !== this.pubkey) {
throw new Error('Remote signer pubkey mismatch'); throw new Error('Remote signer pubkey mismatch');
} }
this.nostrData.loadProfile(this.pubkey);
} }
async signEvent(event) { async signEvent(event) {
@@ -266,6 +272,7 @@ export default class NostrAuthService extends Service {
async disconnect() { async disconnect() {
this.pubkey = null; this.pubkey = null;
this.nostrData?.loadProfile(null);
this.signerType = null; this.signerType = null;
this.connectStatus = null; this.connectStatus = null;
this.connectUri = 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

@@ -190,7 +190,16 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 */ /* User Menu Popover */