Compare commits

..
Author SHA1 Message Date
raucao ac7a4eb262 Fetch kind 3 follow list for connected user 2026-08-27 08:40:21 -06:00
2 changed files with 175 additions and 3 deletions
+18 -3
View File
@@ -5,6 +5,7 @@ import { EMPTY, from, timeout, filter, connect, take, takeUntil } from 'rxjs';
import { EventStore } from 'applesauce-core/event-store';
import { ProfileModel } from 'applesauce-core/models/profile';
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { ContactsModel } from 'applesauce-core/models/contacts';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
@@ -56,6 +57,7 @@ export default class NostrDataService extends Service {
@tracked profile = null;
@tracked mailboxes = null;
@tracked contacts = null;
@tracked blossomServers = [];
@tracked placePhotos = [];
@tracked myContributionEvents = [];
@@ -78,6 +80,7 @@ export default class NostrDataService extends Service {
_profileSub = null;
_mailboxesSub = null;
_contactsSub = null;
_blossomSub = null;
_photosSub = null;
_contributionsSub = null;
@@ -123,10 +126,11 @@ export default class NostrDataService extends Service {
this._stopPersisting = persistEventsToCache(
this.store,
async (events) => {
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
// Only cache profiles, mailboxes, contacts, blossom servers, place photos, and deletions
const toCache = events.filter(
(e) =>
e.kind === 0 ||
e.kind === 3 ||
e.kind === 5 ||
e.kind === 10002 ||
e.kind === 10063 ||
@@ -576,6 +580,7 @@ export default class NostrDataService extends Service {
// Reset state
this.profile = null;
this.mailboxes = null;
this.contacts = null;
this.blossomServers = [];
this._cleanupSubscriptions();
@@ -594,6 +599,12 @@ export default class NostrDataService extends Service {
this.mailboxes = mailboxesData;
});
this._contactsSub = this.store
.model(ContactsModel, pubkey)
.subscribe((contacts) => {
this.contacts = contacts;
});
this._blossomSub = this.store
.replaceable(10063, pubkey)
.subscribe((event) => {
@@ -623,7 +634,7 @@ export default class NostrDataService extends Service {
const cachedEvents = await this.cache.query([
{
authors: [pubkey],
kinds: [0, 10002, 10063],
kinds: [0, 3, 10002, 10063],
},
]);
@@ -644,7 +655,7 @@ export default class NostrDataService extends Service {
.request(profileRelays, [
{
authors: [pubkey],
kinds: [0, 10002, 10063],
kinds: [0, 3, 10002, 10063],
},
])
.subscribe({
@@ -806,6 +817,10 @@ export default class NostrDataService extends Service {
this._mailboxesSub.unsubscribe();
this._mailboxesSub = null;
}
if (this._contactsSub) {
this._contactsSub.unsubscribe();
this._contactsSub = null;
}
if (this._blossomSub) {
this._blossomSub.unsubscribe();
this._blossomSub = null;
+157
View File
@@ -0,0 +1,157 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import { Subject, EMPTY } from 'rxjs';
import Service from '@ember/service';
import NostrDataService from 'marco/services/nostr-data';
function makePubkey(n) {
return n.toString(16).padStart(64, '0');
}
function makeEventId(n) {
return `e${n.toString(16).padStart(63, '0')}`;
}
function makeContactsEvent(pubkey, contactPubkeys, opts = {}) {
const id = opts.id || makeEventId(1);
const createdAt = opts.created_at || 1000;
return {
id,
pubkey,
kind: 3,
created_at: createdAt,
tags: contactPubkeys.map((pk) => ['p', pk, 'wss://relay.example']),
content: '',
sig: 'sig',
};
}
module('Unit | Service | nostr-data | contacts', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.requestedFilters = [];
const requestedFilters = this.requestedFilters;
class StubNostrRelayService extends Service {
pool = {
relays$: new Subject(),
request: (_relays, filters) => {
requestedFilters.push(...filters);
return EMPTY;
},
req: () => EMPTY,
publish: () => Promise.resolve([{ ok: true }]),
};
}
this.owner.register('service:nostrRelay', StubNostrRelayService);
this.owner.register('service:nostrData', NostrDataService);
});
test('loadProfile populates contacts from store via ContactsModel', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const pubkey = makePubkey(1);
const contactA = makePubkey(2);
const contactB = makePubkey(3);
service.store.add(makeContactsEvent(pubkey, [contactA, contactB]));
await service.loadProfile(pubkey);
assert.ok(service.contacts, 'contacts is populated');
assert.strictEqual(service.contacts.length, 2, 'two contacts');
const pubkeys = service.contacts.map((c) => c.pubkey).sort();
assert.deepEqual(
pubkeys,
[contactA, contactB].sort(),
'contact pubkeys match'
);
});
test('loadProfile tears down previous contacts subscription when called with a different pubkey', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const pubkeyA = makePubkey(1);
const pubkeyB = makePubkey(4);
const contactA = makePubkey(2);
service.store.add(makeContactsEvent(pubkeyA, [contactA]));
await service.loadProfile(pubkeyA);
assert.strictEqual(
service.contacts.length,
1,
'contacts populated for pubkeyA'
);
// Switch to a different pubkey — this should tear down pubkeyA's subscription
await service.loadProfile(pubkeyB);
assert.deepEqual(
service.contacts,
[],
'contacts is empty for pubkeyB (no kind 3 event)'
);
// Add a new contacts event for pubkeyA after the switch
const newerEvent = makeContactsEvent(pubkeyA, [makePubkey(9)], {
created_at: 2000,
});
service.store.add(newerEvent);
// Give the subscription a tick to propagate
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(
service.contacts,
[],
'contacts remains empty — old subscription was torn down'
);
});
test('loadProfile network request includes kind 3', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
const pubkey = makePubkey(1);
await service.loadProfile(pubkey);
const kindsFilter = this.requestedFilters.find((f) =>
f.authors?.includes(pubkey)
);
assert.ok(kindsFilter, 'filter with authors was requested');
assert.ok(kindsFilter.kinds.includes(3), 'kinds includes 3 (contacts)');
assert.deepEqual(
kindsFilter.kinds.sort(),
[0, 3, 10002, 10063].sort(),
'kinds match expected set'
);
});
test('kind 3 events are persisted to IDB cache', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
// Wait for the IDB cache to be ready before adding events
await service._cachePromise;
const pubkey = makePubkey(1);
const contactA = makePubkey(2);
const event = makeContactsEvent(pubkey, [contactA], { id: makeEventId(5) });
service.store.add(event);
// Wait for the persistEventsToCache batch (1s) plus some margin
await new Promise((r) => setTimeout(r, 1500));
const cached = await service.cache.query([
{ kinds: [3], authors: [pubkey] },
]);
assert.strictEqual(cached.length, 1, 'kind 3 event is in IDB cache');
assert.strictEqual(cached[0].id, event.id, 'cached event id matches');
});
});