Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ebe565cbc
|
||
|
|
e7020469c3
|
||
|
|
bf3011115b
|
||
|
|
8c98e0757b
|
||
|
|
2ae9a78f5d
|
||
|
|
252f41f897
|
||
|
|
572213eda2
|
||
|
|
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 = () => {
|
||||
|
||||
+45
-17
@@ -5,7 +5,8 @@ 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 { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { ContactsModel } from 'applesauce-core/models/contacts';
|
||||
import { isEventPointer, npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
||||
import { RelayGroup } from 'applesauce-relay';
|
||||
@@ -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 = [];
|
||||
@@ -70,6 +72,10 @@ export default class NostrDataService extends Service {
|
||||
_eventRelays = new Map();
|
||||
_provenanceReady = null;
|
||||
|
||||
// Set of pubkeys from the user's follow list (kind 3 contacts) for O(1)
|
||||
// trust lookups. Rebuilt whenever contacts change.
|
||||
_contactPubkeys = null;
|
||||
|
||||
// Session-only reveal toggle for untrusted content. Not persisted.
|
||||
@tracked showUntrustedContent = false;
|
||||
// Count of currently-hidden (untrusted) place photos for the selected place.
|
||||
@@ -78,6 +84,7 @@ export default class NostrDataService extends Service {
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_contactsSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
_contributionsSub = null;
|
||||
@@ -123,10 +130,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 ||
|
||||
@@ -149,20 +157,17 @@ export default class NostrDataService extends Service {
|
||||
// instantly without waiting for relay connections.
|
||||
this._provenanceReady = this._hydrateProvenance();
|
||||
|
||||
// Centralized kind-5 deletion handling: when a deletion event enters the
|
||||
// store, drop the provenance entries for the events it deletes so the
|
||||
// trust map and IDB don't accumulate dead entries.
|
||||
this._deletionsSub = this.store
|
||||
.timeline([{ kinds: [5] }])
|
||||
.subscribe((events) => {
|
||||
for (const event of events) {
|
||||
for (const tag of event.tags || []) {
|
||||
if (tag[0] === 'e' && tag[1]) {
|
||||
this._removeProvenance(tag[1]);
|
||||
}
|
||||
}
|
||||
// Centralized kind-5 deletion handling: the store routes kind-5 events to
|
||||
// its DeleteManager (never into the event database), so listen to the
|
||||
// deletion stream rather than a timeline. Drop provenance entries for
|
||||
// deleted events so the trust map and IDB don't accumulate dead entries.
|
||||
this._deletionsSub = this.store.deletes.deleted$.subscribe(
|
||||
({ pointer }) => {
|
||||
if (isEventPointer(pointer)) {
|
||||
this._removeProvenance(pointer.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Feed events from the relay pool into the event store
|
||||
this.nostrRelay.pool.relays$.subscribe(() => {
|
||||
@@ -241,6 +246,7 @@ export default class NostrDataService extends Service {
|
||||
/**
|
||||
* Returns true if an event should be considered trusted:
|
||||
* - authored by the connected user (own uploads), OR
|
||||
* - authored by a pubkey the user follows (kind 3 contacts), OR
|
||||
* - seen on at least one trusted (moderated) relay.
|
||||
*/
|
||||
isTrustedEvent(event) {
|
||||
@@ -248,6 +254,8 @@ export default class NostrDataService extends Service {
|
||||
const myPubkey = this.nostrAuth?.pubkey;
|
||||
if (myPubkey && event.pubkey === myPubkey) return true;
|
||||
|
||||
if (this._contactPubkeys?.has(event.pubkey)) return true;
|
||||
|
||||
const relays = this._eventRelays.get(event.id);
|
||||
if (!relays || relays.size === 0) return false;
|
||||
const trusted = this.trustedRelays;
|
||||
@@ -576,6 +584,8 @@ export default class NostrDataService extends Service {
|
||||
// Reset state
|
||||
this.profile = null;
|
||||
this.mailboxes = null;
|
||||
this.contacts = null;
|
||||
this._contactPubkeys = null;
|
||||
this.blossomServers = [];
|
||||
|
||||
this._cleanupSubscriptions();
|
||||
@@ -594,6 +604,14 @@ export default class NostrDataService extends Service {
|
||||
this.mailboxes = mailboxesData;
|
||||
});
|
||||
|
||||
this._contactsSub = this.store
|
||||
.model(ContactsModel, pubkey)
|
||||
.subscribe((contacts) => {
|
||||
this.contacts = contacts;
|
||||
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
|
||||
this._updatePlacePhotos();
|
||||
});
|
||||
|
||||
this._blossomSub = this.store
|
||||
.replaceable(10063, pubkey)
|
||||
.subscribe((event) => {
|
||||
@@ -623,7 +641,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 +662,7 @@ export default class NostrDataService extends Service {
|
||||
.request(profileRelays, [
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
kinds: [0, 3, 10002, 10063],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
@@ -806,6 +824,11 @@ export default class NostrDataService extends Service {
|
||||
this._mailboxesSub.unsubscribe();
|
||||
this._mailboxesSub = null;
|
||||
}
|
||||
if (this._contactsSub) {
|
||||
this._contactsSub.unsubscribe();
|
||||
this._contactsSub = null;
|
||||
this._contactPubkeys = null;
|
||||
}
|
||||
if (this._blossomSub) {
|
||||
this._blossomSub.unsubscribe();
|
||||
this._blossomSub = null;
|
||||
@@ -827,6 +850,11 @@ export default class NostrDataService extends Service {
|
||||
this._cleanupSubscriptions();
|
||||
this._clearProfileSubs();
|
||||
|
||||
if (this._deletionsSub) {
|
||||
this._deletionsSub.unsubscribe();
|
||||
this._deletionsSub = null;
|
||||
}
|
||||
|
||||
if (this._stopPersisting) {
|
||||
this._stopPersisting();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.30.1",
|
||||
"version": "1.31.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-DeiUGvCJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DWxZH-9E.css">
|
||||
<script type="module" crossorigin src="/assets/main-D65ZMp0t.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DjaQtV1_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
@@ -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,912 @@
|
||||
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';
|
||||
import { getGeohashPrefixesInBbox } from 'marco/utils/geohash-coverage';
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
function makePhotoEvent(pubkey, placeId, opts = {}) {
|
||||
const id = opts.id || makeEventId(100);
|
||||
const createdAt = opts.created_at || 2000;
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
kind: 360,
|
||||
created_at: createdAt,
|
||||
tags: [
|
||||
['i', placeId],
|
||||
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
|
||||
],
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
function makePhotoEventWithGeohash(pubkey, placeId, geohash, opts = {}) {
|
||||
const event = makePhotoEvent(pubkey, placeId, opts);
|
||||
event.tags.push(['g', geohash]);
|
||||
return event;
|
||||
}
|
||||
|
||||
function makeDeletionEvent(pubkey, eventIds, opts = {}) {
|
||||
const id = opts.id || makeEventId(50);
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
kind: 5,
|
||||
created_at: opts.created_at || 5000,
|
||||
tags: eventIds.map((eid) => ['e', eid]),
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
function makeZapReceiptEvent(pubkey, photoEventIds, opts = {}) {
|
||||
const id = opts.id || makeEventId(600);
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
kind: 9735,
|
||||
created_at: opts.created_at || 6000,
|
||||
tags: photoEventIds.map((eid) => ['e', eid]),
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function setupNostrDataService(hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
const requestedFilters = [];
|
||||
const reqCalls = [];
|
||||
const reqMessages = new Subject();
|
||||
|
||||
class StubNostrRelayService extends Service {
|
||||
pool = {
|
||||
relays$: new Subject(),
|
||||
request: (_relays, filters) => {
|
||||
requestedFilters.push(...filters);
|
||||
return EMPTY;
|
||||
},
|
||||
req: (_relays, filters) => {
|
||||
reqCalls.push(true);
|
||||
requestedFilters.push(...filters);
|
||||
return reqMessages;
|
||||
},
|
||||
publish: () => Promise.resolve([{ ok: true }]),
|
||||
};
|
||||
}
|
||||
|
||||
this.owner.register('service:nostrRelay', StubNostrRelayService);
|
||||
this.owner.register('service:nostrData', NostrDataService);
|
||||
|
||||
this.requestedFilters = requestedFilters;
|
||||
this.reqCalls = reqCalls;
|
||||
this.reqMessages = reqMessages;
|
||||
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
service.store.verifyEvent = undefined;
|
||||
});
|
||||
|
||||
hooks.afterEach(async function () {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
await service.clearCache();
|
||||
await service.localForage.clear('event-relay-provenance');
|
||||
});
|
||||
}
|
||||
|
||||
module('Unit | Service | nostr-data | contacts', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('loadProfile populates contacts from store via ContactsModel', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
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');
|
||||
|
||||
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 subscriptions 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');
|
||||
|
||||
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');
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | relay configuration', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('mailboxReadRelays normalizes mailbox inbox URLs', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(service.mailboxReadRelays, [], 'empty when no mailboxes');
|
||||
|
||||
service.mailboxes = {
|
||||
inboxes: ['WSS://Relay.Example.COM/', 'relay.two.example'],
|
||||
outboxes: [],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
service.mailboxReadRelays,
|
||||
['wss://relay.example.com', 'wss://relay.two.example'],
|
||||
'normalizes and filters invalid URLs'
|
||||
);
|
||||
});
|
||||
|
||||
test('mailboxWriteRelays returns empty array without mailboxes', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(service.mailboxWriteRelays, [], 'empty when no mailboxes');
|
||||
|
||||
service.mailboxes = {
|
||||
inboxes: [],
|
||||
outboxes: ['WSS://Outbox.Example.COM/'],
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
service.mailboxWriteRelays,
|
||||
['wss://outbox.example.com'],
|
||||
'normalizes outbox URLs'
|
||||
);
|
||||
});
|
||||
|
||||
test('configuredReadRelays merges mailbox and custom relays with dedupe and exclusions', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.mailboxes = { inboxes: ['wss://a.example'], outboxes: [] };
|
||||
service.settings.nostrReadRelays = ['wss://a.example/', 'wss://b.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredReadRelays,
|
||||
['wss://a.example', 'wss://b.example'],
|
||||
'merges and deduplicates (normalized)'
|
||||
);
|
||||
|
||||
service.settings.nostrReadRelayExclusions = ['wss://a.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredReadRelays,
|
||||
['wss://b.example'],
|
||||
'exclusions remove mailbox relays'
|
||||
);
|
||||
});
|
||||
|
||||
test('configuredWriteRelays merges mailbox outboxes with custom write relays', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.mailboxes = { inboxes: [], outboxes: ['wss://out.example'] };
|
||||
service.settings.nostrWriteRelays = ['wss://custom.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredWriteRelays,
|
||||
['wss://out.example', 'wss://custom.example'],
|
||||
'merges mailbox and custom write relays'
|
||||
);
|
||||
|
||||
service.settings.nostrWriteRelayExclusions = ['wss://out.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.configuredWriteRelays,
|
||||
['wss://custom.example'],
|
||||
'exclusions apply to write relays'
|
||||
);
|
||||
});
|
||||
|
||||
test('activeReadRelays puts required relays first and appends custom', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(
|
||||
service.activeReadRelays,
|
||||
['wss://nostr.kosmos.org'],
|
||||
'default is required only'
|
||||
);
|
||||
|
||||
service.settings.nostrReadRelays = ['wss://custom.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.activeReadRelays,
|
||||
['wss://nostr.kosmos.org', 'wss://custom.example'],
|
||||
'required first, custom appended'
|
||||
);
|
||||
});
|
||||
|
||||
test('activeWriteRelays returns empty when nothing configured', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(
|
||||
service.activeWriteRelays,
|
||||
[],
|
||||
'no required write relays by default'
|
||||
);
|
||||
});
|
||||
|
||||
test('trustedRelays includes required read relays plus user-marked trusted relays', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
assert.deepEqual(
|
||||
service.trustedRelays,
|
||||
['wss://nostr.kosmos.org'],
|
||||
'default is required read relays only'
|
||||
);
|
||||
|
||||
service.settings.nostrTrustedRelays = ['wss://custom.example'];
|
||||
|
||||
assert.deepEqual(
|
||||
service.trustedRelays,
|
||||
['wss://nostr.kosmos.org', 'wss://custom.example'],
|
||||
'merges custom trusted relays'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | provenance', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('_recordProvenance accumulates relays for an event and persists them', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(900);
|
||||
|
||||
service._recordProvenance(eventId, 'wss://one.example');
|
||||
service._recordProvenance(eventId, 'wss://two.example');
|
||||
service._recordProvenance(eventId, 'wss://one.example'); // duplicate
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.strictEqual(relays.size, 2, 'accumulates unique relays');
|
||||
assert.true(relays.has('wss://one.example'), 'has first relay');
|
||||
assert.true(relays.has('wss://two.example'), 'has second relay');
|
||||
|
||||
const persisted = await service.localForage.get(
|
||||
'event-relay-provenance',
|
||||
eventId
|
||||
);
|
||||
assert.deepEqual(
|
||||
persisted.sort(),
|
||||
['wss://one.example', 'wss://two.example'],
|
||||
'persists to localForage'
|
||||
);
|
||||
});
|
||||
|
||||
test('kind 5 deletion events remove provenance for referenced events', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const deadId = makeEventId(901);
|
||||
const author = makePubkey(50);
|
||||
|
||||
service._recordProvenance(deadId, 'wss://one.example');
|
||||
|
||||
// The store routes kind-5 events to its DeleteManager, which emits
|
||||
// deleted$ synchronously from add() — provenance is dropped immediately.
|
||||
service.store.add(makeDeletionEvent(author, [deadId]));
|
||||
|
||||
assert.false(
|
||||
service._eventRelays.has(deadId),
|
||||
'provenance removed from memory'
|
||||
);
|
||||
const persisted = await service.localForage.get(
|
||||
'event-relay-provenance',
|
||||
deadId
|
||||
);
|
||||
assert.strictEqual(persisted, null, 'provenance removed from localForage');
|
||||
});
|
||||
|
||||
test('_hydrateProvenance restores persisted provenance', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(902);
|
||||
|
||||
await service.localForage.set('event-relay-provenance', eventId, [
|
||||
'wss://x.example',
|
||||
]);
|
||||
|
||||
await service._hydrateProvenance();
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.ok(relays, 'provenance restored');
|
||||
assert.true(relays.has('wss://x.example'), 'contains the relay');
|
||||
});
|
||||
|
||||
test('_requestContentWithProvenance records provenance and adds events to the store', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(903);
|
||||
const pubkey = makePubkey(60);
|
||||
|
||||
service._requestContentWithProvenance(
|
||||
['wss://relay.test'],
|
||||
[{ kinds: [360] }],
|
||||
'test'
|
||||
);
|
||||
|
||||
const photoEvent = makePhotoEvent(pubkey, 'osm:node:1', { id: eventId });
|
||||
this.reqMessages.next({
|
||||
type: 'EVENT',
|
||||
event: photoEvent,
|
||||
from: 'wss://relay.test',
|
||||
});
|
||||
|
||||
await wait(50);
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.ok(relays, 'provenance recorded');
|
||||
assert.true(
|
||||
relays.has('wss://relay.test'),
|
||||
'contains the relay the event came from'
|
||||
);
|
||||
assert.true(service.store.hasEvent(eventId), 'event added to store');
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | trust evaluation', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('isTrustedEvent trusts content from followed contacts', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const followedPubkey = makePubkey(2);
|
||||
|
||||
// User follows followedPubkey
|
||||
service.store.add(makeContactsEvent(userPubkey, [followedPubkey]));
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Photo from followed contact (no trusted relay provenance)
|
||||
const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:123', {
|
||||
id: makeEventId(200),
|
||||
});
|
||||
service.store.add(photoEvent);
|
||||
|
||||
assert.true(
|
||||
service.isTrustedEvent(photoEvent),
|
||||
'photo from followed contact is trusted'
|
||||
);
|
||||
});
|
||||
|
||||
test('isTrustedEvent does not trust content from unfollowed pubkeys', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const followedPubkey = makePubkey(2);
|
||||
const unfollowedPubkey = makePubkey(3);
|
||||
|
||||
// User follows followedPubkey (but NOT unfollowedPubkey)
|
||||
service.store.add(makeContactsEvent(userPubkey, [followedPubkey]));
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Photo from unfollowed pubkey (no trusted relay provenance)
|
||||
const photoEvent = makePhotoEvent(unfollowedPubkey, 'osm:node:123', {
|
||||
id: makeEventId(201),
|
||||
});
|
||||
service.store.add(photoEvent);
|
||||
|
||||
assert.false(
|
||||
service.isTrustedEvent(photoEvent),
|
||||
'photo from unfollowed pubkey is not trusted'
|
||||
);
|
||||
});
|
||||
|
||||
test('isTrustedEvent still trusts own content (regression)', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
service.nostrAuth = { pubkey: userPubkey };
|
||||
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Photo from the user themselves
|
||||
const photoEvent = makePhotoEvent(userPubkey, 'osm:node:123', {
|
||||
id: makeEventId(202),
|
||||
});
|
||||
service.store.add(photoEvent);
|
||||
|
||||
assert.true(service.isTrustedEvent(photoEvent), 'own photo is trusted');
|
||||
});
|
||||
|
||||
test('isTrustedEvent still trusts content from trusted relays (regression)', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const randomPubkey = makePubkey(99);
|
||||
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Photo from random pubkey
|
||||
const photoEvent = makePhotoEvent(randomPubkey, 'osm:node:123', {
|
||||
id: makeEventId(203),
|
||||
});
|
||||
service.store.add(photoEvent);
|
||||
|
||||
// Simulate provenance: photo was seen on a trusted relay
|
||||
const trustedRelay = 'wss://nostr.kosmos.org';
|
||||
service._recordProvenance(photoEvent.id, trustedRelay);
|
||||
|
||||
assert.true(
|
||||
service.isTrustedEvent(photoEvent),
|
||||
'photo from trusted relay is trusted'
|
||||
);
|
||||
});
|
||||
|
||||
test('isTrustedEvent re-evaluates when contacts change', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const followedPubkey = makePubkey(2);
|
||||
|
||||
// Load profile first (no contacts yet)
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Create a photo from followedPubkey (who is not yet followed)
|
||||
const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:456', {
|
||||
id: makeEventId(204),
|
||||
});
|
||||
|
||||
// Photo should be untrusted initially (no contacts loaded yet)
|
||||
assert.false(
|
||||
service.isTrustedEvent(photoEvent),
|
||||
'photo is untrusted before contacts load'
|
||||
);
|
||||
|
||||
// Now load contacts (user follows followedPubkey)
|
||||
service.store.add(
|
||||
makeContactsEvent(userPubkey, [followedPubkey], {
|
||||
id: makeEventId(300),
|
||||
created_at: 3000,
|
||||
})
|
||||
);
|
||||
|
||||
// Give the contacts subscription a tick to propagate
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Photo should now be trusted (contacts loaded)
|
||||
assert.true(
|
||||
service.isTrustedEvent(photoEvent),
|
||||
'photo is trusted after contacts load'
|
||||
);
|
||||
});
|
||||
|
||||
test('partitionByTrust always passes kind 5 deletions through as trusted', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const untrustedPk = makePubkey(70);
|
||||
const authorPk = makePubkey(71);
|
||||
|
||||
const untrustedPhoto = makePhotoEvent(untrustedPk, 'osm:node:1', {
|
||||
id: makeEventId(904),
|
||||
});
|
||||
const deletion = makeDeletionEvent(authorPk, [untrustedPhoto.id], {
|
||||
id: makeEventId(905),
|
||||
});
|
||||
|
||||
const { trusted, untrusted } = service.partitionByTrust([
|
||||
untrustedPhoto,
|
||||
deletion,
|
||||
]);
|
||||
|
||||
assert.strictEqual(trusted.length, 1, 'one trusted event');
|
||||
assert.strictEqual(trusted[0].kind, 5, 'deletion is trusted');
|
||||
assert.strictEqual(untrusted.length, 1, 'one untrusted event');
|
||||
assert.strictEqual(untrusted[0].kind, 360, 'photo is untrusted');
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | geohash loading', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
const BERLIN_BBOX = {
|
||||
minLat: 52.5,
|
||||
minLon: 13.4,
|
||||
maxLat: 52.55,
|
||||
maxLon: 13.45,
|
||||
};
|
||||
|
||||
test('requests kind 360 events for missing geohash prefixes and marks them loaded', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const expected = getGeohashPrefixesInBbox(BERLIN_BBOX);
|
||||
assert.ok(expected.length > 0, 'bbox produces prefixes');
|
||||
|
||||
await service.loadPlacesInBounds(BERLIN_BBOX);
|
||||
|
||||
assert.strictEqual(this.reqCalls.length, 1, 'one network request made');
|
||||
const filter = this.requestedFilters.find((f) => f['#g']);
|
||||
assert.ok(filter, 'geohash filter was requested');
|
||||
assert.deepEqual(filter.kinds, [360], 'requests kind 360');
|
||||
assert.deepEqual(
|
||||
filter['#g'].sort(),
|
||||
expected.sort(),
|
||||
'covers all prefixes'
|
||||
);
|
||||
for (const p of expected) {
|
||||
assert.true(
|
||||
service.loadedGeohashPrefixes.has(p),
|
||||
`prefix ${p} marked loaded`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('skips prefixes that were already loaded', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
await service.loadPlacesInBounds(BERLIN_BBOX);
|
||||
const firstReqCalls = this.reqCalls.length;
|
||||
|
||||
await service.loadPlacesInBounds(BERLIN_BBOX);
|
||||
|
||||
assert.strictEqual(
|
||||
this.reqCalls.length,
|
||||
firstReqCalls,
|
||||
'no additional network request'
|
||||
);
|
||||
});
|
||||
|
||||
test('hydrates matching cached photos into the store', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
await service._cachePromise;
|
||||
|
||||
const pk = makePubkey(80);
|
||||
const geohash = getGeohashPrefixesInBbox(BERLIN_BBOX)[0];
|
||||
const cached = makePhotoEventWithGeohash(pk, 'osm:node:700', geohash, {
|
||||
id: makeEventId(700),
|
||||
created_at: 4000,
|
||||
});
|
||||
await service.cache.add(cached);
|
||||
|
||||
await service.loadPlacesInBounds(BERLIN_BBOX);
|
||||
|
||||
assert.true(
|
||||
service.store.hasEvent(cached.id),
|
||||
'cached photo added to store'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | place photos', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('sets entity id and streams photos for the place', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pk = makePubkey(81);
|
||||
const photoId = makeEventId(800);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
|
||||
assert.strictEqual(
|
||||
service._currentPlaceEntityId,
|
||||
'osm:node:800',
|
||||
'entity id set'
|
||||
);
|
||||
|
||||
service._recordProvenance(photoId, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: photoId }));
|
||||
|
||||
assert.strictEqual(service.placePhotos.length, 1, 'photo visible');
|
||||
});
|
||||
|
||||
test('calling with the same place twice does not tear down subscriptions', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
|
||||
const firstSub = service._photosSub;
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
|
||||
|
||||
assert.strictEqual(
|
||||
service._photosSub,
|
||||
firstSub,
|
||||
'same subscription retained'
|
||||
);
|
||||
});
|
||||
|
||||
test('switching places resets state and re-subscribes', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pk = makePubkey(82);
|
||||
const idA = makeEventId(801);
|
||||
const idB = makeEventId(802);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
|
||||
service._recordProvenance(idA, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: idA }));
|
||||
assert.strictEqual(service.placePhotos.length, 1, 'place 800 has photo');
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '801', osmType: 'node' });
|
||||
assert.deepEqual(service.placePhotos, [], 'photos cleared');
|
||||
assert.strictEqual(
|
||||
service._currentPlaceEntityId,
|
||||
'osm:node:801',
|
||||
'entity id updated'
|
||||
);
|
||||
|
||||
service._recordProvenance(idB, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pk, 'osm:node:801', { id: idB }));
|
||||
assert.strictEqual(service.placePhotos.length, 1, 'new place has photo');
|
||||
assert.strictEqual(service.placePhotos[0].id, idB);
|
||||
});
|
||||
|
||||
test('null place clears state and tears down subscription', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pk = makePubkey(83);
|
||||
const idA = makeEventId(803);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
|
||||
service._recordProvenance(idA, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: idA }));
|
||||
assert.strictEqual(service.placePhotos.length, 1);
|
||||
|
||||
await service.loadPhotosForPlace(null);
|
||||
|
||||
assert.deepEqual(service.placePhotos, [], 'photos cleared');
|
||||
assert.strictEqual(
|
||||
service._currentPlaceEntityId,
|
||||
null,
|
||||
'entity id cleared'
|
||||
);
|
||||
assert.strictEqual(service._photosSub, null, 'subscription torn down');
|
||||
});
|
||||
|
||||
test('placePhotos hides untrusted photos by default and counts them', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pkA = makePubkey(84);
|
||||
const pkB = makePubkey(85);
|
||||
const trustedId = makeEventId(900);
|
||||
const untrustedId = makeEventId(901);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '900', osmType: 'node' });
|
||||
|
||||
service._recordProvenance(trustedId, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pkA, 'osm:node:900', { id: trustedId }));
|
||||
service.store.add(makePhotoEvent(pkB, 'osm:node:900', { id: untrustedId }));
|
||||
|
||||
assert.deepEqual(
|
||||
service.placePhotos.map((e) => e.id),
|
||||
[trustedId],
|
||||
'only trusted shown'
|
||||
);
|
||||
assert.strictEqual(service.untrustedContentCount, 1, 'untrusted counted');
|
||||
});
|
||||
|
||||
test('toggleShowUntrustedContent reveals all photos and toggles back', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const pkA = makePubkey(86);
|
||||
const pkB = makePubkey(87);
|
||||
const trustedId = makeEventId(902);
|
||||
const untrustedId = makeEventId(903);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '901', osmType: 'node' });
|
||||
|
||||
service._recordProvenance(trustedId, 'wss://nostr.kosmos.org');
|
||||
service.store.add(makePhotoEvent(pkA, 'osm:node:901', { id: trustedId }));
|
||||
service.store.add(makePhotoEvent(pkB, 'osm:node:901', { id: untrustedId }));
|
||||
|
||||
service.toggleShowUntrustedContent();
|
||||
assert.strictEqual(service.placePhotos.length, 2, 'all photos shown');
|
||||
assert.strictEqual(service.untrustedContentCount, 1, 'count unchanged');
|
||||
|
||||
service.toggleShowUntrustedContent();
|
||||
assert.strictEqual(service.placePhotos.length, 1, 'back to trusted only');
|
||||
});
|
||||
|
||||
test('own photos are shown without provenance', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const myPubkey = makePubkey(95);
|
||||
service.nostrAuth = { pubkey: myPubkey };
|
||||
const ownId = makeEventId(950);
|
||||
|
||||
await service.loadPhotosForPlace({ osmId: '950', osmType: 'node' });
|
||||
service.store.add(makePhotoEvent(myPubkey, 'osm:node:950', { id: ownId }));
|
||||
|
||||
assert.strictEqual(
|
||||
service.placePhotos.length,
|
||||
1,
|
||||
'own photo shown without provenance'
|
||||
);
|
||||
assert.strictEqual(service.untrustedContentCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | my contributions', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('returns early when pubkey is null', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
await service.loadMyContributions(null);
|
||||
|
||||
assert.deepEqual(service.myContributionEvents, [], 'no events loaded');
|
||||
assert.strictEqual(
|
||||
service._contributionsSub,
|
||||
null,
|
||||
'no subscription created'
|
||||
);
|
||||
});
|
||||
|
||||
test('loads own photos from store into myContributionEvents', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const myPk = makePubkey(60);
|
||||
const photoId = makeEventId(600);
|
||||
|
||||
service.store.add(makePhotoEvent(myPk, 'osm:node:600', { id: photoId }));
|
||||
|
||||
await service.loadMyContributions(myPk);
|
||||
|
||||
assert.strictEqual(
|
||||
service.myContributionEvents.length,
|
||||
1,
|
||||
'photo in contributions'
|
||||
);
|
||||
assert.strictEqual(service.myContributionEvents[0].id, photoId);
|
||||
});
|
||||
|
||||
test('requests own contributions from network with correct filter', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const myPk = makePubkey(61);
|
||||
|
||||
await service.loadMyContributions(myPk);
|
||||
|
||||
const filter = this.requestedFilters.find((f) => f.authors?.includes(myPk));
|
||||
assert.ok(filter, 'filter for own pubkey requested');
|
||||
assert.deepEqual(filter.kinds.sort(), [360, 5].sort(), 'kinds 360 and 5');
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | zap receipts', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('_refreshZapReceiptSubscription batches >100 IDs into multiple filters', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const photoIds = Array.from({ length: 250 }, (_, i) =>
|
||||
makeEventId(1000 + i)
|
||||
);
|
||||
|
||||
service._refreshZapReceiptSubscription(photoIds);
|
||||
|
||||
const filters = this.requestedFilters.filter((f) =>
|
||||
f.kinds?.includes(9735)
|
||||
);
|
||||
assert.strictEqual(filters.length, 3, '250 IDs → 3 filters (100+100+50)');
|
||||
assert.strictEqual(filters[0]['#e'].length, 100);
|
||||
assert.strictEqual(filters[1]['#e'].length, 100);
|
||||
assert.strictEqual(filters[2]['#e'].length, 50);
|
||||
});
|
||||
|
||||
test('_updateZapReceipts groups receipts by photo event id', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const photoA = makeEventId(2000);
|
||||
const photoB = makeEventId(2001);
|
||||
|
||||
const receipt1 = makeZapReceiptEvent(makePubkey(70), [photoA], {
|
||||
id: makeEventId(2100),
|
||||
});
|
||||
const receipt2 = makeZapReceiptEvent(makePubkey(71), [photoA, photoB], {
|
||||
id: makeEventId(2101),
|
||||
});
|
||||
const receipt3 = makeZapReceiptEvent(makePubkey(72), [photoB], {
|
||||
id: makeEventId(2102),
|
||||
});
|
||||
|
||||
service._updateZapReceipts([receipt1, receipt2, receipt3]);
|
||||
|
||||
assert.strictEqual(
|
||||
service.zapReceipts[photoA].length,
|
||||
2,
|
||||
'photoA has 2 receipts'
|
||||
);
|
||||
assert.strictEqual(
|
||||
service.zapReceipts[photoB].length,
|
||||
2,
|
||||
'photoB has 2 receipts'
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.zapReceipts[photoA].map((r) => r.id).sort(),
|
||||
[makeEventId(2100), makeEventId(2101)],
|
||||
'receipts for photoA match'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user