Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ebe565cbc
|
||
|
|
e7020469c3
|
||
|
|
bf3011115b
|
||
|
|
8c98e0757b
|
||
|
|
2ae9a78f5d
|
||
|
|
252f41f897
|
||
|
|
572213eda2
|
||
|
|
ac7a4eb262
|
||
|
|
7d19068777
|
||
|
|
17dd721986
|
||
|
|
474bc8cd63
|
@@ -10,8 +10,10 @@ import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
uniqNormalizedRelays,
|
||||
} from '../../../utils/nostr';
|
||||
import { DEFAULT_BLOSSOM_SERVER } from '../../../services/blossom';
|
||||
import tooltip from '../../../modifiers/tooltip';
|
||||
|
||||
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||
|
||||
@@ -49,6 +51,10 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
return new Set(this.nostrData.requiredReadRelays.filter(Boolean));
|
||||
}
|
||||
|
||||
get trustedRelaySet() {
|
||||
return new Set(this.nostrData.trustedRelays.filter(Boolean));
|
||||
}
|
||||
|
||||
get requiredWriteRelaySet() {
|
||||
return new Set(this.nostrData.requiredWriteRelays.filter(Boolean));
|
||||
}
|
||||
@@ -78,6 +84,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
return {
|
||||
url,
|
||||
isRequired: this.requiredReadRelaySet.has(url),
|
||||
isTrusted: this.trustedRelaySet.has(url),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -182,6 +189,26 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
this.settings.update('nostrReadRelayExclusions', null);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleRelayTrust(url) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
if (!normalized) return;
|
||||
// Required relays are always trusted and cannot be untrusted.
|
||||
if (this.requiredReadRelaySet.has(normalized)) return;
|
||||
|
||||
const current = uniqNormalizedRelays(
|
||||
this.settings.nostrTrustedRelays || []
|
||||
);
|
||||
const idx = current.indexOf(normalized);
|
||||
let next;
|
||||
if (idx >= 0) {
|
||||
next = current.filter((r) => r !== normalized);
|
||||
} else {
|
||||
next = [...current, normalized];
|
||||
}
|
||||
this.settings.update('nostrTrustedRelays', next.length > 0 ? next : null);
|
||||
}
|
||||
|
||||
@action
|
||||
addWriteRelay() {
|
||||
const url = normalizeRelayUrl(this.newWriteRelay);
|
||||
@@ -251,7 +278,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
{{! template-lint-disable no-nested-interactive no-unsupported-role-attributes }}
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="zap" @size={{20}} />
|
||||
@@ -260,21 +287,56 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
<div class="details-content form-layout">
|
||||
<div class="form-group">
|
||||
<label for="new-read-relay">Read Relays</label>
|
||||
<p class="relay-list-hint">
|
||||
Mark relays as trusted to show
|
||||
<strong>all</strong>
|
||||
photos and reviews from them by default (risk of spam).
|
||||
</p>
|
||||
<ul class="relay-list">
|
||||
{{#each this.readRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
{{#unless relay.isRequired}}
|
||||
<div class="relay-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-trust-relay {{if relay.isTrusted 'is-trusted'}}"
|
||||
aria-label="Toggle trust"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relay — always trusted"
|
||||
(if
|
||||
relay.isTrusted
|
||||
"Trusted: show all content from this relay"
|
||||
"Untrusted: hide content from outside of my circles"
|
||||
)
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.toggleRelayTrust relay.url)}}
|
||||
>
|
||||
<Icon
|
||||
@name="shield"
|
||||
@size={{14}}
|
||||
@color="currentColor"
|
||||
@filled={{relay.isTrusted}}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relays cannot be removed"
|
||||
"Remove relay"
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.removeReadRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
{{/unless}}
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
@@ -311,17 +373,23 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||
<li>
|
||||
<span>{{stripProtocol relay.url}}</span>
|
||||
{{#unless relay.isRequired}}
|
||||
<div class="relay-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remove-relay"
|
||||
title="Remove relay"
|
||||
aria-label="Remove"
|
||||
aria-description={{if
|
||||
relay.isRequired
|
||||
"Required relays cannot be removed"
|
||||
"Remove relay"
|
||||
}}
|
||||
disabled={{relay.isRequired}}
|
||||
{{tooltip}}
|
||||
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
||||
>
|
||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||
</button>
|
||||
{{/unless}}
|
||||
</div>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
@@ -113,6 +113,17 @@ export default class PlaceDetails extends Component {
|
||||
}));
|
||||
}
|
||||
|
||||
get hasUntrustedContent() {
|
||||
return (
|
||||
this.nostrData.untrustedContentCount > 0 &&
|
||||
!this.nostrData.showUntrustedContent
|
||||
);
|
||||
}
|
||||
|
||||
get hasMultipleUntrusted() {
|
||||
return this.nostrData.untrustedContentCount > 1;
|
||||
}
|
||||
|
||||
@action
|
||||
startEditing() {
|
||||
if (!this.isSaved) return; // Only allow editing saved places
|
||||
@@ -618,6 +629,23 @@ export default class PlaceDetails extends Component {
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
{{#if this.hasUntrustedContent}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="shield" />
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
{{on "click" this.nostrData.toggleShowUntrustedContent}}
|
||||
>
|
||||
Show
|
||||
{{this.nostrData.untrustedContentCount}}
|
||||
hidden photo{{if this.hasMultipleUntrusted "s" ""}}
|
||||
(untrusted relays)
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element) => {
|
||||
let tooltipEl = null;
|
||||
const portal = document.getElementById('modal-portal') || document.body;
|
||||
|
||||
const show = () => {
|
||||
const text = element.getAttribute('aria-description');
|
||||
if (!text || tooltipEl) return;
|
||||
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.className = 'tooltip';
|
||||
tooltipEl.setAttribute('role', 'tooltip');
|
||||
tooltipEl.textContent = text;
|
||||
portal.appendChild(tooltipEl);
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tipRect = tooltipEl.getBoundingClientRect();
|
||||
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(
|
||||
margin,
|
||||
Math.min(left, window.innerWidth - tipRect.width - margin)
|
||||
);
|
||||
|
||||
tooltipEl.style.top = `${top}px`;
|
||||
tooltipEl.style.left = `${left}px`;
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
tooltipEl?.remove();
|
||||
tooltipEl = null;
|
||||
};
|
||||
|
||||
element.addEventListener('mouseenter', show);
|
||||
element.addEventListener('mouseleave', hide);
|
||||
element.addEventListener('focus', show);
|
||||
element.addEventListener('blur', hide);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('mouseenter', show);
|
||||
element.removeEventListener('mouseleave', hide);
|
||||
element.removeEventListener('focus', show);
|
||||
element.removeEventListener('blur', hide);
|
||||
hide();
|
||||
};
|
||||
});
|
||||
+251
-57
@@ -1,17 +1,21 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { EMPTY, from } from 'rxjs';
|
||||
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';
|
||||
import { NostrIDB, openDB } from 'nostr-idb';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
relayUrlMatches,
|
||||
uniqNormalizedRelays,
|
||||
} from '../utils/nostr';
|
||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
@@ -24,28 +28,67 @@ const DIRECTORY_RELAYS = [
|
||||
];
|
||||
|
||||
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
||||
const DEFAULT_WRITE_RELAYS = [];
|
||||
const DEFAULT_WRITE_RELAYS = []; // TODO nostr.kosmos.org/marco
|
||||
|
||||
// Content event kinds subject to relay-trust filtering. Adding `30360`
|
||||
// (place reviews) here is the only change needed to extend the feature.
|
||||
const TRUSTED_CONTENT_KINDS = [360];
|
||||
|
||||
// localforage store name for persisted relay provenance.
|
||||
const PROVENANCE_STORE = 'event-relay-provenance';
|
||||
|
||||
// Replicates applesauce-relay's `completeWhen`: completes the source when the
|
||||
// `operator` emits a truthy value. `completeWhen` is used internally by
|
||||
// RelayGroup but not re-exported, so we recreate it from rxjs primitives.
|
||||
function completeWhen(operator) {
|
||||
return connect((shared$) => {
|
||||
const complete$ = shared$.pipe(operator, filter(Boolean), take(1));
|
||||
return shared$.pipe(takeUntil(complete$));
|
||||
});
|
||||
}
|
||||
|
||||
export default class NostrDataService extends Service {
|
||||
@service nostrRelay;
|
||||
@service nostrAuth;
|
||||
@service settings;
|
||||
@service localForage;
|
||||
|
||||
store = new EventStore();
|
||||
|
||||
@tracked profile = null;
|
||||
@tracked mailboxes = null;
|
||||
@tracked contacts = null;
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked myContributionEvents = [];
|
||||
@tracked profiles = {};
|
||||
@tracked zapReceipts = {};
|
||||
|
||||
// Relay-provenance trust state. `_eventRelays` maps eventId -> Set<relayUrl>
|
||||
// and accumulates every relay an event was seen on (unconditionally, so an
|
||||
// event first seen on an untrusted relay can be upgraded to trusted when it
|
||||
// later arrives from a trusted one). Hydrated from IDB at init so cached
|
||||
// events render with correct trust state instantly.
|
||||
_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.
|
||||
@tracked untrustedContentCount = 0;
|
||||
_allPlacePhotos = [];
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_contactsSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
_contributionsSub = null;
|
||||
_deletionsSub = null;
|
||||
_profileModelSubs = new Map();
|
||||
|
||||
_zapReceiptsSub = null;
|
||||
@@ -87,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 ||
|
||||
@@ -109,6 +153,22 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
});
|
||||
|
||||
// Hydrate relay provenance from IDB so cached events can be trust-checked
|
||||
// instantly without waiting for relay connections.
|
||||
this._provenanceReady = this._hydrateProvenance();
|
||||
|
||||
// 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(() => {
|
||||
// Setup relay subscription tracking if needed, or we just rely on request()
|
||||
@@ -174,6 +234,133 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Relays treated as "moderated" for content-trust filtering. Always includes
|
||||
// the required read relays (e.g. nostr.kosmos.org); `nostrTrustedRelays`
|
||||
// adds user-marked custom relays on top. Default (null) = required relays
|
||||
// only, so the app only surfaces content verified by those relays.
|
||||
get trustedRelays() {
|
||||
const configured = this.settings.nostrTrustedRelays || [];
|
||||
return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (!event) return false;
|
||||
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;
|
||||
for (const url of relays) {
|
||||
if (relayUrlMatches(url, trusted)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits content events into trusted / untrusted buckets. Non-content kinds
|
||||
* (e.g. kind 5 deletions) are always treated as trusted so they pass through.
|
||||
*/
|
||||
partitionByTrust(events) {
|
||||
const trusted = [];
|
||||
const untrusted = [];
|
||||
for (const event of events) {
|
||||
if (
|
||||
!TRUSTED_CONTENT_KINDS.includes(event.kind) ||
|
||||
this.isTrustedEvent(event)
|
||||
) {
|
||||
trusted.push(event);
|
||||
} else {
|
||||
untrusted.push(event);
|
||||
}
|
||||
}
|
||||
return { trusted, untrusted };
|
||||
}
|
||||
|
||||
// Hydrate the in-memory provenance map from the IDB store.
|
||||
async _hydrateProvenance() {
|
||||
try {
|
||||
await this.localForage.iterate(PROVENANCE_STORE, (value, key) => {
|
||||
this._eventRelays.set(key, new Set(value || []));
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug('[nostr-data] Failed to hydrate relay provenance', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Record that `eventId` was seen on `relayUrl`. Merges into the existing
|
||||
// set (accumulates across sightings) and persists fire-and-forget.
|
||||
_recordProvenance(eventId, relayUrl) {
|
||||
if (!eventId || !relayUrl) return;
|
||||
let set = this._eventRelays.get(eventId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this._eventRelays.set(eventId, set);
|
||||
}
|
||||
if (set.has(relayUrl)) return;
|
||||
set.add(relayUrl);
|
||||
const urls = Array.from(set);
|
||||
// Fire-and-forget persistence; never blocks the render path.
|
||||
this.localForage.set(PROVENANCE_STORE, eventId, urls).catch((e) => {
|
||||
console.debug('[nostr-data] Failed to persist relay provenance', e);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove provenance for a deleted event (called on kind-5 processing).
|
||||
_removeProvenance(eventId) {
|
||||
if (!eventId) return;
|
||||
if (!this._eventRelays.delete(eventId)) return;
|
||||
this.localForage.remove(PROVENANCE_STORE, eventId).catch((e) => {
|
||||
console.debug('[nostr-data] Failed to remove relay provenance', e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request content events from relays while capturing full provenance.
|
||||
*
|
||||
* This mirrors `RelayGroup.request()` completion semantics (wait for the
|
||||
* first relay EOSE + 5s grace period, or all relays EOSE; 30s hard
|
||||
* timeout) but uses `pool.req()` so each `EVENT` message retains its
|
||||
* `from` relay URL. Every sighting is recorded in the provenance map
|
||||
* (unconditionally, including duplicates) so an event first seen on an
|
||||
* untrusted relay can be upgraded to trusted when it later arrives from
|
||||
* a trusted one. Events are always added to the store regardless of trust
|
||||
* status; filtering happens at presentation time.
|
||||
*/
|
||||
_requestContentWithProvenance(relays, filters, errorLabel) {
|
||||
const complete = RelayGroup.completeOnAny(
|
||||
RelayGroup.completeAfterFirstRelay(5_000),
|
||||
RelayGroup.completeOnAllEose()
|
||||
);
|
||||
return this.nostrRelay.pool
|
||||
.req(relays, filters)
|
||||
.pipe(
|
||||
completeWhen(complete),
|
||||
timeout({ first: 30_000 }),
|
||||
// Only EVENT messages carry content; ignore OPEN/EOSE/CLOSED/ERROR.
|
||||
// We deliberately do NOT dedupe so we see every relay's copy and can
|
||||
// accumulate full provenance across relays.
|
||||
filter((message) => message.type === 'EVENT')
|
||||
)
|
||||
.subscribe({
|
||||
next: (message) => {
|
||||
this._recordProvenance(message.event.id, message.from);
|
||||
this.store.add(message.event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(errorLabel, err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadPlacesInBounds(bbox) {
|
||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||
|
||||
@@ -212,25 +399,13 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Fire network request for new prefixes
|
||||
this.nostrRelay.pool
|
||||
.request(this.activeReadRelays, [
|
||||
{
|
||||
kinds: [360],
|
||||
'#g': missingPrefixes,
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching place photos by geohash:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
// Fire network request for new prefixes (captures relay provenance for
|
||||
// trust filtering; events are added to the store regardless of trust).
|
||||
this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
[{ kinds: [360], '#g': missingPrefixes }],
|
||||
'[nostr-data] Error fetching place photos by geohash:'
|
||||
);
|
||||
|
||||
for (const p of missingPrefixes) {
|
||||
this.loadedGeohashPrefixes.add(p);
|
||||
@@ -277,7 +452,8 @@ export default class NostrDataService extends Service {
|
||||
},
|
||||
])
|
||||
.subscribe((events) => {
|
||||
this.placePhotos = events;
|
||||
this._allPlacePhotos = events;
|
||||
this._updatePlacePhotos();
|
||||
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
||||
this.loadProfiles(pubkeys);
|
||||
this._scheduleZapReceiptRefresh(events);
|
||||
@@ -305,25 +481,28 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Fire network request specifically for this place
|
||||
this.nostrRelay.pool
|
||||
.request(this.activeReadRelays, [
|
||||
{
|
||||
kinds: [360, 5],
|
||||
'#i': [entityId],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching place photos for place:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
// Fire network request specifically for this place (captures provenance).
|
||||
this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
[{ kinds: [360, 5], '#i': [entityId] }],
|
||||
'[nostr-data] Error fetching place photos for place:'
|
||||
);
|
||||
}
|
||||
|
||||
// Recompute `placePhotos` from `_allPlacePhotos` applying the trust filter
|
||||
// and the session-only reveal toggle. Called whenever the timeline emits or
|
||||
// the user flips `showUntrustedContent`.
|
||||
_updatePlacePhotos() {
|
||||
const events = this._allPlacePhotos;
|
||||
const { trusted, untrusted } = this.partitionByTrust(events);
|
||||
this.untrustedContentCount = untrusted.length;
|
||||
this.placePhotos = this.showUntrustedContent ? events : trusted;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleShowUntrustedContent() {
|
||||
this.showUntrustedContent = !this.showUntrustedContent;
|
||||
this._updatePlacePhotos();
|
||||
}
|
||||
|
||||
async loadMyContributions(pubkey) {
|
||||
@@ -363,18 +542,13 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Request fresh events from the network in the background
|
||||
this.nostrRelay.pool.request(this.activeReadRelays, filters).subscribe({
|
||||
next: (event) => {
|
||||
this.store.add(event);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(
|
||||
'[nostr-data] Error fetching my contribution events:',
|
||||
err
|
||||
);
|
||||
},
|
||||
});
|
||||
// 3. Request fresh events from the network in the background (captures
|
||||
// provenance; own-author events bypass trust filtering anyway).
|
||||
this._requestContentWithProvenance(
|
||||
this.activeReadRelays,
|
||||
filters,
|
||||
'[nostr-data] Error fetching my contribution events:'
|
||||
);
|
||||
}
|
||||
|
||||
loadProfiles(pubkeys) {
|
||||
@@ -410,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();
|
||||
@@ -428,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) => {
|
||||
@@ -457,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],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -478,7 +662,7 @@ export default class NostrDataService extends Service {
|
||||
.request(profileRelays, [
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 10002, 10063],
|
||||
kinds: [0, 3, 10002, 10063],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
@@ -640,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;
|
||||
@@ -661,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();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ const DEFAULT_SETTINGS = {
|
||||
nostrWriteRelays: null,
|
||||
nostrReadRelayExclusions: null,
|
||||
nostrWriteRelayExclusions: null,
|
||||
// Relays treated as "moderated" for content-trust filtering. When null,
|
||||
// the app falls back to the required read relays (e.g. nostr.kosmos.org),
|
||||
// so default behavior only surfaces content verified by those relays.
|
||||
nostrTrustedRelays: null,
|
||||
experimentalEnablePhotoDeletion: false,
|
||||
};
|
||||
|
||||
@@ -30,6 +34,7 @@ export default class SettingsService extends Service {
|
||||
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
|
||||
@tracked nostrWriteRelayExclusions =
|
||||
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
|
||||
@tracked nostrTrustedRelays = DEFAULT_SETTINGS.nostrTrustedRelays;
|
||||
@tracked experimentalEnablePhotoDeletion =
|
||||
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
|
||||
|
||||
@@ -123,6 +128,7 @@ export default class SettingsService extends Service {
|
||||
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
|
||||
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
|
||||
this.nostrTrustedRelays = finalSettings.nostrTrustedRelays;
|
||||
this.experimentalEnablePhotoDeletion =
|
||||
finalSettings.experimentalEnablePhotoDeletion;
|
||||
|
||||
@@ -142,6 +148,7 @@ export default class SettingsService extends Service {
|
||||
nostrWriteRelays: this.nostrWriteRelays,
|
||||
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
|
||||
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
|
||||
nostrTrustedRelays: this.nostrTrustedRelays,
|
||||
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
||||
};
|
||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||
|
||||
+86
-3
@@ -13,6 +13,10 @@
|
||||
--danger-color: var(--marker-color-primary);
|
||||
--danger-color-dark: var(--marker-color-dark);
|
||||
--default-list-color: #fc3;
|
||||
--secondary-text-color: #898989;
|
||||
--border-color: #d8d8d8;
|
||||
--success-color: #2e7d32;
|
||||
--secondary-background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -611,14 +615,28 @@ body {
|
||||
|
||||
.relay-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0.25rem 0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.relay-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.relay-list-hint {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.btn-remove-relay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -639,12 +657,19 @@ body {
|
||||
stroke: currentcolor;
|
||||
}
|
||||
|
||||
.btn-remove-relay:hover,
|
||||
.btn-remove-relay:active {
|
||||
.btn-remove-relay:hover:not(:disabled),
|
||||
.btn-remove-relay:active:not(:disabled) {
|
||||
background-color: var(--danger-color);
|
||||
color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.btn-remove-relay:disabled {
|
||||
border-color: var(--border-color);
|
||||
color: var(--secondary-text-color);
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.add-relay-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -655,6 +680,64 @@ body {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-trust-relay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: all 0.1s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-trust-relay svg {
|
||||
stroke: currentcolor;
|
||||
}
|
||||
|
||||
.btn-trust-relay.is-trusted {
|
||||
border-color: var(--success-color);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.btn-trust-relay:hover,
|
||||
.btn-trust-relay:active {
|
||||
background-color: var(--secondary-background-color);
|
||||
}
|
||||
|
||||
.btn-trust-relay:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--body-text-color);
|
||||
color: var(--primary-background-color);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
animation: tooltip-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes tooltip-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes details-slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -34,6 +34,7 @@ import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import shield from 'feather-icons/dist/icons/shield.svg?raw';
|
||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
|
||||
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
|
||||
@@ -295,6 +296,7 @@ const ICONS = {
|
||||
'castle-keep': castleKeep,
|
||||
x,
|
||||
zap,
|
||||
shield,
|
||||
'loading-ring': loadingRing,
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,27 @@ export function excludeRequiredRelays(customRelays = [], requiredRelays = []) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `relayUrl` matches any entry in `trustedRelays`.
|
||||
*
|
||||
* Comparison is performed on normalized relay URLs (lowercased, `wss://`
|
||||
* scheme ensured, trailing slashes stripped) via `normalizeRelayUrl`, so
|
||||
* callers may pass raw `wss://` URLs received from relays or bare hostnames.
|
||||
*
|
||||
* @param {string} relayUrl The relay URL a content event was seen on
|
||||
* @param {Array<string>} trustedRelays List of trusted relay URLs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function relayUrlMatches(relayUrl, trustedRelays = []) {
|
||||
if (!relayUrl) return false;
|
||||
const target = normalizeRelayUrl(relayUrl);
|
||||
if (!target) return false;
|
||||
const trustedSet = new Set(
|
||||
uniqNormalizedRelays(trustedRelays || []).filter(Boolean)
|
||||
);
|
||||
return trustedSet.has(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
|
||||
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Relay Trust in Marco
|
||||
|
||||
## Purpose
|
||||
|
||||
Marco fetches Nostr content (currently kind 360 place photos, later kind 30360 reviews) from the user's read relays. Because anyone can run a relay and publish spam, Marco only displays content by default when it was seen on a **trusted relay** — a relay the user (or Marco's defaults) treats as sufficiently moderated.
|
||||
|
||||
## Trust Model
|
||||
|
||||
| Rule | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Required relays** | Always trusted (e.g. `wss://nostr.kosmos.org`). Cannot be untrusted. |
|
||||
| **Custom relays** | Untrusted by default. Users toggle them to trusted in **Settings → Nostr → Read Relays**. |
|
||||
| **Own content** | Events authored by the connected user's pubkey are always trusted. |
|
||||
| **Non-content kinds** | Kind 5 deletions and metadata always pass through unfiltered. |
|
||||
| **Provenance upgrades** | If an event is first seen on an untrusted relay and later on a trusted one, it becomes visible. |
|
||||
| **Hiding, not blocking** | Untrusted content is cached and stored; it is filtered at presentation time so it can be revealed on demand. |
|
||||
|
||||
## How It Works Under the Hood
|
||||
|
||||
### 1. Provenance Capture
|
||||
|
||||
- Instead of `pool.request()` (which drops relay origin via an internal `map(m => m.event)`), Marco uses `RelayPool.req()` from `applesauce-relay`.
|
||||
- Each `EVENT` message includes `from: relayUrl`.
|
||||
- Every sighting is recorded in an in-memory map: `eventId → Set<relayUrl>`.
|
||||
- Duplicate deliveries are _not_ deduplicated at the stream level, so full provenance across all responding relays is preserved.
|
||||
|
||||
### 2. Persistence
|
||||
|
||||
- Provenance is persisted to IndexedDB via `localForage` under the store `event-relay-provenance`.
|
||||
- On app start, it is hydrated into memory so cached photos render instantly with correct trust state before relay connections are established.
|
||||
|
||||
### 3. Trust Evaluation
|
||||
|
||||
- `trustedRelays` = required read relays + `settings.nostrTrustedRelays` (user-marked custom relays).
|
||||
- An event is trusted if any relay URL in its provenance set matches `trustedRelays` (compared via normalized URLs).
|
||||
- Trust is evaluated when presenting the photo timeline for a place.
|
||||
|
||||
### 4. Presentation Filtering
|
||||
|
||||
- `NostrDataService.placePhotos` contains only trusted events.
|
||||
- `untrustedContentCount` tracks how many are hidden.
|
||||
- A session-only toggle `showUntrustedContent` reveals them via the "Show N hidden photo(s) (untrusted relays)" button in place details.
|
||||
|
||||
### 5. Deletions
|
||||
|
||||
- When a kind 5 deletion event enters the store, provenance entries for the referenced event IDs are removed from memory and IndexedDB.
|
||||
|
||||
## Key Code Paths
|
||||
|
||||
| File | What it does |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `app/services/nostr-data.js` | `_requestContentWithProvenance()`, `isTrustedEvent()`, `partitionByTrust()`, `trustedRelays` getter, provenance hydration/persistence |
|
||||
| `app/services/settings.js` | `nostrTrustedRelays` setting (null = required relays only) |
|
||||
| `app/components/app-menu/settings/nostr.gjs` | Per-relay shield toggle in read-relay list |
|
||||
| `app/components/place-details.gjs` | Hidden-content reveal link in meta section |
|
||||
| `app/utils/nostr.js` | `relayUrlMatches()` normalization/matching helper |
|
||||
| `app/utils/icons.js` | `shield` icon used for trusted state |
|
||||
| `app/styles/app.css` | `.btn-trust-relay` styles in relay settings |
|
||||
|
||||
## Extending the Feature
|
||||
|
||||
- **Add kind 30360 reviews:** Add `30360` to `TRUSTED_CONTENT_KINDS` in `app/services/nostr-data.js`. Reuse `partitionByTrust` in the reviews timeline.
|
||||
- **Followee trust:** Extend `isTrustedEvent()` to also check `event.pubkey` against a contact/follow list.
|
||||
- **Spam eviction from cache:** Provenance data already supports this; future work can purge cached events from `nostr-idb` if they only appear on relays marked untrusted.
|
||||
- **Provenance UI:** The stored `eventId → relayUrls` map can power a "Seen on these relays" tooltip or details pane.
|
||||
|
||||
## Known Limitations / Future Work
|
||||
|
||||
- Provenance store is currently unbounded; add LRU/TTL cleanup.
|
||||
- Untrusting a relay hides its content immediately, but does not purge the `nostr-idb` event cache.
|
||||
- Relay settings UI is currently the only way to manage trust; no global "trust all" toggle.
|
||||
+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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { click, fillIn, render } from '@ember/test-helpers';
|
||||
import { click, fillIn, render, settled } from '@ember/test-helpers';
|
||||
import Service, { service } from '@ember/service';
|
||||
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
||||
import {
|
||||
@@ -59,6 +59,11 @@ class MockNostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
get trustedRelays() {
|
||||
const configured = this.settings.nostrTrustedRelays || [];
|
||||
return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]);
|
||||
}
|
||||
|
||||
async clearCache() {}
|
||||
}
|
||||
|
||||
@@ -108,8 +113,17 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
const requiredRow = rowByText(rows, 'nostr.kosmos.org');
|
||||
const mailboxRow = rowByText(rows, 'mailbox.example.com');
|
||||
|
||||
assert.dom(requiredRow.querySelector('.btn-remove-relay')).doesNotExist();
|
||||
assert.dom(mailboxRow.querySelector('.btn-remove-relay')).exists();
|
||||
const requiredRemoveBtn = requiredRow.querySelector('.btn-remove-relay');
|
||||
assert.dom(requiredRemoveBtn).exists();
|
||||
assert.true(requiredRemoveBtn.disabled, 'required relay cannot be removed');
|
||||
assert.strictEqual(
|
||||
requiredRemoveBtn.getAttribute('aria-description'),
|
||||
'Required relays cannot be removed'
|
||||
);
|
||||
|
||||
const mailboxRemoveBtn = mailboxRow.querySelector('.btn-remove-relay');
|
||||
assert.dom(mailboxRemoveBtn).exists();
|
||||
assert.false(mailboxRemoveBtn.disabled);
|
||||
});
|
||||
|
||||
test('removing mailbox read relay stores exclusion override', async function (assert) {
|
||||
@@ -217,4 +231,76 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
.dom('#nostr-photo-fallback-uploads')
|
||||
.exists('fallback toggle visible with multiple media servers');
|
||||
});
|
||||
|
||||
test('required read relay is trusted and its trust toggle is disabled', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const requiredRow = rowByText(readRows(element), 'nostr.kosmos.org');
|
||||
|
||||
const trustBtn = requiredRow.querySelector('.btn-trust-relay');
|
||||
assert.dom(trustBtn).exists('trust toggle renders for required relay');
|
||||
assert.true(trustBtn.classList.contains('is-trusted'));
|
||||
assert.true(trustBtn.disabled, 'required relay toggle is disabled');
|
||||
});
|
||||
|
||||
test('toggling trust on a custom read relay marks it trusted', async function (assert) {
|
||||
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const customRow = rowByText(readRows(element), 'custom.example.com');
|
||||
|
||||
const trustBtn = customRow.querySelector('.btn-trust-relay');
|
||||
assert.false(trustBtn.classList.contains('is-trusted'), 'starts untrusted');
|
||||
assert.strictEqual(this.settings.nostrTrustedRelays, null);
|
||||
|
||||
await click(trustBtn);
|
||||
|
||||
assert.deepEqual(this.settings.nostrTrustedRelays, [
|
||||
'wss://custom.example.com',
|
||||
]);
|
||||
const updatedBtn = rowByText(
|
||||
readRows(element),
|
||||
'custom.example.com'
|
||||
).querySelector('.btn-trust-relay');
|
||||
assert.true(updatedBtn.classList.contains('is-trusted'), 'now trusted');
|
||||
});
|
||||
|
||||
test('toggling trust off removes relay from trusted list', async function (assert) {
|
||||
this.settings.update('nostrReadRelays', ['wss://custom.example.com']);
|
||||
this.settings.update('nostrTrustedRelays', ['wss://custom.example.com']);
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const customRow = rowByText(readRows(element), 'custom.example.com');
|
||||
const trustBtn = customRow.querySelector('.btn-trust-relay');
|
||||
|
||||
assert.true(trustBtn.classList.contains('is-trusted'));
|
||||
|
||||
await click(trustBtn);
|
||||
|
||||
assert.strictEqual(
|
||||
this.settings.nostrTrustedRelays,
|
||||
null,
|
||||
'empty list collapses to null'
|
||||
);
|
||||
});
|
||||
|
||||
test('tooltip appears on hover and disappears on mouseleave', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const mailboxRow = rowByText(readRows(element), 'mailbox.example.com');
|
||||
const removeBtn = mailboxRow.querySelector('.btn-remove-relay');
|
||||
|
||||
assert.dom('.tooltip', document.body).doesNotExist('no tooltip initially');
|
||||
|
||||
removeBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await settled();
|
||||
|
||||
assert.dom('.tooltip', document.body).exists('tooltip appears on hover');
|
||||
assert.dom('.tooltip', document.body).hasText('Remove relay');
|
||||
|
||||
removeBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
await settled();
|
||||
|
||||
assert
|
||||
.dom('.tooltip', document.body)
|
||||
.doesNotExist('tooltip removed on mouseleave');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mergeRequiredRelays,
|
||||
normalizeRelayUrl,
|
||||
parsePlacePhotos,
|
||||
relayUrlMatches,
|
||||
uniqNormalizedRelays,
|
||||
} from 'marco/utils/nostr';
|
||||
|
||||
@@ -324,4 +325,44 @@ module('Unit | Utility | nostr', function () {
|
||||
'wss://custom.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('relayUrlMatches returns true for normalized trusted relays', function (assert) {
|
||||
const trusted = ['wss://nostr.kosmos.org', 'wss://relay.damus.io'];
|
||||
|
||||
assert.true(
|
||||
relayUrlMatches('wss://nostr.kosmos.org', trusted),
|
||||
'exact match'
|
||||
);
|
||||
assert.true(
|
||||
relayUrlMatches('wss://nostr.kosmos.org/', trusted),
|
||||
'trailing slash normalized'
|
||||
);
|
||||
assert.true(
|
||||
relayUrlMatches('WSS://Nostr.Kosmos.org/', trusted),
|
||||
'case-insensitive'
|
||||
);
|
||||
assert.true(
|
||||
relayUrlMatches('nostr.kosmos.org', trusted),
|
||||
'bare hostname gets wss:// prefix'
|
||||
);
|
||||
});
|
||||
|
||||
test('relayUrlMatches returns false for non-trusted relays', function (assert) {
|
||||
const trusted = ['wss://nostr.kosmos.org'];
|
||||
|
||||
assert.false(
|
||||
relayUrlMatches('wss://spam.example.com', trusted),
|
||||
'untrusted relay'
|
||||
);
|
||||
assert.false(relayUrlMatches('', trusted), 'empty url');
|
||||
assert.false(relayUrlMatches(null, trusted), 'null url');
|
||||
assert.false(
|
||||
relayUrlMatches('wss://nostr.kosmos.org', []),
|
||||
'empty trust list'
|
||||
);
|
||||
assert.false(
|
||||
relayUrlMatches('wss://nostr.kosmos.org', null),
|
||||
'null trust list'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user