Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
263ecbbad7
|
||
|
|
7d19068777
|
||
|
|
17dd721986
|
||
|
|
474bc8cd63
|
||
|
|
3cc5e8834e
|
||
|
|
45c18100d3
|
||
|
|
17db6a5f65
|
||
|
|
7f9ee29136
|
||
|
|
81bbc03f54
|
||
|
|
bf1fcbcb40
|
||
|
|
5d9c6ebc89
|
||
|
|
2690c5470f
|
||
|
|
46bb2efc70
|
||
|
|
6feb776247
|
||
|
|
2746ec035d
|
||
|
|
cdeb4f5d8c
|
||
|
|
8494f5a084
|
No files matched your search
@@ -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)
|
||||
```
|
||||
|
||||
@@ -4,12 +4,16 @@ import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { fn } from '@ember/helper';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import Icon from '#components/icon';
|
||||
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?:\/\//, '') : '');
|
||||
|
||||
@@ -47,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));
|
||||
}
|
||||
@@ -76,6 +84,7 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
return {
|
||||
url,
|
||||
isRequired: this.requiredReadRelaySet.has(url),
|
||||
isTrusted: this.trustedRelaySet.has(url),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -89,6 +98,15 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
get mediaServerOptions() {
|
||||
const servers = this.nostrData.blossomServers;
|
||||
return servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
|
||||
}
|
||||
|
||||
get hasMultipleMediaServers() {
|
||||
return this.mediaServerOptions.length > 1;
|
||||
}
|
||||
|
||||
@action
|
||||
updateNewReadRelay(event) {
|
||||
this.newReadRelay = event.target.value;
|
||||
@@ -171,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);
|
||||
@@ -240,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}} />
|
||||
@@ -249,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>
|
||||
@@ -300,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>
|
||||
@@ -342,31 +421,57 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||
servers</label>
|
||||
<label for="nostr-media-server">Media server</label>
|
||||
<select
|
||||
id="nostr-photo-fallback-uploads"
|
||||
id="nostr-media-server"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
||||
{{on "change" (fn @onChange "nostrMediaServer")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
{{#each this.mediaServerOptions as |server|}}
|
||||
<option
|
||||
value={{server}}
|
||||
selected={{if
|
||||
(eq server this.settings.nostrMediaServer)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{server}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{#if this.hasMultipleMediaServers}}
|
||||
<div class="form-group">
|
||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
||||
servers</label>
|
||||
<select
|
||||
id="nostr-photo-fallback-uploads"
|
||||
class="form-control"
|
||||
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.nostrPhotoFallbackUploads
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="form-group">
|
||||
<label>Cached data</label>
|
||||
<button
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
});
|
||||
@@ -24,14 +24,15 @@ export default class BlossomService extends Service {
|
||||
@service settings;
|
||||
|
||||
get servers() {
|
||||
const servers = this.nostrData.blossomServers;
|
||||
const allServers = servers.length ? servers : [DEFAULT_BLOSSOM_SERVER];
|
||||
const discovered = this.nostrData.blossomServers;
|
||||
const mainServer = this.settings.nostrMediaServer || DEFAULT_BLOSSOM_SERVER;
|
||||
const fallbacks = discovered.filter((server) => server !== mainServer);
|
||||
|
||||
if (!this.settings.nostrPhotoFallbackUploads) {
|
||||
return [allServers[0]];
|
||||
return [mainServer];
|
||||
}
|
||||
|
||||
return allServers;
|
||||
return [mainServer, ...fallbacks];
|
||||
}
|
||||
|
||||
async _getAuthHeader(action, hash, serverUrl) {
|
||||
|
||||
+230
-53
@@ -1,20 +1,24 @@
|
||||
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';
|
||||
import { DEFAULT_BLOSSOM_SERVER } from './blossom';
|
||||
|
||||
const DIRECTORY_RELAYS = [
|
||||
'wss://relay.primal.net',
|
||||
@@ -23,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();
|
||||
|
||||
@@ -40,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;
|
||||
@@ -108,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()
|
||||
@@ -173,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);
|
||||
|
||||
@@ -211,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);
|
||||
@@ -276,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);
|
||||
@@ -304,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) {
|
||||
@@ -362,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) {
|
||||
@@ -437,6 +604,16 @@ export default class NostrDataService extends Service {
|
||||
} else {
|
||||
this.blossomServers = [];
|
||||
}
|
||||
|
||||
if (this.blossomServers.length > 0) {
|
||||
const current = this.settings.nostrMediaServer;
|
||||
if (
|
||||
current === DEFAULT_BLOSSOM_SERVER ||
|
||||
!this.blossomServers.includes(current)
|
||||
) {
|
||||
this.settings.update('nostrMediaServer', this.blossomServers[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Await cache initialization and populate the EventStore with local data
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
import { DEFAULT_BLOSSOM_SERVER } from './blossom';
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
overpassApi: 'https://overpass-api.de/api/interpreter',
|
||||
mapKinetic: true,
|
||||
photonApi: 'https://photon.komoot.io/api/',
|
||||
showQuickSearchButtons: true,
|
||||
nostrPhotoFallbackUploads: false,
|
||||
nostrMediaServer: DEFAULT_BLOSSOM_SERVER,
|
||||
nostrReadRelays: null,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -21,11 +28,13 @@ export default class SettingsService extends Service {
|
||||
@tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons;
|
||||
@tracked nostrPhotoFallbackUploads =
|
||||
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
|
||||
@tracked nostrMediaServer = DEFAULT_SETTINGS.nostrMediaServer;
|
||||
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
|
||||
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
|
||||
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
|
||||
@tracked nostrWriteRelayExclusions =
|
||||
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
|
||||
@tracked nostrTrustedRelays = DEFAULT_SETTINGS.nostrTrustedRelays;
|
||||
@tracked experimentalEnablePhotoDeletion =
|
||||
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
|
||||
|
||||
@@ -114,10 +123,12 @@ export default class SettingsService extends Service {
|
||||
this.photonApi = finalSettings.photonApi;
|
||||
this.showQuickSearchButtons = finalSettings.showQuickSearchButtons;
|
||||
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
|
||||
this.nostrMediaServer = finalSettings.nostrMediaServer;
|
||||
this.nostrReadRelays = finalSettings.nostrReadRelays;
|
||||
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
|
||||
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
|
||||
this.nostrTrustedRelays = finalSettings.nostrTrustedRelays;
|
||||
this.experimentalEnablePhotoDeletion =
|
||||
finalSettings.experimentalEnablePhotoDeletion;
|
||||
|
||||
@@ -132,10 +143,12 @@ export default class SettingsService extends Service {
|
||||
photonApi: this.photonApi,
|
||||
showQuickSearchButtons: this.showQuickSearchButtons,
|
||||
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
|
||||
nostrMediaServer: this.nostrMediaServer,
|
||||
nostrReadRelays: this.nostrReadRelays,
|
||||
nostrWriteRelays: this.nostrWriteRelays,
|
||||
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
|
||||
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
|
||||
nostrTrustedRelays: this.nostrTrustedRelays,
|
||||
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
||||
};
|
||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||
|
||||
+111
-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;
|
||||
@@ -1093,6 +1196,11 @@ abbr[title] {
|
||||
}
|
||||
|
||||
.btn {
|
||||
/* TODO: remove this scoped rule in favor of a global box-sizing reset
|
||||
(e.g. `*, *::before, *::after { box-sizing: border-box; }`) at the top
|
||||
of this file. Needed so `width: 100%` .btn elements like <a class="btn-full">
|
||||
include padding within their width like <button> does. */
|
||||
box-sizing: border-box;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -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.29.0",
|
||||
"version": "1.30.1",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
:root{--body-text-color:#333;--primary-background-color:#fff;--hover-bg:#f8f9fa;--divider-color:#eee;--sidebar-width:350px;--link-color:#2a7fff;--link-color-visited:#6a4fbf;--marker-color-primary:#ea4335;--marker-color-dark:#b31412;--danger-color:var(--marker-color-primary);--danger-color-dark:var(--marker-color-dark);--default-list-color:#fc3}html,body{overscroll-behavior:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent;height:100%}button{-webkit-tap-highlight-color:transparent}body{color:var(--body-text-color);margin:0;font-family:Noto Sans,sans-serif;font-size:16px;font-weight:400}#root,#ember-testing{height:100%}.map-container{-webkit-tap-highlight-color:transparent;background:#f8f9fa;outline:none;position:absolute;inset:0}#rs-widget-container{z-index:4000;display:none;position:absolute;top:60px;right:10px}#rs-widget-container.visible{display:block}.rs-backdrop{z-index:3999;position:fixed;inset:0}.app-header{z-index:3000;pointer-events:none;grid-template-columns:1fr auto 1fr;grid-template-areas:"search chips user";align-items:center;gap:1rem;height:60px;padding:.5rem 1rem;display:grid;position:absolute;top:0;left:0;right:0}@media (width<=768px){.app-header{grid-template-columns:1fr auto;grid-template-areas:"search user""chips chips";row-gap:8px;height:auto;padding:.5rem .5rem 0}}.header-left,.header-right,.header-center{pointer-events:auto}.header-left{grid-area:search;justify-self:start;align-items:center;width:100%;display:flex}@media (width>768px){.header-left{min-width:300px;max-width:350px}}.header-right{grid-area:user;justify-self:end}.header-center{grid-area:chips;justify-content:center;min-width:0;display:flex}@media (width>768px){.header-center .category-chips-scroll{width:auto;max-width:100%}}@media (width<=768px){.header-center{justify-content:start;width:100%;overflow:hidden}.header-center.searching{display:none}}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:0;display:flex}.user-avatar-placeholder{background:#2a3743;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;width:40px;height:40px;display:flex}.user-avatar{object-fit:cover;border-radius:50%;flex-shrink:0;width:40px;height:40px;display:block}.photo-preview-img{max-width:100%;height:auto}.dropzone{text-align:center;cursor:pointer;aspect-ratio:4/3;background-color:#ffffff05;border:2px dashed #ccc;border-radius:8px;margin:1.5rem 0 1rem;transition:all .2s}.dropzone.is-dragging{background-color:#61afef0d;border-color:#61afef}.dropzone-label{cursor:pointer;color:#898989;flex-direction:column;justify-content:center;align-items:center;gap:1rem;width:100%;height:100%;display:flex}.dropzone-label p{margin:0}.file-input-hidden{display:none}.photo-upload-item{aspect-ratio:4/3;background:#1e262e;border-radius:6px;width:100%;margin-bottom:1.25rem;position:relative;overflow:hidden}.photo-upload-item img{object-fit:contain;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.photo-upload-item .overlay,.photo-upload-item .btn-remove-photo{z-index:2}.photo-upload-item .overlay{background:#0009;flex-direction:column;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.upload-status-text{color:#fff;text-shadow:0 1px 3px #000c;text-align:center;margin-top:1rem;padding:0 1rem;font-size:.9rem}.photo-upload-item .error-overlay{cursor:pointer;background:#e06c75cc;border:none;width:100%;margin:0;padding:0}.photo-upload-item .btn-remove-photo{cursor:pointer;color:#fff;background:#000000b3;border:none;border-radius:50%;justify-content:center;align-items:center;width:24px;height:24px;padding:0;display:flex;position:absolute;top:4px;right:4px}.photo-upload-item .btn-remove-photo:hover{background:var(--danger-color)}.spin-animation{animation:1s linear infinite spin}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.btn-publish{width:100%}.user-menu-container{position:relative}.user-menu-popover{z-index:3001;background:#fff;border-radius:8px;width:280px;margin-top:10px;padding:1rem;position:absolute;top:100%;right:0;box-shadow:0 4px 12px #00000026}.menu-backdrop{z-index:3000;position:fixed;inset:0}.account-list{margin:0;padding:0;list-style:none}.account-item{border-bottom:1px solid var(--divider-color);flex-direction:column;padding:1rem 0;display:flex}.account-item:last-child{border-bottom:none;padding-bottom:0}.account-item:first-child{padding-top:0}.account-header{justify-content:space-between;align-items:center;display:flex}.account-status{color:#898989;white-space:nowrap;text-overflow:ellipsis;margin-top:.35rem;margin-left:calc(18px + .75rem);font-size:.85rem;overflow:hidden}.account-status strong{font-weight:600}.account-item.disabled{opacity:.5;pointer-events:none}.account-info{align-items:center;gap:.75rem;display:flex}.btn-text{cursor:pointLine truncated
|
||||
@@ -1 +0,0 @@
|
||||
var t=Object.create,e=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,s=Object.prototype.hasOwnProperty,o=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports),a=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:"Module"}),r},l=(t,i,o,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(var l,c=r(i),u=0,h=c.length;u<h;u++)l=c[u],s.call(t,l)||l===o||e(t,l,{get:(t=>i[t]).bind(null,l),enumerable:!(a=n(i,l))||a.enumerable});return t},c=(n,r,o)=>(o=null!=n?t(i(n)):{},l(!r&&n&&n.__esModule&&s.call(n,"default")?o:e(o,"default",{value:n,enumerable:!0}),n)),u=(t=>"undefined"!=typeof require?require:"undefined"!=typeof Proxy?new Proxy(t,{get:(t,e)=>("undefined"!=typeof require?require:t)[e]}):t)(function(t){if("undefined"!=typeof require)return require.apply(this,arguments);throw Error('Calling `require` for "'+t+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();var h,d=Object.prototype,p=Symbol("undefined"),f=function(t){return t[t.ADD=0]="ADD",t[t.ONCE=1]="ONCE",t[t.REMOVE=2]="REMOVE",t}(f||{}),g=1,m=class{_descriptors;_mixins;_isInit;_lazyChains;_values;_revisions;source;proto;_parent;_listeners;_listenersVersion=1;_inheritedEnd=-1;_flattenedVersion=0;constructor(t){this._parent=void 0,this._descriptors=void 0,this._mixins=void 0,this._lazyChains=void 0,this._values=void 0,this._revisions=void 0,this._isInit=!1,this.source=t,this.proto=void 0===t.constructor?void 0:t.constructor.prototype,this._listeners=void 0}get parent(){let t=this._parent;if(void 0===t){let e=y(this.source);this._parent=t=null===e||e===d?null:_(e)}return t}setInitializing(){this._isInit=!0}unsetInitializing(){this._isInit=!1}isInitializing(){return this._isInit}isPrototypeMeta(t){return this.proto===this.source&&this.source===t}_getOrCreateOwnMap(t){return this[t]||(this[t]=Object.create(null))}_getOrCreateOwnSet(t){return this[t]||(this[t]=new Set)}_findInheritedMap(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r){let t=r.get(e);if(void 0!==t)return t}n=n.parent}}_hasInInheritedSet(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r&&r.has(e))return!0;n=n.parent}return!1}valueFor(t){let e=this._values;return void 0!==e?e[t]:void 0}setValueFor(t,e){this._getOrCreateOwnMap("_values")[t]=e}revisionFor(t){let e=this._revisions;return void 0!==e?e[t]:void 0}setRevisionFor(t,e){this._getOrCreateOwnMap("_revisions")[t]=e}writableLazyChainsFor(t){let e=this._getOrCreateOwnMap("_lazyChains"),n=e[t];return void 0===n&&(n=e[t]=[]),n}readableLazyChainsFor(t){let e=this._lazyChains;if(void 0!==e)return e[t]}addMixin(t){this._getOrCreateOwnSet("_mixins").add(t)}hasMixin(t){return this._hasInInheritedSet("_mixins",t)}forEachMixins(t){let e,n=this;for(;null!==n;){let r=n._mixins;void 0!==r&&(e=void 0===e?new Set:e,r.forEach(n=>{e.has(n)||(e.add(n),t(n))})),n=n.parent}}writeDescriptors(t,e){(this._descriptors||(this._descriptors=new Map)).set(t,e)}peekDescriptors(t){let e=this._findInheritedMap("_descriptors",t);return e===p?void 0:e}removeDescriptors(t){this.writeDescriptors(t,p)}forEachDescriptors(t){let e,n=this;for(;null!==n;){let r=n._descriptors;void 0!==r&&(e=void 0===e?new Set:e,r.forEach((n,r)=>{e.has(r)||(e.add(r),n!==p&&t(r,n))})),n=n.parent}}addToListeners(t,e,n,r,i){this.pushListener(t,e,n,r?f.ONCE:f.ADD,i)}removeFromListeners(t,e,n){this.pushListener(t,e,n,f.REMOVE)}pushListener(t,e,n,r,i=!1){let s=this.writableListeners(),o=x(s,t,e,n);if(-1!==o&&o<this._inheritedEnd&&(s.splice(o,1),this._inheritedEnd--,o=-1),-1===o)s.push({event:t,target:e,method:n,kind:r,sync:i});else{let t=s[o];r===f.REMOVE&&t.kind!==f.REMOVE?s.splice(o,1):(t.kind=r,t.sync=i)}}writableListeners(){return this._flattenedVersion!==g||this.source!==this.proto&&-1!==this._inheritedEnd||g++,-1===this._inheritedEnd&&(this._inheritedEnd=0,this._listeners=[]),this._listeners}flattenedListeners(){if(this._flattenedVersion<g){let t=this.parent;if(null!==t){let e=t.flattenedListeners();if(void 0!==e)if(void 0===this._listeners)this._listeners=e;else{let t=this._listeners;this._inheritedEnd>0&&(t.splice(0,this._inheritedEnd),this._inheritedEnd=0);for(Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
:root{--body-text-color:#333;--primary-background-color:#fff;--hover-bg:#f8f9fa;--divider-color:#eee;--sidebar-width:350px;--link-color:#2a7fff;--link-color-visited:#6a4fbf;--marker-color-primary:#ea4335;--marker-color-dark:#b31412;--danger-color:var(--marker-color-primary);--danger-color-dark:var(--marker-color-dark);--default-list-color:#fc3}html,body{overscroll-behavior:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent;height:100%}button{-webkit-tap-highlight-color:transparent}body{color:var(--body-text-color);margin:0;font-family:Noto Sans,sans-serif;font-size:16px;font-weight:400}#root,#ember-testing{height:100%}.map-container{-webkit-tap-highlight-color:transparent;background:#f8f9fa;outline:none;position:absolute;inset:0}#rs-widget-container{z-index:4000;display:none;position:absolute;top:60px;right:10px}#rs-widget-container.visible{display:block}.rs-backdrop{z-index:3999;position:fixed;inset:0}.app-header{z-index:3000;pointer-events:none;grid-template-columns:1fr auto 1fr;grid-template-areas:"search chips user";align-items:center;gap:1rem;height:60px;padding:.5rem 1rem;display:grid;position:absolute;top:0;left:0;right:0}@media (width<=768px){.app-header{grid-template-columns:1fr auto;grid-template-areas:"search user""chips chips";row-gap:8px;height:auto;padding:.5rem .5rem 0}}.header-left,.header-right,.header-center{pointer-events:auto}.header-left{grid-area:search;justify-self:start;align-items:center;width:100%;display:flex}@media (width>768px){.header-left{min-width:300px;max-width:350px}}.header-right{grid-area:user;justify-self:end}.header-center{grid-area:chips;justify-content:center;min-width:0;display:flex}@media (width>768px){.header-center .category-chips-scroll{width:auto;max-width:100%}}@media (width<=768px){.header-center{justify-content:start;width:100%;overflow:hidden}.header-center.searching{display:none}}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:0;display:flex}.user-avatar-placeholder{background:#2a3743;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;width:40px;height:40px;display:flex}.user-avatar{object-fit:cover;border-radius:50%;flex-shrink:0;width:40px;height:40px;display:block}.photo-preview-img{max-width:100%;height:auto}.dropzone{text-align:center;cursor:pointer;aspect-ratio:4/3;background-color:#ffffff05;border:2px dashed #ccc;border-radius:8px;margin:1.5rem 0 1rem;transition:all .2s}.dropzone.is-dragging{background-color:#61afef0d;border-color:#61afef}.dropzone-label{cursor:pointer;color:#898989;flex-direction:column;justify-content:center;align-items:center;gap:1rem;width:100%;height:100%;display:flex}.dropzone-label p{margin:0}.file-input-hidden{display:none}.photo-upload-item{aspect-ratio:4/3;background:#1e262e;border-radius:6px;width:100%;margin-bottom:1.25rem;position:relative;overflow:hidden}.photo-upload-item img{object-fit:contain;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.photo-upload-item .overlay,.photo-upload-item .btn-remove-photo{z-index:2}.photo-upload-item .overlay{background:#0009;flex-direction:column;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.upload-status-text{color:#fff;text-shadow:0 1px 3px #000c;text-align:center;margin-top:1rem;padding:0 1rem;font-size:.9rem}.photo-upload-item .error-overlay{cursor:pointer;background:#e06c75cc;border:none;width:100%;margin:0;padding:0}.photo-upload-item .btn-remove-photo{cursor:pointer;color:#fff;background:#000000b3;border:none;border-radius:50%;justify-content:center;align-items:center;width:24px;height:24px;padding:0;display:flex;position:absolute;top:4px;right:4px}.photo-upload-item .btn-remove-photo:hover{background:var(--danger-color)}.spin-animation{animation:1s linear infinite spin}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.btn-publish{width:100%}.user-menu-container{position:relative}.user-menu-popover{z-index:3001;background:#fff;border-radius:8px;width:280px;margin-top:10px;padding:1rem;position:absolute;top:100%;right:0;box-shadow:0 4px 12px #00000026}.menu-backdrop{z-index:3000;position:fixed;inset:0}.account-list{margin:0;padding:0;list-style:none}.account-item{border-bottom:1px solid var(--divider-color);flex-direction:column;padding:1rem 0;display:flex}.account-item:last-child{border-bottom:none;padding-bottom:0}.account-item:first-child{padding-top:0}.account-header{justify-content:space-between;align-items:center;display:flex}.account-status{color:#898989;white-space:nowrap;text-overflow:ellipsis;margin-top:.35rem;margin-left:calc(18px + .75rem);font-size:.85rem;overflow:hidden}.account-status strong{font-weight:600}.account-item.disabled{opacity:.5;pointer-events:none}.account-info{align-items:center;gap:.75rem;display:flex}.btn-text{cursor:pointLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
var t=Object.create,e=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,s=Object.prototype.hasOwnProperty,o=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports),a=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:"Module"}),r},l=(t,i,o,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(var l,c=r(i),u=0,h=c.length;u<h;u++)l=c[u],s.call(t,l)||l===o||e(t,l,{get:(t=>i[t]).bind(null,l),enumerable:!(a=n(i,l))||a.enumerable});return t},c=(n,r,o)=>(o=null!=n?t(i(n)):{},l(!r&&n&&n.__esModule&&s.call(n,"default")?o:e(o,"default",{value:n,enumerable:!0}),n)),u=(t=>"undefined"!=typeof require?require:"undefined"!=typeof Proxy?new Proxy(t,{get:(t,e)=>("undefined"!=typeof require?require:t)[e]}):t)(function(t){if("undefined"!=typeof require)return require.apply(this,arguments);throw Error('Calling `require` for "'+t+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();var h,d=Object.prototype,p=Symbol("undefined"),f=function(t){return t[t.ADD=0]="ADD",t[t.ONCE=1]="ONCE",t[t.REMOVE=2]="REMOVE",t}(f||{}),g=1,m=class{_descriptors;_mixins;_isInit;_lazyChains;_values;_revisions;source;proto;_parent;_listeners;_listenersVersion=1;_inheritedEnd=-1;_flattenedVersion=0;constructor(t){this._parent=void 0,this._descriptors=void 0,this._mixins=void 0,this._lazyChains=void 0,this._values=void 0,this._revisions=void 0,this._isInit=!1,this.source=t,this.proto=void 0===t.constructor?void 0:t.constructor.prototype,this._listeners=void 0}get parent(){let t=this._parent;if(void 0===t){let e=y(this.source);this._parent=t=null===e||e===d?null:_(e)}return t}setInitializing(){this._isInit=!0}unsetInitializing(){this._isInit=!1}isInitializing(){return this._isInit}isPrototypeMeta(t){return this.proto===this.source&&this.source===t}_getOrCreateOwnMap(t){return this[t]||(this[t]=Object.create(null))}_getOrCreateOwnSet(t){return this[t]||(this[t]=new Set)}_findInheritedMap(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r){let t=r.get(e);if(void 0!==t)return t}n=n.parent}}_hasInInheritedSet(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r&&r.has(e))return!0;n=n.parent}return!1}valueFor(t){let e=this._values;return void 0!==e?e[t]:void 0}setValueFor(t,e){this._getOrCreateOwnMap("_values")[t]=e}revisionFor(t){let e=this._revisions;return void 0!==e?e[t]:void 0}setRevisionFor(t,e){this._getOrCreateOwnMap("_revisions")[t]=e}writableLazyChainsFor(t){let e=this._getOrCreateOwnMap("_lazyChains"),n=e[t];return void 0===n&&(n=e[t]=[]),n}readableLazyChainsFor(t){let e=this._lazyChains;if(void 0!==e)return e[t]}addMixin(t){this._getOrCreateOwnSet("_mixins").add(t)}hasMixin(t){return this._hasInInheritedSet("_mixins",t)}forEachMixins(t){let e,n=this;for(;null!==n;){let r=n._mixins;void 0!==r&&(e=void 0===e?new Set:e,r.forEach(n=>{e.has(n)||(e.add(n),t(n))})),n=n.parent}}writeDescriptors(t,e){(this._descriptors||(this._descriptors=new Map)).set(t,e)}peekDescriptors(t){let e=this._findInheritedMap("_descriptors",t);return e===p?void 0:e}removeDescriptors(t){this.writeDescriptors(t,p)}forEachDescriptors(t){let e,n=this;for(;null!==n;){let r=n._descriptors;void 0!==r&&(e=void 0===e?new Set:e,r.forEach((n,r)=>{e.has(r)||(e.add(r),n!==p&&t(r,n))})),n=n.parent}}addToListeners(t,e,n,r,i){this.pushListener(t,e,n,r?f.ONCE:f.ADD,i)}removeFromListeners(t,e,n){this.pushListener(t,e,n,f.REMOVE)}pushListener(t,e,n,r,i=!1){let s=this.writableListeners(),o=x(s,t,e,n);if(-1!==o&&o<this._inheritedEnd&&(s.splice(o,1),this._inheritedEnd--,o=-1),-1===o)s.push({event:t,target:e,method:n,kind:r,sync:i});else{let t=s[o];r===f.REMOVE&&t.kind!==f.REMOVE?s.splice(o,1):(t.kind=r,t.sync=i)}}writableListeners(){return this._flattenedVersion!==g||this.source!==this.proto&&-1!==this._inheritedEnd||g++,-1===this._inheritedEnd&&(this._inheritedEnd=0,this._listeners=[]),this._listeners}flattenedListeners(){if(this._flattenedVersion<g){let t=this.parent;if(null!==t){let e=t.flattenedListeners();if(void 0!==e)if(void 0===this._listeners)this._listeners=e;else{let t=this._listeners;this._inheritedEnd>0&&(t.splice(0,this._inheritedEnd),this._inheritedEnd=0);for(Line truncated
|
||||
@@ -1 +1 @@
|
||||
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-Cx0KJENk.js";var Ka=Aa({default:()=>Ga}),Qa=Aa({default:()=>tn}),Xa={willChange:"_arrangedContentArrayWillChange",didChange:"_arrangedContentArrayDidChange"};function en(e,t){return"[]"===t?(e._revalidate(),e._arrTag):"length"===t?(e._revalidate(),e._lengthTag):B(e,t)}var tn=class extends Y{_objectsDirtyIndex=0;_objects=null;_lengthDirty=!0;_length=0;_arrangedContent=null;_arrangedContentIsUpdating=!1;_arrangedContentTag=null;_arrangedContentRevision=null;_lengthTag=null;_arrTag=null;init(e){super.init(e),W(this,en)}[b](){this._revalidate()}willDestroy(){this._removeArrangedContentArrayObserver()}objectAtContent(e){let t=be(this,"arrangedContent");return le(t,e)}replace(e,t,a){this.replaceContent(e,t,a)}replaceContent(e,t,a){let n=be(this,"content");$a(n,e,t,a)}objectAt(e){if(this._revalidate(),null===this._objects&&(this._objects=[]),-1!==this._objectsDirtyIndex&&e>=this._objectsDirtyIndex){let e=be(this,"arrangedContent");if(e){let t=this._objects.length=be(e,"length");for(let e=this._objectsDirtyIndex;e<t;e++)this._objects[e]=this.objectAtContent(e)}else this._objects.length=0;this._objectsDirtyIndex=-1}return this._objects[e]}get length(){if(this._revalidate(),this._lengthDirty){let e=be(this,"arrangedContent");this._length=e?be(e,"length"):0,this._lengthDirty=!1}return L(this._lengthTag),this._length}set length(e){let t,a=this.length-e;if(0===a)return;a<0&&(t=new Array(-a),a=0);let n=be(this,"content");n&&($a(n,e,a,t),this._invalidate())}_updateArrangedContentArray(e){let t=null===this._objects?0:this._objects.length,a=e?be(e,"length"):0;this._removeArrangedContentArrayObserver(),Je(this,0,t,a),this._invalidate(),Dt(this,0,t,a,!1),this._addArrangedContentArrayObserver(e)}_addArrangedContentArrayObserver(e){e&&!e.isDestroyed&&(Et(e,this,Xa),this._arrangedContent=e)}_removeArrangedContentArrayObserver(){this._arrangedContent&&Va(this._arrangedContent,this,Xa)}_arrangedContentArrayWillChange(){}_arrangedContentArrayDidChange(e,t,a,n){Je(this,t,a,n);let s=t;s<0&&(s+=be(this._arrangedContent,"length")+a-n),(-1===this._objectsDirtyIndex||this._objectsDirtyIndex>s)&&(this._objectsDirtyIndex=s),this._lengthDirty=!0,Dt(this,t,a,n,!1)}_invalidate(){this._objectsDirtyIndex=0,this._lengthDirty=!0}_revalidate(){if(!0!==this._arrangedContentIsUpdating&&(null===this._arrangedContentTag||!Ye(this._arrangedContentTag,this._arrangedContentRevision))){let e=this.get("arrangedContent");null===this._arrangedContentTag?this._addArrangedContentArrayObserver(e):(this._arrangedContentIsUpdating=!0,this._updateArrangedContentArray(e),this._arrangedContentIsUpdating=!1);let t=this._arrangedContentTag=B(this,"arrangedContent");this._arrangedContentRevision=Ge(this._arrangedContentTag),O(e)?(this._lengthTag=ua([t,I(e,"length")]),this._arrTag=ua([t,I(e,"[]")])):this._lengthTag=this._arrTag=t}}};tn.reopen(Ga,{arrangedContent:st("content")});var an=Aa({Meta:()=>va,UNDEFINED:()=>Qt,counters:()=>ma,meta:()=>jt,peekMeta:()=>Xe,setMeta:()=>na}),nn=Aa({ActionHandler:()=>Fe,Comparable:()=>C,ContainerProxyMixin:()=>Ua,MutableEnumerable:()=>_,RSVP:()=>wt,RegistryProxyMixin:()=>nt,TargetActionSupport:()=>Pa,_ProxyMixin:()=>Sa,_contentFor:()=>taLine truncated
|
||||
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-DeiUGvCJ.js";var Ka=Aa({default:()=>Ga}),Qa=Aa({default:()=>tn}),Xa={willChange:"_arrangedContentArrayWillChange",didChange:"_arrangedContentArrayDidChange"};function en(e,t){return"[]"===t?(e._revalidate(),e._arrTag):"length"===t?(e._revalidate(),e._lengthTag):B(e,t)}var tn=class extends Y{_objectsDirtyIndex=0;_objects=null;_lengthDirty=!0;_length=0;_arrangedContent=null;_arrangedContentIsUpdating=!1;_arrangedContentTag=null;_arrangedContentRevision=null;_lengthTag=null;_arrTag=null;init(e){super.init(e),W(this,en)}[b](){this._revalidate()}willDestroy(){this._removeArrangedContentArrayObserver()}objectAtContent(e){let t=be(this,"arrangedContent");return le(t,e)}replace(e,t,a){this.replaceContent(e,t,a)}replaceContent(e,t,a){let n=be(this,"content");$a(n,e,t,a)}objectAt(e){if(this._revalidate(),null===this._objects&&(this._objects=[]),-1!==this._objectsDirtyIndex&&e>=this._objectsDirtyIndex){let e=be(this,"arrangedContent");if(e){let t=this._objects.length=be(e,"length");for(let e=this._objectsDirtyIndex;e<t;e++)this._objects[e]=this.objectAtContent(e)}else this._objects.length=0;this._objectsDirtyIndex=-1}return this._objects[e]}get length(){if(this._revalidate(),this._lengthDirty){let e=be(this,"arrangedContent");this._length=e?be(e,"length"):0,this._lengthDirty=!1}return L(this._lengthTag),this._length}set length(e){let t,a=this.length-e;if(0===a)return;a<0&&(t=new Array(-a),a=0);let n=be(this,"content");n&&($a(n,e,a,t),this._invalidate())}_updateArrangedContentArray(e){let t=null===this._objects?0:this._objects.length,a=e?be(e,"length"):0;this._removeArrangedContentArrayObserver(),Je(this,0,t,a),this._invalidate(),Dt(this,0,t,a,!1),this._addArrangedContentArrayObserver(e)}_addArrangedContentArrayObserver(e){e&&!e.isDestroyed&&(Et(e,this,Xa),this._arrangedContent=e)}_removeArrangedContentArrayObserver(){this._arrangedContent&&Va(this._arrangedContent,this,Xa)}_arrangedContentArrayWillChange(){}_arrangedContentArrayDidChange(e,t,a,n){Je(this,t,a,n);let s=t;s<0&&(s+=be(this._arrangedContent,"length")+a-n),(-1===this._objectsDirtyIndex||this._objectsDirtyIndex>s)&&(this._objectsDirtyIndex=s),this._lengthDirty=!0,Dt(this,t,a,n,!1)}_invalidate(){this._objectsDirtyIndex=0,this._lengthDirty=!0}_revalidate(){if(!0!==this._arrangedContentIsUpdating&&(null===this._arrangedContentTag||!Ye(this._arrangedContentTag,this._arrangedContentRevision))){let e=this.get("arrangedContent");null===this._arrangedContentTag?this._addArrangedContentArrayObserver(e):(this._arrangedContentIsUpdating=!0,this._updateArrangedContentArray(e),this._arrangedContentIsUpdating=!1);let t=this._arrangedContentTag=B(this,"arrangedContent");this._arrangedContentRevision=Ge(this._arrangedContentTag),O(e)?(this._lengthTag=ua([t,I(e,"length")]),this._arrTag=ua([t,I(e,"[]")])):this._lengthTag=this._arrTag=t}}};tn.reopen(Ga,{arrangedContent:st("content")});var an=Aa({Meta:()=>va,UNDEFINED:()=>Qt,counters:()=>ma,meta:()=>jt,peekMeta:()=>Xe,setMeta:()=>na}),nn=Aa({ActionHandler:()=>Fe,Comparable:()=>C,ContainerProxyMixin:()=>Ua,MutableEnumerable:()=>_,RSVP:()=>wt,RegistryProxyMixin:()=>nt,TargetActionSupport:()=>Pa,_ProxyMixin:()=>Sa,_contentFor:()=>taLine truncated
|
||||
@@ -1 +1 @@
|
||||
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-Cx0KJENk.js";function l(){for(var t=[],r=0;r<arguments.length;r++)t[r]=arguments[r];return 1===(t=n(t)).length?s(t[0]):new e(function(t){return function(e){for(var r=[],n=function(n){r.push(s(t[n]).subscribe(i(e,function(t){if(r){for(var i=0;i<r.length;i++)i!==n&&r[i].unsubscribe();r=null}e.next(t)})))},a=0;r&&!e.closed&&a<t.length;a++)n(a)}}(t))}var d=32,c=class t{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(e){if(e instanceof t&&(e=e.unwrap()),"number"!=typeof e.length)throw Error("bad length");const i=e.length+this.length;if(this.capacity<i){const t=this._raw,e=Math.max(2*this.capacity,i);this._raw=new Uint8Array(e),this._raw.set(t)}this._raw.set(e,this.length),this.length+=e.length}shift(){const t=this._raw[0];return this._raw=this._raw.subarray(1),this.length--,t}shiftN(t=1){const e=this._raw.subarray(0,t);return this._raw=this._raw.subarray(t),this.length-=t,e}};function f(t){let e=0;for(;;){if(0===t.length)throw Error("parse ends prematurely");let i=t.shift();if(e=e<<7|127&i,!(128&i))break}return e}function m(t){if(0===t)return new c([0]);let e=[];for(;0!==t;)e.push(127&t),t>>>=7;e.reverse();for(let i=0;i<e.length-1;i++)e[i]|=128;return new c(e)}function g(t,e){if(t.length<e)throw Error("parse ends prematurely");return t.shiftN(e)}var p=class{buf;sha256;constructor(){this.buf=new Uint8Array(d),this.sha256=async t=>u.create().update(t).digest()}setToZero(){this.buf=new Uint8Array(d)}add(t){let e=0,i=0,r=new DataView(this.buf.buffer),n=new DataView(t.buffer);for(let s=0;s<8;s++){let t=4*s,a=r.getUint32(t,!0);a+=e,a+=n.getUint32(t,!0),a>4294967295&&(i=1),r.setUint32(t,4294967295&a,!0),e=i,i=0}}negate(){let t=new DataView(this.buf.buffer);for(let i=0;i<8;i++){let e=4*i;t.setUint32(e,~t.getUint32(e,!0))}let e=new Uint8Array(d);e[0]=1,this.add(e)}async getFingerprint(t){let e=new c;return e.extend(this.buf),e.extend(m(t)),(await this.sha256(e.unwrap())).subarray(0,16)}},w=class{items;sealed;constructor(){this.items=[],this.sealed=!1}insert(t,e){if(this.sealed)throw Error("already sealed");if((e=y(e)).byteLength!==d)throw Error("bad id size for added item");this.items.push({timestamp:t,id:e})}seal(){if(this.sealed)throw Error("already sealed");this.sealed=!0,this.items.sort(A);for(let t=1;t<this.items.length;t++)if(0===A(this.items[t-1],this.items[t]))throw Error("duplicate item inserted")}unseal(){this.sealed=!1}size(){return this._checkSealed(),this.items.length}getItem(t){if(this._checkSealed(),t>=this.items.length)throw Error("out of range");return this.items[t]}iterate(t,e,i){this._checkSealed(),this._checkBounds(t,e);for(let r=t;r<e&&i(this.items[r],r);++r);}findLowerBound(t,e,i){return this._checkSealed(),this._checkBounds(t,e),this._binarySearch(this.items,t,e,t=>A(t,i)<0)}async fingerprint(t,e){let i=new p;return i.setToZero(),this.iterate(t,e,t=>(i.add(t.id),!0)),await i.getFingerprint(e-t)}_checkSealed(){if(!this.sealed)throw Error("not sealed")}_checkBounds(t,e){if(t>e||e>this.items.length)throw Error("bad range")}_binarySearch(t,e,i,r){let n=i-e;for(;n>0;){let i=e,s=Math.floor(n/2);i+=s,r(t[i])?(e=++i,n-=s+1):n=s}return e}},b=class{storage;frameSizeLimit;lastTimestampIn;lastTimestampOut;isInitiator;wantUint8ArrayOutput;constructor(t,e=0){if(0!==e&&e<4096)throw Error("frameSizeLimit too small");this.storage=t,this.frameSizeLimit=e,this.lastTimestampIn=0,this.lastTimestampOut=0}_bound(t,e){return{timestamp:t,id:e||new Uint8Array(0)}}async initiate(){if(this.isInitiator)throw Error("already initiated");this.isInitiator=!0;let t=new c;return t.extend([97]),await this.splitRange(0,this.storage.size(),this._bound(Number.MAX_VALUE),t),this._renderOutput(t)}setInitiator(){this.isInitiator=!0}async reconcile(t){let e=[],i=[],r=new c(y(t));this.lastTimestampIn=this.lastTimestampOut=0;let n=new c;n.extend([97]);let s=g(r,1)[0];if(s<96||s>111)throw Error("invalid negentropy protocol version byte");if(97!==s){if(this.isInitiator)throw Error("unsupported negentropy protocol version requested: "+(s-96));return[this._renderOutput(n),e,i]}let a=this.storage.size(),o=this._bound(0),h=0,u=!1;for(;0!==r.length;){let t=new c,s=()=>{u&&(u=!1,t.extend(this.encodeBound(o)),t.extend(m(0)))},l=this.decodeBound(r),p=f(r),w=h,b=this.storage.findLowerBound(h,a,l);if(0===p)u=!0;else if(1===p)0!==E(g(r,16),await this.storage.fingerprint(w,b))?(s(),await this.splitRange(w,b,l,t)):u=!0;else{if(2!==p)throw Error("unexpected mode");{let a=f(r),o={};for(let t=0;t<a;t++){let t=g(r,d);this.isInitiator&&(o[t]=t)}if(this.isInitiator){u=!0,this.storage.iterate(w,b,t=>{let i=t.id;return o[i]?delete o[i]:this.isInitiator&&e.push(this.wantUint8ArrayOutput?i:x(i)),!0});for(let t of Object.values(o))i.push(this.wantUint8ArrayOutput?t:x(t))}else{s();let e=new c,i=0,r=l;this.storage.iterate(w,b,(t,s)=>this.exceededFLine truncated
|
||||
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-DeiUGvCJ.js";function l(){for(var t=[],r=0;r<arguments.length;r++)t[r]=arguments[r];return 1===(t=n(t)).length?s(t[0]):new e(function(t){return function(e){for(var r=[],n=function(n){r.push(s(t[n]).subscribe(i(e,function(t){if(r){for(var i=0;i<r.length;i++)i!==n&&r[i].unsubscribe();r=null}e.next(t)})))},a=0;r&&!e.closed&&a<t.length;a++)n(a)}}(t))}var d=32,c=class t{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(e){if(e instanceof t&&(e=e.unwrap()),"number"!=typeof e.length)throw Error("bad length");const i=e.length+this.length;if(this.capacity<i){const t=this._raw,e=Math.max(2*this.capacity,i);this._raw=new Uint8Array(e),this._raw.set(t)}this._raw.set(e,this.length),this.length+=e.length}shift(){const t=this._raw[0];return this._raw=this._raw.subarray(1),this.length--,t}shiftN(t=1){const e=this._raw.subarray(0,t);return this._raw=this._raw.subarray(t),this.length-=t,e}};function f(t){let e=0;for(;;){if(0===t.length)throw Error("parse ends prematurely");let i=t.shift();if(e=e<<7|127&i,!(128&i))break}return e}function m(t){if(0===t)return new c([0]);let e=[];for(;0!==t;)e.push(127&t),t>>>=7;e.reverse();for(let i=0;i<e.length-1;i++)e[i]|=128;return new c(e)}function g(t,e){if(t.length<e)throw Error("parse ends prematurely");return t.shiftN(e)}var p=class{buf;sha256;constructor(){this.buf=new Uint8Array(d),this.sha256=async t=>u.create().update(t).digest()}setToZero(){this.buf=new Uint8Array(d)}add(t){let e=0,i=0,r=new DataView(this.buf.buffer),n=new DataView(t.buffer);for(let s=0;s<8;s++){let t=4*s,a=r.getUint32(t,!0);a+=e,a+=n.getUint32(t,!0),a>4294967295&&(i=1),r.setUint32(t,4294967295&a,!0),e=i,i=0}}negate(){let t=new DataView(this.buf.buffer);for(let i=0;i<8;i++){let e=4*i;t.setUint32(e,~t.getUint32(e,!0))}let e=new Uint8Array(d);e[0]=1,this.add(e)}async getFingerprint(t){let e=new c;return e.extend(this.buf),e.extend(m(t)),(await this.sha256(e.unwrap())).subarray(0,16)}},w=class{items;sealed;constructor(){this.items=[],this.sealed=!1}insert(t,e){if(this.sealed)throw Error("already sealed");if((e=y(e)).byteLength!==d)throw Error("bad id size for added item");this.items.push({timestamp:t,id:e})}seal(){if(this.sealed)throw Error("already sealed");this.sealed=!0,this.items.sort(A);for(let t=1;t<this.items.length;t++)if(0===A(this.items[t-1],this.items[t]))throw Error("duplicate item inserted")}unseal(){this.sealed=!1}size(){return this._checkSealed(),this.items.length}getItem(t){if(this._checkSealed(),t>=this.items.length)throw Error("out of range");return this.items[t]}iterate(t,e,i){this._checkSealed(),this._checkBounds(t,e);for(let r=t;r<e&&i(this.items[r],r);++r);}findLowerBound(t,e,i){return this._checkSealed(),this._checkBounds(t,e),this._binarySearch(this.items,t,e,t=>A(t,i)<0)}async fingerprint(t,e){let i=new p;return i.setToZero(),this.iterate(t,e,t=>(i.add(t.id),!0)),await i.getFingerprint(e-t)}_checkSealed(){if(!this.sealed)throw Error("not sealed")}_checkBounds(t,e){if(t>e||e>this.items.length)throw Error("bad range")}_binarySearch(t,e,i,r){let n=i-e;for(;n>0;){let i=e,s=Math.floor(n/2);i+=s,r(t[i])?(e=++i,n-=s+1):n=s}return e}},b=class{storage;frameSizeLimit;lastTimestampIn;lastTimestampOut;isInitiator;wantUint8ArrayOutput;constructor(t,e=0){if(0!==e&&e<4096)throw Error("frameSizeLimit too small");this.storage=t,this.frameSizeLimit=e,this.lastTimestampIn=0,this.lastTimestampOut=0}_bound(t,e){return{timestamp:t,id:e||new Uint8Array(0)}}async initiate(){if(this.isInitiator)throw Error("already initiated");this.isInitiator=!0;let t=new c;return t.extend([97]),await this.splitRange(0,this.storage.size(),this._bound(Number.MAX_VALUE),t),this._renderOutput(t)}setInitiator(){this.isInitiator=!0}async reconcile(t){let e=[],i=[],r=new c(y(t));this.lastTimestampIn=this.lastTimestampOut=0;let n=new c;n.extend([97]);let s=g(r,1)[0];if(s<96||s>111)throw Error("invalid negentropy protocol version byte");if(97!==s){if(this.isInitiator)throw Error("unsupported negentropy protocol version requested: "+(s-96));return[this._renderOutput(n),e,i]}let a=this.storage.size(),o=this._bound(0),h=0,u=!1;for(;0!==r.length;){let t=new c,s=()=>{u&&(u=!1,t.extend(this.encodeBound(o)),t.extend(m(0)))},l=this.decodeBound(r),p=f(r),w=h,b=this.storage.findLowerBound(h,a,l);if(0===p)u=!0;else if(1===p)0!==E(g(r,16),await this.storage.fingerprint(w,b))?(s(),await this.splitRange(w,b,l,t)):u=!0;else{if(2!==p)throw Error("unexpected mode");{let a=f(r),o={};for(let t=0;t<a;t++){let t=g(r,d);this.isInitiator&&(o[t]=t)}if(this.isInitiator){u=!0,this.storage.iterate(w,b,t=>{let i=t.id;return o[i]?delete o[i]:this.isInitiator&&e.push(this.wantUint8ArrayOutput?i:x(i)),!0});for(let t of Object.values(o))i.push(this.wantUint8ArrayOutput?t:x(t))}else{s();let e=new c,i=0,r=l;this.storage.iterate(w,b,(t,s)=>this.exceededFLine truncated
|
||||
+2
-2
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-Cx0KJENk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CHNrhL7t.css">
|
||||
<script type="module" crossorigin src="/assets/main-DeiUGvCJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DWxZH-9E.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
@@ -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 {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mergeRequiredRelays,
|
||||
uniqNormalizedRelays,
|
||||
} from 'marco/utils/nostr';
|
||||
import { DEFAULT_BLOSSOM_SERVER } from 'marco/services/blossom';
|
||||
|
||||
class MockNostrDataService extends Service {
|
||||
@service settings;
|
||||
@@ -18,6 +19,8 @@ class MockNostrDataService extends Service {
|
||||
mailboxReadRelays = ['wss://mailbox.example.com'];
|
||||
mailboxWriteRelays = ['wss://mailbox-write.example.com'];
|
||||
|
||||
blossomServers = [];
|
||||
|
||||
get configuredReadRelays() {
|
||||
const configured = uniqNormalizedRelays([
|
||||
...this.mailboxReadRelays,
|
||||
@@ -56,6 +59,11 @@ class MockNostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
get trustedRelays() {
|
||||
const configured = this.settings.nostrTrustedRelays || [];
|
||||
return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]);
|
||||
}
|
||||
|
||||
async clearCache() {}
|
||||
}
|
||||
|
||||
@@ -105,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) {
|
||||
@@ -185,4 +202,126 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
'wss://mailbox-write.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('media server dropdown shows only the default server when no blossom servers discovered', async function (assert) {
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const select = element.querySelector('#nostr-media-server');
|
||||
|
||||
assert.dom(select).exists();
|
||||
assert.strictEqual(select.options.length, 1);
|
||||
assert.dom(select.options[0]).hasValue(DEFAULT_BLOSSOM_SERVER);
|
||||
assert.dom(select.options[0]).hasText(DEFAULT_BLOSSOM_SERVER);
|
||||
assert.strictEqual(select.value, DEFAULT_BLOSSOM_SERVER);
|
||||
assert
|
||||
.dom('#nostr-photo-fallback-uploads')
|
||||
.doesNotExist('fallback toggle hidden with single media server');
|
||||
});
|
||||
|
||||
test('media server dropdown lists discovered blossom servers', async function (assert) {
|
||||
const servers = ['https://cdn.example.com', 'https://cdn.satellite.earth'];
|
||||
this.owner.lookup('service:nostrData').blossomServers = servers;
|
||||
|
||||
const element = await renderAndOpenDetails(this);
|
||||
const select = element.querySelector('#nostr-media-server');
|
||||
|
||||
assert.strictEqual(select.options.length, servers.length);
|
||||
assert.dom(select.options[0]).hasValue(servers[0]);
|
||||
assert.dom(select.options[1]).hasValue(servers[1]);
|
||||
assert
|
||||
.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