Use nevent with relay hints for copying photo event ID #108

Merged
raucao merged 1 commits from feature/copy_event_id into master 2026-09-18 09:55:24 +00:00
7 changed files with 180 additions and 21 deletions
+27 -2
View File
@@ -7,6 +7,7 @@ import { service } from '@ember/service';
import { modifier } from 'ember-modifier';
import { task } from 'ember-concurrency';
import { EventFactory } from 'applesauce-core';
import { encodePointer } from 'applesauce-core/helpers/pointers';
import or from 'ember-truth-helpers/helpers/or';
import config from 'marco/config/environment';
import DropdownMenu from './dropdown-menu';
@@ -15,6 +16,8 @@ import ZapPhotoModal from './zap-photo-modal';
import Icon from './icon';
import formatRelativeDate from '../helpers/format-relative-date';
const MAX_NEVENT_RELAY_HINTS = 3;
const GalleryContent = <template>
<div
class="photo-gallery-overlay"
@@ -236,8 +239,23 @@ export default class PhotoGallery extends Component {
@action
async copyEventId(closeMenu) {
if (this.currentPhoto?.eventId) {
let value = this.currentPhoto.eventId;
try {
await navigator.clipboard.writeText(this.currentPhoto.eventId);
const nevent = encodePointer({
id: this.currentPhoto.eventId,
relays: this.nostrData
.getEventRelays(this.currentPhoto.eventId)
.slice(0, MAX_NEVENT_RELAY_HINTS),
author: this.currentPhoto.pubkey,
kind: 360,
});
if (nevent) value = nevent;
} catch (err) {
console.warn('Failed to encode nevent, copying raw event ID:', err);
}
try {
await navigator.clipboard.writeText(value);
this.toast.show('Event ID copied to clipboard');
} catch (err) {
console.error('Failed to copy event ID:', err);
@@ -290,7 +308,14 @@ export default class PhotoGallery extends Component {
.modifyPublicTags(() => tags)
.as(this.nostrAuth.signer)
.sign();
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
const responses = await this.nostrRelay.publish(
this.nostrData.activeWriteRelays,
event
);
this.nostrData.recordPublishResult(event.id, responses);
if (!responses?.some((res) => res.ok)) {
throw new Error('Failed to publish deletion event.');
}
// Remove from local store by adding the kind 5 to it
this.nostrData.store.add(event);
+8 -1
View File
@@ -211,7 +211,14 @@ export default class PlacePhotoUpload extends Component {
.modifyPublicTags(() => tags)
.as(this.nostrAuth.signer)
.sign();
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
const responses = await this.nostrRelay.publish(
this.nostrData.activeWriteRelays,
event
);
this.nostrData.recordPublishResult(event.id, responses);
if (!responses?.some((res) => res.ok)) {
throw new Error('Failed to publish event.');
}
this.nostrData.store.add(event);
this.toast.show('Photo published successfully');
+27
View File
@@ -339,6 +339,33 @@ export default class NostrDataService extends Service {
});
}
// Public getter for the normalized relays an event is known to have been
// seen on. Used to build shareable `nevent` pointers with relay hints.
getEventRelays(eventId) {
const relays = this._eventRelays.get(eventId);
return relays ? uniqNormalizedRelays([...relays]) : [];
}
/**
* Records the result of publishing an event to relays.
*
* Successful relays are merged into the provenance map so the event gets
* correct relay hints (e.g. in `nevent` pointers) and trust checks. The full
* per-relay responses are only debug-logged for now; this is the intended
* hook for a future publishing-status store/UI.
*
* @param {string} eventId The published event id
* @param {Array<{ok: boolean, message?: string, from: string}>} responses
*/
recordPublishResult(eventId, responses = []) {
console.debug('[nostr-data] Publish result', eventId, responses);
for (const res of responses || []) {
if (res?.ok && res.from) {
this._recordProvenance(eventId, res.from);
}
}
}
/**
* Request content events from relays while capturing full provenance.
*
+6 -13
View File
@@ -8,18 +8,11 @@ export default class NostrRelayService extends Service {
if (!relays || relays.length === 0) {
throw new Error('No relays provided to publish the event.');
}
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
// and automatically handles reconnecting and retrying.
const responses = await this.pool.publish(relays, event);
// Check if at least one relay accepted the event
const success = responses.some((res) => res.ok);
if (!success) {
throw new Error(
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
);
}
return responses;
// The publish method is a wrapper around the event method that returns a
// Promise<PublishResponse[]> and automatically handles reconnecting and
// retrying. It resolves with the per-relay responses even when no relay
// accepted the event; callers are responsible for checking `ok` so that
// failed attempts can still be recorded.
return await this.pool.publish(relays, event);
}
}
+8 -2
View File
@@ -62,6 +62,12 @@ export class MockNostrDataService extends Service {
return this.profiles[pubkey];
}
getEventRelays() {
return [];
}
recordPublishResult() {}
refreshProfiles() {
return Promise.resolve();
}
@@ -109,14 +115,14 @@ export class MockNostrDataService extends Service {
export class MockNostrRelayService extends Service {
pool = {
publish: () => Promise.resolve([{ ok: true }]),
publish: () => Promise.resolve([{ ok: true, from: 'wss://relay.test' }]),
subscribe: () => {},
unsubscribe: () => {},
close: () => {},
};
async publish() {
return [{ ok: true }];
return [{ ok: true, from: 'wss://relay.test' }];
}
}
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
import Service from '@ember/service';
import { decodePointer } from 'applesauce-core/helpers/pointers';
import PhotoGallery from 'marco/components/photo-gallery';
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
import sinon from 'sinon';
@@ -163,8 +164,11 @@ module('Integration | Component | photo-gallery', function (hooks) {
const confirmStub = sinon.stub(window, 'confirm').returns(true);
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
const publishStub = sinon
.stub(this.nostrRelay, 'publish')
.resolves([{ ok: true, from: 'wss://relay.test' }]);
const storeStub = sinon.stub(this.nostrData.store, 'add');
const recordPublishSpy = sinon.spy(this.nostrData, 'recordPublishResult');
const toastSpy = sinon.spy(this.toast, 'show');
await render(
@@ -224,6 +228,17 @@ module('Integration | Component | photo-gallery', function (hooks) {
'added kind 5 event to local store'
);
// Check publish result recording
assert.ok(
recordPublishSpy.calledOnce,
'nostrData.recordPublishResult was called'
);
assert.strictEqual(
recordPublishSpy.firstCall.args[0],
publishedEvent.id,
'publish result recorded for the deletion event'
);
// Check UX
assert.ok(
toastSpy.calledWith('Photo deleted successfully'),
@@ -261,7 +276,9 @@ module('Integration | Component | photo-gallery', function (hooks) {
sinon.stub(window, 'confirm').returns(true);
sinon.stub(this.blossom, 'delete').resolves();
sinon.stub(this.nostrRelay, 'publish').resolves();
sinon
.stub(this.nostrRelay, 'publish')
.resolves([{ ok: true, from: 'wss://relay.test' }]);
sinon.stub(this.nostrData.store, 'add');
await render(
@@ -284,9 +301,27 @@ module('Integration | Component | photo-gallery', function (hooks) {
});
test('it copies event id to clipboard', async function (assert) {
const eventId = '1'.repeat(64);
this.nostrAuth.pubkey = USER_A;
this.photos = [
{
eventId,
pubkey: USER_A,
placeIdentifier: 'osm:node:12345',
url: 'photo.jpg',
},
];
this.selectedPhoto = this.photos[0];
sinon
.stub(this.nostrData, 'getEventRelays')
.returns([
'wss://a.test',
'wss://b.test',
'wss://c.test',
'wss://d.test',
'wss://e.test',
]);
const clipboardStub = sinon
.stub(navigator.clipboard, 'writeText')
.resolves();
@@ -317,7 +352,23 @@ module('Integration | Component | photo-gallery', function (hooks) {
await click(copyBtn);
assert.ok(clipboardStub.calledWith('event1'), 'copied correct event id');
const copied = clipboardStub.firstCall.args[0];
assert.ok(
copied.startsWith('nevent1'),
'copied value is an nevent identifier'
);
const decoded = decodePointer(copied);
assert.strictEqual(decoded.type, 'nevent', 'decoded value is an nevent');
assert.strictEqual(
decoded.data.id,
eventId,
'decoded nevent references the correct event id'
);
assert.deepEqual(
decoded.data.relays,
['wss://a.test', 'wss://b.test', 'wss://c.test'],
'decoded nevent includes at most 3 relay hints'
);
assert.ok(
toastSpy.calledWith('Event ID copied to clipboard'),
'success toast was shown'
+50
View File
@@ -498,6 +498,56 @@ module('Unit | Service | nostr-data | provenance', function (hooks) {
);
assert.true(service.store.hasEvent(eventId), 'event added to store');
});
test('getEventRelays returns normalized relays for an event', function (assert) {
const service = this.owner.lookup('service:nostr-data');
const eventId = makeEventId(904);
service._recordProvenance(eventId, 'wss://one.example/');
service._recordProvenance(eventId, 'WSS://Two.Example');
assert.deepEqual(
service.getEventRelays(eventId).sort(),
['wss://one.example', 'wss://two.example'],
'returns normalized, unique relays'
);
assert.deepEqual(
service.getEventRelays('unknown-event'),
[],
'returns an empty array for unknown events'
);
});
test('recordPublishResult records only successful relays into provenance', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const eventId = makeEventId(905);
service.recordPublishResult(eventId, [
{ ok: true, from: 'wss://accepted.example' },
{ ok: false, from: 'wss://rejected.example', message: 'blocked' },
]);
const relays = service._eventRelays.get(eventId);
assert.ok(relays, 'provenance recorded');
assert.true(
relays.has('wss://accepted.example'),
'accepted relay is recorded'
);
assert.false(
relays.has('wss://rejected.example'),
'rejected relay is not recorded'
);
const persisted = await service.localForage.get(
'event-relay-provenance',
eventId
);
assert.deepEqual(
persisted,
['wss://accepted.example'],
'accepted relay is persisted'
);
});
});
module('Unit | Service | nostr-data | trust evaluation', function (hooks) {