Compare commits

...
Author SHA1 Message Date
raucao 263ecbbad7 Add arrows to tooltips 2026-08-27 08:11:02 -06:00
raucao 7d19068777 Merge pull request 'Only trust content from trusted relays by default' (#85) from feature/relay_trust into master
CI / Lint (push) Successful in 1m16s
CI / Test (push) Successful in 1m31s
Reviewed-on: #85
2026-08-27 14:05:11 +00:00
raucao 17dd721986 Introduce tooltip component
CI / Lint (pull_request) Successful in 1m18s
CI / Test (pull_request) Successful in 1m32s
Release Drafter / Update release notes draft (pull_request) Successful in 6s
Tooltips need to be more reliable and controlled than simple title
attributes.
2026-08-27 07:47:14 -06:00
raucao 474bc8cd63 Add relay trust settings, provenance cache
By default, only automatically show photos for relays marked as trusted
(which by default is the required read relay). For other relays, only
show the user's own events by default, but allow showing all events via
link in place details (drawer bottom)
2026-08-26 18:06:06 -06:00
11 changed files with 742 additions and 66 deletions
+75 -7
View File
@@ -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>
+28
View File
@@ -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>
+62
View File
@@ -0,0 +1,62 @@
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 arrowSize = 6;
// Vertical placement: above trigger, flip below if no room
let placement = 'top';
let top = rect.top - tipRect.height - arrowSize;
if (top < arrowSize) {
placement = 'bottom';
top = rect.bottom + arrowSize;
}
// Horizontal: center on trigger, clamp to viewport
let left = rect.left + rect.width / 2 - tipRect.width / 2;
left = Math.max(
arrowSize,
Math.min(left, window.innerWidth - tipRect.width - arrowSize)
);
// Arrow always points at trigger center, even when clamped
const arrowLeft = rect.left + rect.width / 2 - left;
tooltipEl.dataset.placement = placement;
tooltipEl.style.top = `${top}px`;
tooltipEl.style.left = `${left}px`;
tooltipEl.style.setProperty('--arrow-left', `${arrowLeft}px`);
};
const hide = () => {
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();
};
});
+219 -53
View File
@@ -1,17 +1,20 @@
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 { 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,12 +27,30 @@ 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();
@@ -41,11 +62,26 @@ export default class NostrDataService extends Service {
@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;
// 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;
_blossomSub = null;
_photosSub = null;
_contributionsSub = null;
_deletionsSub = null;
_profileModelSubs = new Map();
_zapReceiptsSub = null;
@@ -109,6 +145,25 @@ 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: when a deletion event enters the
// store, drop the provenance entries for the events it deletes so the
// trust map and IDB don't accumulate dead entries.
this._deletionsSub = this.store
.timeline([{ kinds: [5] }])
.subscribe((events) => {
for (const event of events) {
for (const tag of event.tags || []) {
if (tag[0] === 'e' && tag[1]) {
this._removeProvenance(tag[1]);
}
}
}
});
// 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 +229,130 @@ 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
* - 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;
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 +391,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 +444,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 +473,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 +534,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) {
+7
View File
@@ -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));
+106 -3
View File
@@ -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,84 @@ 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;
}
.tooltip::after {
content: '';
position: absolute;
width: 0;
height: 0;
border: 6px solid transparent;
left: var(--arrow-left, 50%);
transform: translateX(-50%);
}
.tooltip[data-placement='top']::after {
top: 100%;
border-top-color: var(--body-text-color);
}
.tooltip[data-placement='bottom']::after {
bottom: 100%;
border-bottom-color: var(--body-text-color);
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes details-slide-down {
from {
opacity: 0;
+2
View File
@@ -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,
};
+21
View File
@@ -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.
+71
View File
@@ -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,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,97 @@ 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();
const tooltip = document.body.querySelector('.tooltip');
assert.dom('.tooltip', document.body).exists('tooltip appears on hover');
assert.dom('.tooltip', document.body).hasText('Remove relay');
// Verify arrow is rendered
assert.ok(tooltip.dataset.placement, 'tooltip has placement attribute');
const arrowStyle = getComputedStyle(tooltip, '::after');
assert.strictEqual(arrowStyle.content, '""', 'arrow pseudo-element exists');
// Verify arrow points in correct direction based on placement
if (tooltip.dataset.placement === 'top') {
assert.notStrictEqual(
arrowStyle.borderTopColor,
'rgba(0, 0, 0, 0)',
'arrow points down when tooltip is above trigger'
);
} else {
assert.notStrictEqual(
arrowStyle.borderBottomColor,
'rgba(0, 0, 0, 0)',
'arrow points up when tooltip is below trigger'
);
}
removeBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
await settled();
assert
.dom('.tooltip', document.body)
.doesNotExist('tooltip removed on mouseleave');
});
});
+41
View File
@@ -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'
);
});
});