Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
263ecbbad7
|
||
|
|
7d19068777
|
||
|
|
17dd721986
|
||
|
|
474bc8cd63
|
||
|
|
3cc5e8834e
|
||
|
|
45c18100d3
|
||
|
|
17db6a5f65
|
||
|
|
7f9ee29136
|
||
|
|
81bbc03f54
|
||
|
|
bf1fcbcb40
|
||
|
|
5d9c6ebc89
|
||
|
|
2690c5470f
|
@@ -1,359 +0,0 @@
|
||||
---
|
||||
name: nak
|
||||
description: Interact with Nostr protocol using the nak CLI tool. Use to generate Nostr secret keys, encode and decode Nostr identifiers (hex/npub/nsec/nip05/etc), fetch events from relays, sign and publish Nostr events, and more.
|
||||
license: CC-BY-SA-4.0
|
||||
---
|
||||
|
||||
# nak - Nostr Army Knife
|
||||
|
||||
Work with the Nostr protocol using the `nak` CLI tool.
|
||||
|
||||
GitHub: https://github.com/fiatjaf/nak
|
||||
|
||||
## Installation
|
||||
|
||||
To install (or upgrade) nak:
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
Based on analyzing extensive real-world usage, here are the fundamental concepts distilled down, then branched out:
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 1: Query & Discovery
|
||||
*"Finding events and data on Nostr"*
|
||||
|
||||
**Basic:**
|
||||
- Fetch by identifier: "Get event/profile without knowing relays"
|
||||
- `nak fetch nevent1...` (uses embedded relay hints)
|
||||
- `nak fetch alex@gleasonator.dev` (NIP-05 resolution)
|
||||
- Query by event ID: "I want THIS specific event from a relay"
|
||||
- `nak req -i <event_id> <relay>`
|
||||
- Query by author: "Show me everything from this person"
|
||||
- `nak req -a <pubkey> <relay>`
|
||||
- Query by kind: "Show me all profiles/notes/videos"
|
||||
- `nak req -k <kind> <relay>`
|
||||
|
||||
**Intermediate:**
|
||||
- Fetch addressable events: "Get event by naddr/nprofile"
|
||||
- `nak fetch naddr1...` (kind, author, identifier encoded)
|
||||
- `nak fetch -r relay.primal.net naddr1...` (override relays)
|
||||
- Filter by multiple criteria: "Find posts by author X of kind Y"
|
||||
- `nak req -k 1 -a <pubkey> <relay>`
|
||||
- Tag-based queries: "Find events tagged with bitcoin"
|
||||
- `nak req --tag t=bitcoin <relay>`
|
||||
|
||||
**Advanced:**
|
||||
- Search with ranking: "Find trending/top content"
|
||||
- `nak req --search "sort:hot" <relay>`
|
||||
- `nak req --search "popular:24h" <relay>`
|
||||
- Live monitoring: "Watch events in real-time"
|
||||
- `nak req --stream <relay>`
|
||||
- Cross-protocol queries: "Find bridged Bluesky content"
|
||||
- `nak req --tag "proxy=at://..." <relay>`
|
||||
|
||||
**Use cases:** Discovering content, debugging relay data, testing search algorithms, monitoring live feeds
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 2: Broadcast & Migration
|
||||
*"Moving/copying events between relays"*
|
||||
|
||||
**Basic:**
|
||||
- Publish a single event: "Put this on relay X"
|
||||
- `cat event.json | nak event <relay>`
|
||||
- Query and republish: "Copy event from relay A to relay B"
|
||||
- `nak req -i <id> relay1 | nak event relay2`
|
||||
|
||||
**Intermediate:**
|
||||
- Batch migration: "Copy all events of kind X"
|
||||
- `nak req -k 30717 source_relay | nak event dest_relay`
|
||||
- Paginated backup: "Download everything from a relay"
|
||||
- `nak req --paginate source_relay | nak event backup_relay`
|
||||
- Multi-relay broadcast: "Publish to multiple relays at once"
|
||||
- `cat event.json | nak event relay1 relay2 relay3`
|
||||
|
||||
**Advanced:**
|
||||
- Selective migration: "Copy follow list members' data"
|
||||
- Loop through follow list, query each author, republish to new relay
|
||||
- Filter and migrate: "Copy only tagged/searched content"
|
||||
- `nak req --tag client=X relay1 | nak event relay2`
|
||||
- Cross-relay synchronization: "Keep two relays in sync"
|
||||
- `nak sync source_relay dest_relay`
|
||||
|
||||
**Use cases:** Seeding new relays, backing up data, migrating content between relays, bridging Mostr/Fediverse content
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 3: Identity & Encoding
|
||||
*"Working with Nostr identifiers and keys"*
|
||||
|
||||
**Basic:**
|
||||
- Decode identifiers: "What's inside this npub/nevent?"
|
||||
- `nak decode npub1...`
|
||||
- `nak decode user@domain.com`
|
||||
- Encode identifiers: "Turn this pubkey into npub"
|
||||
- `nak encode npub <hex_pubkey>`
|
||||
|
||||
**Intermediate:**
|
||||
- Generate keys: "Create a new identity"
|
||||
- `nak key generate`
|
||||
- `nak key generate | nak key public | nak encode npub`
|
||||
- Extract hex from NIP-05: "Get the raw pubkey from an address"
|
||||
- `nak decode user@domain.com | jq -r .pubkey`
|
||||
- Create shareable references: "Make a nevent with relay hints"
|
||||
- `nak encode nevent <event_id> --relay <relay>`
|
||||
|
||||
**Advanced:**
|
||||
- Complex naddr creation: "Create addressable event reference with metadata"
|
||||
- `nak encode naddr -k 30717 -a <author> -d <identifier> -r <relay>`
|
||||
- Multi-relay nprofile: "Create profile with multiple relay hints"
|
||||
- `nak encode nprofile <pubkey> -r relay1 -r relay2 -r relay3`
|
||||
|
||||
**Use cases:** Converting between formats, sharing references with relay hints, managing multiple identities, extracting pubkeys for scripting
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 4: Event Creation & Publishing
|
||||
*"Creating and signing new events"*
|
||||
|
||||
**Basic:**
|
||||
- Interactive creation: "Create an event with prompts"
|
||||
- `nak event --prompt-sec <relay>`
|
||||
- Simple note: "Publish a text note"
|
||||
- `nak event -k 1 -c "Hello Nostr" <relay>`
|
||||
|
||||
**Intermediate:**
|
||||
- Events with tags: "Create tagged content"
|
||||
- `nak event -k 1 -t t=bitcoin -t t=nostr <relay>`
|
||||
- Event deletion: "Delete a previous event"
|
||||
- `nak event -k 5 -e <event_id> --prompt-sec <relay>`
|
||||
- Replaceable events: "Create/update a profile or app data"
|
||||
- `nak event -k 10019 -t mint=<url> -t pubkey=<key> <relay>`
|
||||
|
||||
**Advanced:**
|
||||
- Remote signing with bunker: "Sign without exposing keys"
|
||||
- `nak event --connect "bunker://..." <relay>`
|
||||
- Batch event creation: "Generate many events via script"
|
||||
- `for i in {1..100}; do nak event localhost:8000; done`
|
||||
- Complex events from JSON: "Craft specific event structure"
|
||||
- Modify JSON, then `cat event.json | nak event <relay>`
|
||||
|
||||
**Use cases:** Testing event creation, developing apps (kind 31990, 37515), wallet integration (kind 10019), moderation (kind 5), bunker/remote signing implementation
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 5: Development & Testing
|
||||
*"Building on Nostr"*
|
||||
|
||||
**Basic:**
|
||||
- Local relay testing: "Test against dev relay"
|
||||
- `nak req localhost:8000`
|
||||
- `nak event ws://127.0.0.1:7777`
|
||||
|
||||
**Intermediate:**
|
||||
- Inspect JSON: "Examine event structure"
|
||||
- `nak req -i <id> <relay> | jq .`
|
||||
- Test search: "Verify search functionality"
|
||||
- `nak req --search "<query>" localhost:8000`
|
||||
- Admin operations: "Manage relay content"
|
||||
- `nak admin --prompt-sec banevent --id <id> <relay>`
|
||||
|
||||
**Advanced:**
|
||||
- Protocol bridging: "Query/test ATProto integration"
|
||||
- `nak req --tag "proxy=at://..." eclipse.pub/relay`
|
||||
- Git over Nostr: "Use git with Nostr transport"
|
||||
- `nak git clone nostr://...`
|
||||
- `nak req -k 30617 git.shakespeare.diy`
|
||||
- Performance testing: "Measure query speed"
|
||||
- `time nak req -k 0 -a <pubkey> <relay>`
|
||||
- Custom event kinds: "Test proprietary event types"
|
||||
- `nak req -k 37515 -a <author> -d <id> ditto.pub/relay`
|
||||
|
||||
**Use cases:** Relay development (Ditto), testing bridges (Mostr/Bluesky), developing video platforms, implementing Git-over-Nostr, testing search ranking algorithms, performance benchmarking
|
||||
|
||||
---
|
||||
|
||||
### CONCEPT 6: Analytics & Monitoring
|
||||
*"Understanding Nostr data"*
|
||||
|
||||
**Basic:**
|
||||
- Count results: "How many events match?"
|
||||
- `nak req -k 1 <relay> | wc -l`
|
||||
- `nak count -k 7 -e <event_id> <relay>`
|
||||
|
||||
**Intermediate:**
|
||||
- Live monitoring: "Watch relay activity"
|
||||
- `nak req --stream <relay>`
|
||||
- `nak req -l 0 --stream relay1 relay2 relay3`
|
||||
- Client analytics: "What apps are posting?"
|
||||
- `nak req --tag client=<app> <relay>`
|
||||
|
||||
**Advanced:**
|
||||
- Event chain analysis: "Track engagement"
|
||||
- `nak count -k 7 -e <event_id> <relay>` (reactions)
|
||||
- `nak req -k 6 -k 7 -e <event_id> <relay>` (reposts + reactions)
|
||||
- Content ranking: "Find top/hot content"
|
||||
- `nak req --search "sort:top" <relay>`
|
||||
- Cross-relay comparison: "Compare event availability"
|
||||
- Query same event from multiple relays, compare results
|
||||
|
||||
**Use cases:** Monitoring relay health, tracking client usage (Ditto, noStrudel, moStard), analyzing engagement, testing ranking algorithms
|
||||
|
||||
---
|
||||
|
||||
## Summary: The 6 Core Mental Models
|
||||
|
||||
1. **Query & Discovery**: "How do I find things?"
|
||||
2. **Broadcast & Migration**: "How do I move things?"
|
||||
3. **Identity & Encoding**: "How do I represent things?"
|
||||
4. **Event Creation**: "How do I make things?"
|
||||
5. **Development & Testing**: "How do I build things?"
|
||||
6. **Analytics & Monitoring**: "How do I measure things?"
|
||||
|
||||
---
|
||||
|
||||
## Command Shapes and Edge-Cases
|
||||
|
||||
Non-obvious patterns and edge cases for nak commands.
|
||||
|
||||
### Signing Methods
|
||||
|
||||
**Using environment variable:**
|
||||
```bash
|
||||
export NOSTR_SECRET_KEY=<hex_key>
|
||||
nak event -c "hello" # Automatically uses $NOSTR_SECRET_KEY
|
||||
```
|
||||
|
||||
**Reading key from file:**
|
||||
```bash
|
||||
nak event -c "hello" --sec $(cat /path/to/key.txt)
|
||||
```
|
||||
|
||||
### Content from File
|
||||
|
||||
**Using @ prefix to read content from file:**
|
||||
```bash
|
||||
echo "hello world" > content.txt
|
||||
nak event -c @content.txt
|
||||
```
|
||||
|
||||
### Tag Syntax
|
||||
|
||||
**Tag with multiple values (semicolon-separated):**
|
||||
```bash
|
||||
nak event -t custom="value1;value2;value3"
|
||||
# Creates: ["custom", "value1", "value2", "value3"]
|
||||
```
|
||||
|
||||
### Filter Output Modes
|
||||
|
||||
**Print bare filter (JSON only):**
|
||||
```bash
|
||||
nak req -k 1 -l 5 --bare
|
||||
# Output: {"kinds":[1],"limit":5}
|
||||
```
|
||||
|
||||
**Filter from stdin can be modified with flags:**
|
||||
```bash
|
||||
echo '{"kinds": [1]}' | nak req -l 5 -k 3 --bare
|
||||
```
|
||||
|
||||
**Unlimited stream:**
|
||||
```bash
|
||||
nak req -l 0 --stream wss://relay.example.com
|
||||
```
|
||||
|
||||
### Relay Specification
|
||||
|
||||
**Local relays and WebSocket schemes:**
|
||||
```bash
|
||||
nak req localhost:8000
|
||||
nak req ws://127.0.0.1:7777
|
||||
nak req relay.example.com # Assumes wss:// if not specified
|
||||
```
|
||||
|
||||
### Encoding
|
||||
|
||||
**Encode from JSON stdin (auto-detects type):**
|
||||
```bash
|
||||
echo '{"pubkey":"<hex>","relays":["wss://relay.example.com"]}' | nak encode
|
||||
```
|
||||
|
||||
### Key Operations
|
||||
|
||||
**Complete key generation pipeline:**
|
||||
```bash
|
||||
nak key generate | tee secret.key | nak key public | nak encode npub
|
||||
# Saves private key to file AND prints the public npub
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
**Verify and pipe:**
|
||||
```bash
|
||||
nak event -c "test" --sec <key> | nak verify && echo "Valid"
|
||||
```
|
||||
|
||||
### Fetch vs Req
|
||||
|
||||
**nak fetch uses relay hints from identifiers:**
|
||||
```bash
|
||||
nak fetch nevent1... # Uses relays encoded in nevent
|
||||
nak fetch naddr1... # Uses relays encoded in naddr
|
||||
nak fetch alex@gleasonator.dev # Resolves NIP-05
|
||||
nak fetch -r relay.primal.net naddr1... # Override relays
|
||||
```
|
||||
|
||||
**nak req requires explicit relay specification:**
|
||||
```bash
|
||||
nak req -i <event_id> wss://relay.example.com
|
||||
```
|
||||
|
||||
### Edge Cases
|
||||
|
||||
**No relays specified (prints event without publishing):**
|
||||
```bash
|
||||
nak event -c "test" --sec <key> # Just prints the event JSON
|
||||
```
|
||||
|
||||
**Tag order matters for addressable events:**
|
||||
```bash
|
||||
# The first 'd' tag is the identifier
|
||||
nak event -k 30023 -d first -d second # "first" is the identifier
|
||||
```
|
||||
|
||||
**Timestamp override:**
|
||||
```bash
|
||||
nak event --ts 1700000000 -c "backdated" --sec <key>
|
||||
nak event --ts 0 -c "genesis" --sec <key> # Event at Unix epoch
|
||||
```
|
||||
|
||||
**Kind 0 (profile) requires JSON content:**
|
||||
```bash
|
||||
nak event -k 0 -c '{"name":"Alice","about":"Developer"}' --sec <key>
|
||||
```
|
||||
|
||||
**POW (Proof of Work):**
|
||||
```bash
|
||||
nak event --pow 20 -c "mined event" --sec <key>
|
||||
# Will compute hash until difficulty target is met
|
||||
```
|
||||
|
||||
**NIP-42 AUTH:**
|
||||
```bash
|
||||
nak req --auth -k 1 wss://relay.example.com
|
||||
nak event --auth -c "test" --sec <key> wss://relay.example.com
|
||||
# Automatically handles AUTH challenges
|
||||
```
|
||||
|
||||
**Stdin takes precedence over flags:**
|
||||
```bash
|
||||
echo '{"content":"from stdin"}' | nak event -c "from flag" --sec <key>
|
||||
# Uses "from stdin" (stdin overrides flags)
|
||||
```
|
||||
|
||||
@@ -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,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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -47,9 +48,13 @@ import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
* Pinhead icons
|
||||
*/
|
||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||
import anchor from '@waysidemapping/pinhead/dist/icons/anchor.svg?raw';
|
||||
import airportTerminal from '@waysidemapping/pinhead/dist/icons/airport_terminal.svg?raw';
|
||||
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||
import barrelWithBunghole from '@waysidemapping/pinhead/dist/icons/barrel_with_bunghole.svg?raw';
|
||||
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
||||
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
|
||||
import banknoteInSlot from '@waysidemapping/pinhead/dist/icons/banknote_in_slot.svg?raw';
|
||||
import badgeShieldWithFire from '@waysidemapping/pinhead/dist/icons/badge_shield_with_fire.svg?raw';
|
||||
import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
|
||||
import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
|
||||
@@ -60,6 +65,7 @@ import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
||||
import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
||||
import carAndWrench from '@waysidemapping/pinhead/dist/icons/car_and_wrench.svg?raw';
|
||||
import carBetweenRollerBrushes from '@waysidemapping/pinhead/dist/icons/car_between_roller_brushes.svg?raw';
|
||||
import castleKeep from '@waysidemapping/pinhead/dist/icons/castle_keep.svg?raw';
|
||||
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
||||
import cityGate from '@waysidemapping/pinhead/dist/icons/city_gate.svg?raw';
|
||||
@@ -72,11 +78,13 @@ import clothesHanger from '@waysidemapping/pinhead/dist/icons/clothes_hanger.svg
|
||||
import cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw';
|
||||
import cloth from '@waysidemapping/pinhead/dist/icons/cloth.svg?raw';
|
||||
import cocktail from '@waysidemapping/pinhead/dist/icons/cocktail.svg?raw';
|
||||
import couch from '@waysidemapping/pinhead/dist/icons/couch.svg?raw';
|
||||
import coffeeBean from '@waysidemapping/pinhead/dist/icons/coffee_bean.svg?raw';
|
||||
import comedyMaskAndTragedyMask from '@waysidemapping/pinhead/dist/icons/comedy_mask_and_tragedy_mask.svg?raw';
|
||||
import croissant from '@waysidemapping/pinhead/dist/icons/croissant.svg?raw';
|
||||
import cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
|
||||
import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
|
||||
import dollarSwapToEuro from '@waysidemapping/pinhead/dist/icons/dollar_swap_to_euro.svg?raw';
|
||||
import eyeglasses from '@waysidemapping/pinhead/dist/icons/eyeglasses.svg?raw';
|
||||
import fancyMirrorWithReflectionAndStars from '@waysidemapping/pinhead/dist/icons/fancy_mirror_with_reflection_and_stars.svg?raw';
|
||||
import familyRestroomSymbol from '@waysidemapping/pinhead/dist/icons/family_restroom_symbol.svg?raw';
|
||||
@@ -90,6 +98,8 @@ import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?
|
||||
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
||||
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
|
||||
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||
import grapes from '@waysidemapping/pinhead/dist/icons/grapes.svg?raw';
|
||||
import gasolinePump from '@waysidemapping/pinhead/dist/icons/gasoline_pump.svg?raw';
|
||||
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
|
||||
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
|
||||
import infoI from '@waysidemapping/pinhead/dist/icons/info_i.svg?raw';
|
||||
@@ -131,6 +141,7 @@ import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_an
|
||||
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
||||
import windingWayWide from '@waysidemapping/pinhead/dist/icons/winding_way_wide.svg?raw';
|
||||
import waterStandpipeWithHandleAndDrinkingGlass from '@waysidemapping/pinhead/dist/icons/water_standpipe_with_handle_and_drinking_glass.svg?raw';
|
||||
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||
|
||||
/*
|
||||
@@ -145,9 +156,13 @@ import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
const ICONS = {
|
||||
activity,
|
||||
angelfish,
|
||||
anchor,
|
||||
'airport-terminal': airportTerminal,
|
||||
'arrow-left': arrowLeft,
|
||||
barbell,
|
||||
'barrel-with-bunghole': barrelWithBunghole,
|
||||
banknote,
|
||||
'banknote-in-slot': banknoteInSlot,
|
||||
'badge-shield-with-fire': badgeShieldWithFire,
|
||||
'beach-umbrella-in-ground': beachUmbrellaInGround,
|
||||
'beer-mug-with-foam': beerMugWithFoam,
|
||||
@@ -176,6 +191,7 @@ const ICONS = {
|
||||
cleaver,
|
||||
cloth,
|
||||
cocktail,
|
||||
couch,
|
||||
clock,
|
||||
'coffee-bean': coffeeBean,
|
||||
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
||||
@@ -183,6 +199,7 @@ const ICONS = {
|
||||
'cup-and-saucer': cupAndSaucer,
|
||||
database,
|
||||
donut,
|
||||
'dollar-swap-to-euro': dollarSwapToEuro,
|
||||
edit,
|
||||
eyeglasses,
|
||||
facebook,
|
||||
@@ -195,8 +212,10 @@ const ICONS = {
|
||||
'flower-bouquet': flowerBouquet,
|
||||
'fork-and-knife': forkAndKnife,
|
||||
fort,
|
||||
'gasoline-pump': gasolinePump,
|
||||
gift,
|
||||
globe,
|
||||
grapes,
|
||||
gravestone,
|
||||
'grecian-vase': grecianVase,
|
||||
'greek-cross': greekCross,
|
||||
@@ -268,12 +287,16 @@ const ICONS = {
|
||||
whatsapp,
|
||||
wikipedia,
|
||||
winding_way_wide: windingWayWide,
|
||||
'water-standpipe-with-handle-and-drinking-glass':
|
||||
waterStandpipeWithHandleAndDrinkingGlass,
|
||||
parking_p: parkingP,
|
||||
car,
|
||||
'car-and-wrench': carAndWrench,
|
||||
'car-between-roller-brushes': carBetweenRollerBrushes,
|
||||
'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.
|
||||
|
||||
@@ -9,6 +9,8 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { cuisine: 'coffee_shop' }, icon: 'coffee-bean' },
|
||||
{ tags: { cuisine: 'coffee' }, icon: 'coffee-bean' },
|
||||
|
||||
{ tags: { craft: 'brewery' }, icon: 'barrel-with-bunghole' },
|
||||
|
||||
// General Amenity/Shop Types
|
||||
{ tags: { amenity: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||
{ tags: { cuisine: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||
@@ -26,14 +28,24 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { amenity: 'social_facility' }, icon: 'family-restroom-symbol' },
|
||||
|
||||
{ tags: { amenity: 'bank' }, icon: 'banknote' },
|
||||
{ tags: { amenity: 'atm' }, icon: 'banknote-in-slot' },
|
||||
{ tags: { amenity: 'bureau_de_change' }, icon: 'dollar-swap-to-euro' },
|
||||
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
|
||||
{ tags: { amenity: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||
{ tags: { building: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
|
||||
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
||||
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
|
||||
{ tags: { amenity: 'smoking_area' }, icon: 'cigarette-with-smoke-curl' },
|
||||
{ tags: { amenity: 'lounge' }, icon: 'couch' },
|
||||
{
|
||||
tags: { amenity: 'drinking_water' },
|
||||
icon: 'water-standpipe-with-handle-and-drinking-glass',
|
||||
},
|
||||
{ tags: { amenity: 'school' }, icon: 'open-book' },
|
||||
{ tags: { amenity: 'driving_school' }, icon: 'car' },
|
||||
{ tags: { amenity: 'car_wash' }, icon: 'car-between-roller-brushes' },
|
||||
{ tags: { amenity: 'fuel' }, icon: 'gasoline-pump' },
|
||||
|
||||
{ tags: { shop: 'coffee' }, icon: 'coffee-bean' },
|
||||
{ tags: { shop: 'tea' }, icon: 'coffee-bean' },
|
||||
@@ -85,9 +97,12 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { natural: 'beach' }, icon: 'beach-umbrella-in-ground' },
|
||||
{ tags: { leisure: 'park' }, icon: 'tree-and-bench-with-backrest' },
|
||||
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
|
||||
{ tags: { leisure: 'marina' }, icon: 'anchor' },
|
||||
{ tags: { landuse: 'vineyard' }, icon: 'grapes' },
|
||||
|
||||
// Transport
|
||||
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
|
||||
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
|
||||
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
|
||||
@@ -187,6 +202,7 @@ export const POI_ICON_RULES = [
|
||||
// Buildings
|
||||
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
||||
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
||||
{ tags: { building: 'office' }, icon: 'lowrise-building' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -34,18 +34,18 @@ export function getLocalizedName(tags, defaultName = 'Untitled Place') {
|
||||
}
|
||||
|
||||
const PLACE_TYPE_KEYS = [
|
||||
'craft',
|
||||
'amenity',
|
||||
'shop',
|
||||
'tourism',
|
||||
'historic',
|
||||
'leisure',
|
||||
'office',
|
||||
'craft',
|
||||
'aeroway',
|
||||
'building',
|
||||
'landuse',
|
||||
'public_transport',
|
||||
'highway',
|
||||
'aeroway',
|
||||
'waterway',
|
||||
'natural',
|
||||
'place',
|
||||
|
||||
@@ -2,11 +2,11 @@ import { POI_CATEGORIES } from './poi-categories';
|
||||
import { getMatchingPoiCategoryIds } from './poi-category-matcher';
|
||||
|
||||
export const CATEGORY_TAGS = {
|
||||
restaurants: ['food', 'menu', 'vibe', 'front'],
|
||||
coffee: ['food', 'menu', 'vibe', 'front'],
|
||||
groceries: ['front', 'food'],
|
||||
'things-to-do': ['architecture', 'amenities', 'vibe', 'front'],
|
||||
accommodation: ['rooms', 'amenities', 'food', 'vibe', 'front'],
|
||||
restaurants: ['front', 'food', 'drinks', 'menu', 'vibe'],
|
||||
coffee: ['front', 'food', 'drinks', 'menu', 'vibe'],
|
||||
groceries: ['front', 'products'],
|
||||
'things-to-do': ['front', 'architecture', 'amenities', 'vibe'],
|
||||
accommodation: ['front', 'rooms', 'amenities', 'food', 'drinks', 'vibe'],
|
||||
};
|
||||
|
||||
export function getSuggestedPhotoTags(place) {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const POI_CATEGORIES = [
|
||||
filter: [
|
||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]',
|
||||
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
||||
'["leisure"~"^(sports_centre|stadium|water_park|golf_course|marina)$"]',
|
||||
'["historic"]',
|
||||
'["shop"="flea_market"]',
|
||||
],
|
||||
|
||||
@@ -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.0",
|
||||
"version": "1.30.1",
|
||||
"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
+1
-1
@@ -39,7 +39,7 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-Dw0KrEfS.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-DeiUGvCJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DWxZH-9E.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
"skillPath": "skills/ember-best-practices/SKILL.md",
|
||||
"computedHash": "34e82668e2fa1e6b025ac8cf1015634484bf5d8fbc80e8aa642489d2df79498c"
|
||||
},
|
||||
"nak": {
|
||||
"source": "soapbox-pub/nostr-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/nak/SKILL.md",
|
||||
"computedHash": "710d3f3945ff421ed2b7f40ecd32c5e263bc029d43fe8f4fd1491a8013c7389a"
|
||||
},
|
||||
"nostr": {
|
||||
"source": "soapbox-pub/nostr-skills",
|
||||
"sourceType": "github",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ module('Integration | Component | place-photo-upload', function (hooks) {
|
||||
|
||||
assert.dom('.photo-tag-suggestions').exists();
|
||||
assert.dom('.photo-tag-chip').exists();
|
||||
assert.dom('.photo-tag-chip').includesText('Food');
|
||||
assert.dom('.photo-tag-chip').includesText('Front');
|
||||
});
|
||||
|
||||
test('it only allows one selected tag at a time', async function (assert) {
|
||||
|
||||
@@ -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