Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac7a4eb262
|
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user