Compare commits

..
Author SHA1 Message Date
raucao ac7a4eb262 Fetch kind 3 follow list for connected user 2026-08-27 08:40:21 -06:00
5 changed files with 180 additions and 62 deletions
+5 -18
View File
@@ -16,30 +16,17 @@ export default modifier((element) => {
const rect = element.getBoundingClientRect();
const tipRect = tooltipEl.getBoundingClientRect();
const arrowSize = 6;
// Vertical placement: above trigger, flip below if no room
let placement = 'top';
let top = rect.top - tipRect.height - arrowSize;
if (top < arrowSize) {
placement = 'bottom';
top = rect.bottom + arrowSize;
}
// Horizontal: center on trigger, clamp to viewport
const margin = 6;
let top = rect.top - tipRect.height - margin;
if (top < margin) top = rect.bottom + margin;
let left = rect.left + rect.width / 2 - tipRect.width / 2;
left = Math.max(
arrowSize,
Math.min(left, window.innerWidth - tipRect.width - arrowSize)
margin,
Math.min(left, window.innerWidth - tipRect.width - margin)
);
// Arrow always points at trigger center, even when clamped
const arrowLeft = rect.left + rect.width / 2 - left;
tooltipEl.dataset.placement = placement;
tooltipEl.style.top = `${top}px`;
tooltipEl.style.left = `${left}px`;
tooltipEl.style.setProperty('--arrow-left', `${arrowLeft}px`);
};
const hide = () => {
+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;
-20
View File
@@ -728,26 +728,6 @@ body {
animation: tooltip-fade-in 0.15s ease;
}
.tooltip::after {
content: '';
position: absolute;
width: 0;
height: 0;
border: 6px solid transparent;
left: var(--arrow-left, 50%);
transform: translateX(-50%);
}
.tooltip[data-placement='top']::after {
top: 100%;
border-top-color: var(--body-text-color);
}
.tooltip[data-placement='bottom']::after {
bottom: 100%;
border-bottom-color: var(--body-text-color);
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
@@ -293,30 +293,9 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
removeBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await settled();
const tooltip = document.body.querySelector('.tooltip');
assert.dom('.tooltip', document.body).exists('tooltip appears on hover');
assert.dom('.tooltip', document.body).hasText('Remove relay');
// Verify arrow is rendered
assert.ok(tooltip.dataset.placement, 'tooltip has placement attribute');
const arrowStyle = getComputedStyle(tooltip, '::after');
assert.strictEqual(arrowStyle.content, '""', 'arrow pseudo-element exists');
// Verify arrow points in correct direction based on placement
if (tooltip.dataset.placement === 'top') {
assert.notStrictEqual(
arrowStyle.borderTopColor,
'rgba(0, 0, 0, 0)',
'arrow points down when tooltip is above trigger'
);
} else {
assert.notStrictEqual(
arrowStyle.borderBottomColor,
'rgba(0, 0, 0, 0)',
'arrow points up when tooltip is below trigger'
);
}
removeBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
await settled();
+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');
});
});