Compare commits

...
3 Commits
Author SHA1 Message Date
raucao 878387b9a8 1.34.2
CI / Lint (push) Successful in 1m13s
CI / Test (push) Successful in 1m33s
2026-09-18 14:54:43 +02:00
raucao 0f6bfb7e74 Merge pull request 'Use nevent with relay hints for copying photo event ID' (#108) from feature/copy_event_id into master
CI / Lint (push) Successful in 1m12s
CI / Test (push) Failing after 1m33s
Reviewed-on: #108
2026-09-18 09:55:24 +00:00
raucao faea3d26cd Use nevent with relay hints for copying photo event ID
CI / Lint (pull_request) Successful in 47s
CI / Test (pull_request) Successful in 1m4s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
Also adds provenance for newly uploaded photos

refs #46
2026-09-18 11:51:00 +02:00
12 changed files with 185 additions and 26 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);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.34.1",
"version": "1.34.2",
"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
+1 -1
View File
@@ -42,7 +42,7 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-B0dlspz_.js"></script>
<script type="module" crossorigin src="/assets/main-etcSle_0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
</head>
<body>
+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) {