Compare commits

...
33 Commits
Author SHA1 Message Date
raucao da752f7807 1.32.0
CI / Lint (push) Successful in 1m3s
CI / Test (push) Failing after 1m20s
2026-08-29 15:57:05 -06:00
raucao 76867db1dd Merge pull request 'Add Activity timeline, show zaps received' (#91) from feature/89-zaps_received into master
CI / Lint (push) Successful in 1m2s
CI / Test (push) Canceled after 1m18s
Reviewed-on: #91
2026-08-29 21:55:53 +00:00
raucao 23444a49a8 Omit year for current year, tweak layout
CI / Lint (pull_request) Successful in 1m2s
CI / Test (pull_request) Successful in 1m17s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-08-29 15:50:49 -06:00
raucao 919c0335eb Warm up the OSM cache from batch fetches 2026-08-29 15:13:57 -06:00
raucao d0252309c4 Fix delay when opening places without RS connected 2026-08-29 15:13:46 -06:00
raucao c7fc98d4bd Add place names to Activity timeline
Create a new place name resolver service with the logic previously used
for My Contributions only
2026-08-29 14:34:56 -06:00
raucao 3f6bd5267f WIP Add activity timeline route/sidebar showing incoming zaps 2026-08-29 12:01:48 -06:00
raucao fd1b570717 1.31.1
CI / Lint (push) Successful in 1m10s
CI / Test (push) Successful in 1m27s
2026-08-28 12:15:19 -06:00
raucao 6d68a6240b Add fediverse URL for verified link 2026-08-28 12:14:15 -06:00
raucao 0ebe565cbc 1.31.0
CI / Lint (push) Successful in 1m20s
CI / Test (push) Successful in 1m38s
2026-08-27 11:18:18 -06:00
raucao e7020469c3 Merge pull request 'Cover all important aspects of nostr-data service with tests' (#88) from dev/nostr-data-tests into master
CI / Lint (push) Successful in 1m19s
CI / Test (push) Successful in 1m35s
Reviewed-on: #88
2026-08-27 17:15:53 +00:00
raucao bf3011115b Reorganize nostr-data tests into focused modules
CI / Lint (pull_request) Successful in 1m17s
CI / Test (pull_request) Successful in 1m35s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
Split the monolithic test file into 8 single-responsibility modules:
contacts, relay configuration, provenance, trust evaluation, geohash
loading, place photos, my contributions, and zap receipts. Extract
shared setup helper to eliminate duplication.
2026-08-27 11:12:10 -06:00
raucao 8c98e0757b Add nostr-data loading and presentation tests
Cover loadPlacesInBounds (geohash prefix dedup, cache hydration,
network filters), loadPhotosForPlace (entity switching, subscription
teardown, reset semantics), and trust presentation (default hiding of
untrusted photos, reveal toggle, own-content bypass).
2026-08-27 10:47:10 -06:00
raucao 2ae9a78f5d Fix kind 5 deletion provenance cleanup, add nostr-data tests
Listen to the store's deletes stream instead of a timeline (kind 5
events never enter the event database), and unsubscribe the
subscription on destroy. Add unit tests for relay getters,
provenance handling, and trust partitioning.
2026-08-27 10:32:35 -06:00
raucao 252f41f897 Merge pull request 'Trust content from followed pubkeys' (#86) from feature/trust_followee_content into master
CI / Lint (push) Successful in 1m17s
CI / Test (push) Successful in 1m34s
Reviewed-on: #86
2026-08-27 15:52:20 +00:00
raucao 572213eda2 Trust content from followed pubkeys
CI / Lint (pull_request) Successful in 1m18s
CI / Test (pull_request) Successful in 1m35s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
Fetch kind 3 and trust content from any contained pubkeys on all read
relays
2026-08-27 09:48:58 -06:00
raucao ac7a4eb262 Fetch kind 3 follow list for connected user 2026-08-27 08:40:21 -06:00
raucao 7d19068777 Merge pull request 'Only trust content from trusted relays by default' (#85) from feature/relay_trust into master
CI / Lint (push) Successful in 1m16s
CI / Test (push) Successful in 1m31s
Reviewed-on: #85
2026-08-27 14:05:11 +00:00
raucao 17dd721986 Introduce tooltip component
CI / Lint (pull_request) Successful in 1m18s
CI / Test (pull_request) Successful in 1m32s
Release Drafter / Update release notes draft (pull_request) Successful in 6s
Tooltips need to be more reliable and controlled than simple title
attributes.
2026-08-27 07:47:14 -06:00
raucao 474bc8cd63 Add relay trust settings, provenance cache
By default, only automatically show photos for relays marked as trusted
(which by default is the required read relay). For other relays, only
show the user's own events by default, but allow showing all events via
link in place details (drawer bottom)
2026-08-26 18:06:06 -06:00
raucao 3cc5e8834e 1.30.1
CI / Lint (push) Successful in 1m2s
CI / Test (push) Successful in 1m14s
2026-08-23 11:48:43 -06:00
raucao 45c18100d3 Tune tag search/results
CI / Lint (push) Successful in 1m1s
CI / Test (push) Successful in 1m15s
2026-08-23 11:37:52 -06:00
raucao 17db6a5f65 Merge pull request 'Add more place icons' (#84) from ui/place_icons into master
CI / Lint (push) Canceled after 9s
CI / Test (push) Canceled after 8s
Reviewed-on: #84
2026-08-23 17:37:45 +00:00
raucao 7f9ee29136 Add more place icons
CI / Lint (pull_request) Successful in 1m0s
CI / Test (pull_request) Successful in 1m14s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
Add 12 new icon/POI mappings, 10 of which are new icons/imports
2026-08-23 11:35:24 -06:00
raucao 81bbc03f54 Merge pull request 'Improve photo tags' (#83) from feature/photo_tags into master
CI / Lint (push) Successful in 1m0s
CI / Test (push) Successful in 1m14s
Reviewed-on: #83
2026-08-23 16:23:52 +00:00
raucao bf1fcbcb40 Improve photo tags
CI / Lint (pull_request) Successful in 1m1s
CI / Test (pull_request) Successful in 1m15s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-08-23 10:21:40 -06:00
raucao 5d9c6ebc89 Prioritize craft before amenity for place type
CI / Lint (push) Successful in 1m0s
CI / Test (push) Successful in 1m13s
It's usually more distinct and useful/interesting
2026-08-23 10:08:48 -06:00
raucao 2690c5470f Remove nak skill 2026-08-23 09:45:17 -06:00
raucao 46bb2efc70 1.30.0
CI / Lint (push) Successful in 40s
CI / Test (push) Successful in 54s
2026-08-21 11:35:31 -06:00
raucao 6feb776247 Merge pull request 'Add media server selection to Nostr settings' (#82) from feature/blossom_settings into master
CI / Lint (push) Successful in 1m1s
CI / Test (push) Successful in 1m14s
Reviewed-on: #82
2026-08-21 17:34:18 +00:00
raucao 2746ec035d Add media server selection to Nostr settings
CI / Lint (pull_request) Successful in 1m3s
CI / Test (pull_request) Successful in 1m16s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
Also, hide the fallback option when there's no server to fall back to
2026-08-21 11:27:03 -06:00
raucao cdeb4f5d8c 1.29.1
CI / Lint (push) Successful in 1m0s
CI / Test (push) Successful in 1m14s
2026-08-20 17:04:53 -06:00
raucao 8494f5a084 Fix button width 2026-08-20 17:04:04 -06:00
55 changed files with 4285 additions and 868 deletions
-359
View File
@@ -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)
```
+85
View File
@@ -0,0 +1,85 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import Icon from './icon';
import ActivityZapItem from './activity-zap-item';
import Modal from './modal';
import NostrConnect from './nostr-connect';
import not from 'ember-truth-helpers/helpers/not';
import eq from 'ember-truth-helpers/helpers/eq';
import restoreScroll from '../modifiers/restore-scroll';
export default class ActivityTimelineComponent extends Component {
@tracked isNostrConnectModalOpen = false;
@action
openNostrConnectModal(event) {
event.preventDefault();
this.isNostrConnectModalOpen = true;
}
@action
closeNostrConnectModal() {
this.isNostrConnectModalOpen = false;
}
@action
onNostrConnected() {
this.closeNostrConnectModal();
this.args.onNostrConnected?.();
}
<template>
<div class="sidebar">
<div class="sidebar-header has-back-btn">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2 class="sidebar-header-text-centered">
<span class="sidebar-header-icon-wrapper">
<Icon @name="activity" @size={{20}} @color="#898989" />
</span>
Activity
</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
{{#if @isLoading}}
<div class="sidebar-loading">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{else if (not @isConnected)}}
<p class="empty-state">
<a
href="#"
role="button"
tabindex="0"
{{on "click" this.openNostrConnectModal}}
>Connect your Nostr account</a>
to see your activity.
</p>
{{else if (not @items.length)}}
<p class="empty-state">No activity yet.</p>
{{else}}
<ul class="activity-list">
{{#each @items as |item|}}
{{#if (eq item.type "zap")}}
<ActivityZapItem @item={{item}} @onSelect={{@onSelect}} />
{{/if}}
{{/each}}
</ul>
{{/if}}
</div>
</div>
{{#if this.isNostrConnectModalOpen}}
<Modal @onClose={{this.closeNostrConnectModal}}>
<NostrConnect @onConnect={{this.onNostrConnected}} />
</Modal>
{{/if}}
</template>
}
+105
View File
@@ -0,0 +1,105 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import formatRelativeDate from '../helpers/format-relative-date';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import Icon from './icon';
export default class ActivityZapItem extends Component {
get item() {
return this.args.item;
}
get senderName() {
return this.item?.senderName;
}
get senderDisplayName() {
const name = this.senderName;
if (name) return name;
const pubkey = this.item?.senderPubkey;
if (!pubkey) return 'Someone';
try {
return `${npubEncode(pubkey).slice(0, 12)}…`;
} catch {
return `${pubkey.slice(0, 12)}…`;
}
}
get senderAvatar() {
return this.item?.senderAvatar;
}
get hasPhoto() {
return !!this.item?.photo;
}
get photoThumbUrl() {
return this.item?.photo?.thumbUrl || this.item?.photo?.url;
}
get placeName() {
return this.item?.placeName;
}
get placeNameLoading() {
return this.item?.placeNameLoading;
}
<template>
<li>
<button
type="button"
class="activity-zap-item"
{{on "click" (fn @onSelect @item)}}
>
<div class="zap-header">
<span class="zap-sender-line">
<span class="zap-sender-name">{{this.senderDisplayName}}</span>
{{! template-lint-disable no-whitespace-for-layout }}
<span class="zap-action"> zapped your photo</span>
</span>
<span class="zap-amount">{{this.item.amountSats}} ⚡</span>
</div>
<div class="zap-context">
<div class="zap-context-images">
{{#if this.senderAvatar}}
<img
class="zap-sender-avatar"
src={{this.senderAvatar}}
alt=""
loading="lazy"
/>
{{else}}
<div class="zap-sender-avatar-placeholder">
<Icon @name="user" @size={{16}} @color="#999" />
</div>
{{/if}}
{{#if this.hasPhoto}}
<div class="zap-context-thumb">
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
</div>
{{/if}}
</div>
<div class="zap-context-text">
<span class="zap-place-name">
{{#if this.placeName}}
{{this.placeName}}
{{else if this.placeNameLoading}}
<span class="contribution-name-loading">Loading…</span>
{{else}}
<span class="contribution-name-loading">Unnamed place</span>
{{/if}}
</span>
<span class="zap-date">{{formatRelativeDate @item.createdAt}}</span>
</div>
</div>
{{#if this.item.message}}
<div class="zap-message">{{this.item.message}}</div>
{{/if}}
</button>
</li>
</template>
}
+7 -1
View File
@@ -26,8 +26,14 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
</button>
</li>
<li>
<button type="button" {{on "click" @onContributions}}>
<button type="button" {{on "click" @onActivity}}>
<Icon @name="activity" @size={{20}} />
<span>Activity</span>
</button>
</li>
<li>
<button type="button" {{on "click" @onContributions}}>
<Icon @name="user-check" @size={{20}} />
<span>My Contributions</span>
</button>
</li>
+6
View File
@@ -28,6 +28,11 @@ export default class AppMenu extends Component {
this.router.transitionTo('contributions');
}
@action
goToActivity() {
this.router.transitionTo('activity');
}
<template>
<div class="sidebar app-menu-pane">
{{#if (eq this.currentView "menu")}}
@@ -36,6 +41,7 @@ export default class AppMenu extends Component {
@onClose={{@onClose}}
@onSavedPlaces={{this.goToSavedPlaces}}
@onContributions={{this.goToContributions}}
@onActivity={{this.goToActivity}}
/>
{{else if (eq this.currentView "settings")}}
+131 -26
View File
@@ -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
+28
View File
@@ -113,6 +113,17 @@ export default class PlaceDetails extends Component {
}));
}
get hasUntrustedContent() {
return (
this.nostrData.untrustedContentCount > 0 &&
!this.nostrData.showUntrustedContent
);
}
get hasMultipleUntrusted() {
return this.nostrData.untrustedContentCount > 1;
}
@action
startEditing() {
if (!this.isSaved) return; // Only allow editing saved places
@@ -618,6 +629,23 @@ export default class PlaceDetails extends Component {
</button>
</span>
</p>
{{#if this.hasUntrustedContent}}
<p class="content-with-icon">
<Icon @name="shield" />
<span>
<button
type="button"
class="btn-link"
{{on "click" this.nostrData.toggleShowUntrustedContent}}
>
Show
{{this.nostrData.untrustedContentCount}}
hidden photo{{if this.hasMultipleUntrusted "s" ""}}
(untrusted relays)
</button>
</span>
</p>
{{/if}}
</div>
{{/if}}
</div>
+59
View File
@@ -0,0 +1,59 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
export default class ActivityController extends Controller {
@service router;
@service mapUi;
@service nostrAuth;
@service activity;
loadTask = task({ restartable: true }, async (pubkey) => {
if (!pubkey) return;
await this.activity.load(pubkey);
});
get scrollTop() {
return this.mapUi.getScrollPosition('activity');
}
get items() {
return this.activity.items;
}
get isConnected() {
return this.nostrAuth.isConnected;
}
@action
selectItem(item) {
if (!item || !item.placeIdentifier) return;
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition('activity', sidebarContent.scrollTop);
}
this.mapUi.returnToRoute = { name: 'activity' };
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
this.router.transitionTo(`/place/${item.placeIdentifier}`);
}
@action
onNostrConnected() {
this.loadTask.perform(this.nostrAuth.pubkey);
}
@action
backToMenu() {
this.router.transitionTo('menu');
}
@action
close() {
this.router.transitionTo('index');
}
}
+49
View File
@@ -0,0 +1,49 @@
import { modifier } from 'ember-modifier';
export default modifier((element) => {
let tooltipEl = null;
const portal = document.getElementById('modal-portal') || document.body;
const show = () => {
const text = element.getAttribute('aria-description');
if (!text || tooltipEl) return;
tooltipEl = document.createElement('div');
tooltipEl.className = 'tooltip';
tooltipEl.setAttribute('role', 'tooltip');
tooltipEl.textContent = text;
portal.appendChild(tooltipEl);
const rect = element.getBoundingClientRect();
const tipRect = tooltipEl.getBoundingClientRect();
const margin = 6;
let top = rect.top - tipRect.height - margin;
if (top < margin) top = rect.bottom + margin;
let left = rect.left + rect.width / 2 - tipRect.width / 2;
left = Math.max(
margin,
Math.min(left, window.innerWidth - tipRect.width - margin)
);
tooltipEl.style.top = `${top}px`;
tooltipEl.style.left = `${left}px`;
};
const hide = () => {
tooltipEl?.remove();
tooltipEl = null;
};
element.addEventListener('mouseenter', show);
element.addEventListener('mouseleave', hide);
element.addEventListener('focus', show);
element.addEventListener('blur', hide);
return () => {
element.removeEventListener('mouseenter', show);
element.removeEventListener('mouseleave', hide);
element.removeEventListener('focus', show);
element.removeEventListener('blur', hide);
hide();
};
});
+1
View File
@@ -15,6 +15,7 @@ Router.map(function () {
this.route('list', { path: '/:list_id' });
});
this.route('contributions');
this.route('activity');
this.route('oauth', function () {
this.route('osm-callback', { path: '/osm/callback' });
});
+23
View File
@@ -0,0 +1,23 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ActivityRoute extends Route {
@service mapUi;
@service nostrAuth;
@service activity;
activate() {
this.mapUi.showSidebar();
}
setupController(controller, model) {
super.setupController(controller, model);
if (controller && controller.loadTask) {
controller.loadTask.perform(this.nostrAuth.pubkey);
}
}
deactivate() {
this.activity.stop();
}
}
+206
View File
@@ -0,0 +1,206 @@
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { ProfileModel } from 'applesauce-core/models/profile';
import { getProfileContent } from 'applesauce-core/helpers/profile';
import { parseZapReceipt, enrichWithPhoto } from '../utils/activity';
/**
* Orchestrates loading the user's incoming social activity (zaps received on
* their photos) and exposing it as a tracked `items` list for the activity
* timeline.
*
* The flow is:
* 1. Subscribe to `nostrData.store.timeline(...)` for kind 9735 zap receipts
* where the user is the recipient (`#p` filter).
* 2. Parse each receipt into an `ActivityEntry`, filtering to zaps on the
* user's own kind 360 photos.
* 3. Resolve sender profiles asynchronously via a per-sender `ProfileModel`
* subscription, updating the tracked entry fields when they load.
* 4. Resolve place names via the shared `placeNameResolver` service.
* 5. Update `@tracked items` so the UI renders progressively.
*/
export default class ActivityService extends Service {
@service nostrData;
@service nostrAuth;
@service placeNameResolver;
@tracked items = [];
_sub = null;
_profileSubs = new Map();
_userPubkey = null;
/**
* Loads the user's incoming zap receipts and subscribes to live updates.
*
* @param {string} pubkey The user's Nostr pubkey
*/
async load(pubkey) {
if (!pubkey) {
this.items = [];
return;
}
this._userPubkey = pubkey;
const filters = [{ kinds: [9735], '#p': [pubkey] }];
console.debug('[activity] Subscribing to zap receipts', {
filters,
pubkey,
activeReadRelays: this.nostrData.activeReadRelays,
});
// Subscribe to the store timeline so we get live updates as receipts
// arrive and are added to the store.
this._sub = this.nostrData.store.timeline(filters).subscribe((events) => {
this._updateItems(events, pubkey);
});
// Ensure the user's kind 360 photo events are in the store first so
// enrichWithPhoto can look them up when zap receipts arrive.
await this.nostrData.loadMyContributions(pubkey);
// Then load zap receipts — adding them to the store triggers the
// timeline subscription, and by now the photo events are available.
await this.nostrData.loadIncomingZaps(pubkey);
}
/**
* Stops subscriptions and clears the timeline. Called when leaving the
* activity route.
*/
stop() {
if (this._sub) {
this._sub.unsubscribe();
this._sub = null;
}
this._cleanupProfileSubs();
this.items = [];
this._userPubkey = null;
this.placeNameResolver.reset();
}
willDestroy() {
this.stop();
super.willDestroy(...arguments);
}
_updateItems(receipts, pubkey) {
const entries = [];
for (const receipt of receipts) {
const entry = parseZapReceipt(receipt, pubkey);
if (!entry) continue;
// Only keep zaps for the user's own kind 360 photos
if (!enrichWithPhoto(entry, this.nostrData.store, pubkey)) {
continue;
}
// Resolve sender profile (async, fire-and-forget)
this._resolveSender(entry);
entries.push(entry);
}
// Sort newest-first by created_at
entries.sort((a, b) => b.createdAt - a.createdAt);
console.debug('[activity] Zap receipts received', {
total: receipts.length,
matched: entries.length,
pubkey,
});
// 2. Bookmark lookup is synchronous — resolve those immediately so the
// first render shows bookmarked place names without a "Loading…" flicker.
for (const entry of entries) {
if (entry.osmId) {
const bookmarkName = this.placeNameResolver.resolveBookmark(
entry.osmId
);
if (bookmarkName) {
entry.placeName = bookmarkName;
entry.placeNameLoading = false;
}
}
}
this.items = entries;
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
// cache, then fall through to the network batch for the rest.
void this.placeNameResolver.resolveInBackground(entries).then(() => {
this.items = [...this.items];
});
}
_resolveSender(entry) {
const pubkey = entry.senderPubkey;
if (!pubkey) return;
// Set up a ProfileModel subscription for this sender so the entry's
// tracked fields update when the profile arrives from cache or network.
// The event loader (configured in nostrData) auto-fetches missing kind 0
// events from the IDB cache and relays.
if (!this._profileSubs.has(pubkey)) {
const sub = this.nostrData.store
.model(ProfileModel, pubkey)
.subscribe((profileContent) => {
this._applyProfileToPubkey(pubkey, profileContent);
});
this._profileSubs.set(pubkey, sub);
}
// Read immediately in case the profile is already cached
this._applySenderProfile(entry, pubkey);
}
_applySenderProfile(entry, pubkey) {
// Try nostrData's profiles dict first (populated by other parts of the app)
let profile = this.nostrData.getProfile(pubkey);
// Fall back to reading the kind 0 event directly from the store. This
// handles the re-open case where the event is already in the store from
// a previous load but nostrData.profiles wasn't populated by this service.
if (!profile) {
const event = this.nostrData.store.getReplaceable(0, pubkey);
if (event) {
profile = getProfileContent(event);
}
}
if (profile) {
entry.senderName = profile.name || profile.display_name || null;
entry.senderAvatar = profile.picture || null;
entry.senderProfileLoading = false;
}
}
_applyProfileToPubkey(pubkey, profileContent) {
// Update all entries matching this sender
let changed = false;
for (const entry of this.items) {
if (entry.senderPubkey === pubkey) {
entry.senderName =
profileContent.name || profileContent.display_name || null;
entry.senderAvatar = profileContent.picture || null;
entry.senderProfileLoading = false;
changed = true;
}
}
if (changed) {
// Trigger re-render
this.items = [...this.items];
}
}
_cleanupProfileSubs() {
for (const sub of this._profileSubs.values()) {
sub.unsubscribe();
}
this._profileSubs.clear();
}
}
+5 -4
View File
@@ -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) {
+8 -191
View File
@@ -2,8 +2,6 @@ import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { groupPhotoContributions } from '../utils/contributions';
const NAME_CACHE_STORE = 'contributions-name-cache';
/**
* Orchestrates loading the user's own Nostr contributions, grouping them into
* timeline entries, and resolving place names from bookmarks or the OSM API.
@@ -25,77 +23,11 @@ const NAME_CACHE_STORE = 'contributions-name-cache';
export default class ContributionsService extends Service {
@service nostrData;
@service nostrAuth;
@service storage;
@service osm;
@service localForage;
@service placeNameResolver;
@tracked items = [];
_sub = null;
_pendingBatchPromise = null;
_lastBatchSignature = '';
_unresolvable = new Set();
/**
* Async name resolution. Checks, in order:
* 1. Bookmarks (sync, instant).
* 2. The persistent IndexedDB name cache (per-entry key).
* 3. The OSM service's IndexedDB cache (from place detail visits).
*
* @param {object} entry A contribution entry with `osmType`, `osmId`, and `placeIdentifier`.
* @returns {Promise<string|null>}
*/
async _resolveCachedName(entry) {
// 1. Try bookmarks (instant)
const bookmark = this.storage.findPlaceById(entry.osmId);
if (bookmark?.title) return bookmark.title;
// 2. Try the persistent name cache (IndexedDB, per-entry, survives sessions)
const cachedName = await this.localForage.get(
NAME_CACHE_STORE,
entry.placeIdentifier
);
if (cachedName) return cachedName;
// 3. Try OSM IndexedDB cache (from place detail visits)
const cached = await this.osm.getCachedOsmObject(
entry.osmType,
entry.osmId
);
return cached?.title || null;
}
/**
* Asynchronously resolves names for still-loading entries from the caches.
* Falls through to `_maybeBatchFetchNames` for anything that remains
* unresolved. Fire-and-forget from `_updateItems` so the list renders
* immediately with bookmark-resolved names.
*
* @param {Array} entries
* @returns {Promise<void>}
*/
async _resolveFromCache(entries) {
const pending = entries.filter((e) => e.placeNameLoading);
if (pending.length === 0) {
this._maybeBatchFetchNames(this.items);
return;
}
await Promise.all(
pending.map(async (entry) => {
const name = await this._resolveCachedName(entry);
if (name) {
entry.placeName = name;
entry.placeNameLoading = false;
}
})
);
// Re-render with whatever resolved, then kick off the network batch for
// entries that are still loading.
this.items = [...this.items];
this._maybeBatchFetchNames(this.items);
}
/**
* Loads the user's contributions and subscribes to live updates.
@@ -131,9 +63,7 @@ export default class ContributionsService extends Service {
this._sub = null;
}
this.items = [];
this._lastBatchSignature = '';
this._pendingBatchPromise = null;
this._unresolvable.clear();
this.placeNameResolver.reset();
}
willDestroy() {
@@ -148,9 +78,9 @@ export default class ContributionsService extends Service {
// 2. Bookmark lookup is synchronous — resolve those immediately so the
// first render shows bookmarked place names without a "Loading…" flicker.
for (const entry of entries) {
const bookmark = this.storage.findPlaceById(entry.osmId);
if (bookmark?.title) {
entry.placeName = bookmark.title;
const bookmarkName = this.placeNameResolver.resolveBookmark(entry.osmId);
if (bookmarkName) {
entry.placeName = bookmarkName;
entry.placeNameLoading = false;
}
}
@@ -159,121 +89,8 @@ export default class ContributionsService extends Service {
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
// cache, then fall through to the network batch for the rest.
void this._resolveFromCache(entries);
}
_isUnresolvable(entry) {
return this._unresolvable.has(entry.placeIdentifier);
}
_maybeBatchFetchNames(entries) {
const unresolved = entries.filter(
(e) => e.placeNameLoading && !this._isUnresolvable(e)
);
// Apply fallbacks for items already marked unresolvable in this session
for (const entry of entries) {
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
if (unresolved.length === 0) {
if (this.items.some((i) => !i.placeNameLoading)) {
this.items = [...this.items];
}
return;
}
// Build a stable signature so we only fire one batch request per unique set
const signature = unresolved
.map((e) => e.placeIdentifier)
.sort()
.join('|');
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
return;
}
this._lastBatchSignature = signature;
this._pendingBatchPromise = this._batchResolveNames(unresolved)
.then((nameMap) => {
// Merge resolved names back into the current `items`.
for (const item of this.items) {
if (!item.placeNameLoading) continue;
if (nameMap.has(item.placeIdentifier)) {
const name = nameMap.get(item.placeIdentifier);
item.placeName = name;
item.placeNameLoading = false;
} else {
// Could not be resolved (deleted object, network error for this item).
// Mark as unresolvable for this session and use a fallback.
this._unresolvable.add(item.placeIdentifier);
item.placeName = this._fallbackName(item);
item.placeNameLoading = false;
}
}
// Trigger a re-render
this.items = [...this.items];
})
.catch((e) => {
console.error('[contributions] Batch name resolution failed', e);
// On a total failure, mark all as unresolvable and apply fallbacks
for (const item of this.items) {
if (item.placeNameLoading) {
this._unresolvable.add(item.placeIdentifier);
item.placeName = this._fallbackName(item);
item.placeNameLoading = false;
}
}
this.items = [...this.items];
})
.finally(() => {
this._pendingBatchPromise = null;
});
}
_fallbackName(item) {
return `OSM ${item.osmType} ${item.osmId}`;
}
async _batchResolveNames(entries) {
const nameMap = new Map();
const toFetch = [];
// Re-check cache in case it was populated between the trigger and now
for (const entry of entries) {
const cached = await this._resolveCachedName(entry);
if (cached) {
nameMap.set(entry.placeIdentifier, cached);
} else {
toFetch.push({ osmType: entry.osmType, osmId: entry.osmId });
}
}
if (toFetch.length === 0) return nameMap;
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
// Persist each newly-resolved name to the IndexedDB name cache as its own
// entry (per-placeIdentifier key) so we don't re-fetch next session.
const writePromises = [];
for (const entry of entries) {
if (nameMap.has(entry.placeIdentifier)) continue;
const cacheKey = `${entry.osmType}:${entry.osmId}`;
const place = places.get(cacheKey);
if (place?.title) {
nameMap.set(entry.placeIdentifier, place.title);
writePromises.push(
this.localForage.set(
NAME_CACHE_STORE,
entry.placeIdentifier,
place.title
)
);
}
}
await Promise.all(writePromises);
return nameMap;
void this.placeNameResolver.resolveInBackground(entries).then(() => {
this.items = [...this.items];
});
}
}
+314 -56
View File
@@ -1,20 +1,25 @@
import Service, { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { EMPTY, from } from 'rxjs';
import { EMPTY, from, timeout, filter, connect, take, takeUntil } from 'rxjs';
import { EventStore } from 'applesauce-core/event-store';
import { ProfileModel } from 'applesauce-core/models/profile';
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import { ContactsModel } from 'applesauce-core/models/contacts';
import { isEventPointer, npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
import { RelayGroup } from 'applesauce-relay';
import { NostrIDB, openDB } from 'nostr-idb';
import {
excludeRequiredRelays,
mergeRequiredRelays,
normalizeRelayUrl,
relayUrlMatches,
uniqNormalizedRelays,
} from '../utils/nostr';
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
import { DEFAULT_BLOSSOM_SERVER } from './blossom';
const DIRECTORY_RELAYS = [
'wss://relay.primal.net',
@@ -23,34 +28,74 @@ const DIRECTORY_RELAYS = [
];
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
const DEFAULT_WRITE_RELAYS = [];
const DEFAULT_WRITE_RELAYS = []; // TODO nostr.kosmos.org/marco
// Content event kinds subject to relay-trust filtering. Adding `30360`
// (place reviews) here is the only change needed to extend the feature.
const TRUSTED_CONTENT_KINDS = [360];
// localforage store name for persisted relay provenance.
const PROVENANCE_STORE = 'event-relay-provenance';
// Replicates applesauce-relay's `completeWhen`: completes the source when the
// `operator` emits a truthy value. `completeWhen` is used internally by
// RelayGroup but not re-exported, so we recreate it from rxjs primitives.
function completeWhen(operator) {
return connect((shared$) => {
const complete$ = shared$.pipe(operator, filter(Boolean), take(1));
return shared$.pipe(takeUntil(complete$));
});
}
export default class NostrDataService extends Service {
@service nostrRelay;
@service nostrAuth;
@service settings;
@service localForage;
store = new EventStore();
@tracked profile = null;
@tracked mailboxes = null;
@tracked contacts = null;
@tracked blossomServers = [];
@tracked placePhotos = [];
@tracked myContributionEvents = [];
@tracked profiles = {};
@tracked zapReceipts = {};
// Relay-provenance trust state. `_eventRelays` maps eventId -> Set<relayUrl>
// and accumulates every relay an event was seen on (unconditionally, so an
// event first seen on an untrusted relay can be upgraded to trusted when it
// later arrives from a trusted one). Hydrated from IDB at init so cached
// events render with correct trust state instantly.
_eventRelays = new Map();
_provenanceReady = null;
// Set of pubkeys from the user's follow list (kind 3 contacts) for O(1)
// trust lookups. Rebuilt whenever contacts change.
_contactPubkeys = null;
// Session-only reveal toggle for untrusted content. Not persisted.
@tracked showUntrustedContent = false;
// Count of currently-hidden (untrusted) place photos for the selected place.
@tracked untrustedContentCount = 0;
_allPlacePhotos = [];
_profileSub = null;
_mailboxesSub = null;
_contactsSub = null;
_blossomSub = null;
_photosSub = null;
_contributionsSub = null;
_deletionsSub = null;
_profileModelSubs = new Map();
_zapReceiptsSub = null;
_zapReceiptsNetworkSub = null;
_zapRefreshTimer = null;
_lastPhotoIds = new Set();
_incomingZapsNetworkSub = null;
_requestSub = null;
_cachePromise = null;
@@ -86,10 +131,11 @@ export default class NostrDataService extends Service {
this._stopPersisting = persistEventsToCache(
this.store,
async (events) => {
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
// Only cache profiles, mailboxes, contacts, blossom servers, place photos, and deletions
const toCache = events.filter(
(e) =>
e.kind === 0 ||
e.kind === 3 ||
e.kind === 5 ||
e.kind === 10002 ||
e.kind === 10063 ||
@@ -108,6 +154,22 @@ export default class NostrDataService extends Service {
);
});
// Hydrate relay provenance from IDB so cached events can be trust-checked
// instantly without waiting for relay connections.
this._provenanceReady = this._hydrateProvenance();
// Centralized kind-5 deletion handling: the store routes kind-5 events to
// its DeleteManager (never into the event database), so listen to the
// deletion stream rather than a timeline. Drop provenance entries for
// deleted events so the trust map and IDB don't accumulate dead entries.
this._deletionsSub = this.store.deletes.deleted$.subscribe(
({ pointer }) => {
if (isEventPointer(pointer)) {
this._removeProvenance(pointer.id);
}
}
);
// Feed events from the relay pool into the event store
this.nostrRelay.pool.relays$.subscribe(() => {
// Setup relay subscription tracking if needed, or we just rely on request()
@@ -173,6 +235,133 @@ export default class NostrDataService extends Service {
);
}
// Relays treated as "moderated" for content-trust filtering. Always includes
// the required read relays (e.g. nostr.kosmos.org); `nostrTrustedRelays`
// adds user-marked custom relays on top. Default (null) = required relays
// only, so the app only surfaces content verified by those relays.
get trustedRelays() {
const configured = this.settings.nostrTrustedRelays || [];
return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]);
}
/**
* Returns true if an event should be considered trusted:
* - authored by the connected user (own uploads), OR
* - authored by a pubkey the user follows (kind 3 contacts), OR
* - seen on at least one trusted (moderated) relay.
*/
isTrustedEvent(event) {
if (!event) return false;
const myPubkey = this.nostrAuth?.pubkey;
if (myPubkey && event.pubkey === myPubkey) return true;
if (this._contactPubkeys?.has(event.pubkey)) return true;
const relays = this._eventRelays.get(event.id);
if (!relays || relays.size === 0) return false;
const trusted = this.trustedRelays;
for (const url of relays) {
if (relayUrlMatches(url, trusted)) return true;
}
return false;
}
/**
* Splits content events into trusted / untrusted buckets. Non-content kinds
* (e.g. kind 5 deletions) are always treated as trusted so they pass through.
*/
partitionByTrust(events) {
const trusted = [];
const untrusted = [];
for (const event of events) {
if (
!TRUSTED_CONTENT_KINDS.includes(event.kind) ||
this.isTrustedEvent(event)
) {
trusted.push(event);
} else {
untrusted.push(event);
}
}
return { trusted, untrusted };
}
// Hydrate the in-memory provenance map from the IDB store.
async _hydrateProvenance() {
try {
await this.localForage.iterate(PROVENANCE_STORE, (value, key) => {
this._eventRelays.set(key, new Set(value || []));
});
} catch (e) {
console.debug('[nostr-data] Failed to hydrate relay provenance', e);
}
}
// Record that `eventId` was seen on `relayUrl`. Merges into the existing
// set (accumulates across sightings) and persists fire-and-forget.
_recordProvenance(eventId, relayUrl) {
if (!eventId || !relayUrl) return;
let set = this._eventRelays.get(eventId);
if (!set) {
set = new Set();
this._eventRelays.set(eventId, set);
}
if (set.has(relayUrl)) return;
set.add(relayUrl);
const urls = Array.from(set);
// Fire-and-forget persistence; never blocks the render path.
this.localForage.set(PROVENANCE_STORE, eventId, urls).catch((e) => {
console.debug('[nostr-data] Failed to persist relay provenance', e);
});
}
// Remove provenance for a deleted event (called on kind-5 processing).
_removeProvenance(eventId) {
if (!eventId) return;
if (!this._eventRelays.delete(eventId)) return;
this.localForage.remove(PROVENANCE_STORE, eventId).catch((e) => {
console.debug('[nostr-data] Failed to remove relay provenance', e);
});
}
/**
* Request content events from relays while capturing full provenance.
*
* This mirrors `RelayGroup.request()` completion semantics (wait for the
* first relay EOSE + 5s grace period, or all relays EOSE; 30s hard
* timeout) but uses `pool.req()` so each `EVENT` message retains its
* `from` relay URL. Every sighting is recorded in the provenance map
* (unconditionally, including duplicates) so an event first seen on an
* untrusted relay can be upgraded to trusted when it later arrives from
* a trusted one. Events are always added to the store regardless of trust
* status; filtering happens at presentation time.
*/
_requestContentWithProvenance(relays, filters, errorLabel) {
const complete = RelayGroup.completeOnAny(
RelayGroup.completeAfterFirstRelay(5_000),
RelayGroup.completeOnAllEose()
);
return this.nostrRelay.pool
.req(relays, filters)
.pipe(
completeWhen(complete),
timeout({ first: 30_000 }),
// Only EVENT messages carry content; ignore OPEN/EOSE/CLOSED/ERROR.
// We deliberately do NOT dedupe so we see every relay's copy and can
// accumulate full provenance across relays.
filter((message) => message.type === 'EVENT')
)
.subscribe({
next: (message) => {
this._recordProvenance(message.event.id, message.from);
this.store.add(message.event);
},
error: (err) => {
console.error(errorLabel, err);
},
});
}
async loadPlacesInBounds(bbox) {
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
@@ -211,25 +400,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 +453,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 +482,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 +543,61 @@ 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:'
);
}
/**
* Hydrates the store with incoming zap receipts (kind 9735) where the user is
* the recipient (`#p` filter). Loads from the IDB cache first (instant), then
* fires a network request. Called by the `activity` service.
*
* @param {string} pubkey The user's Nostr pubkey
*/
async loadIncomingZaps(pubkey) {
if (!pubkey) return;
const filters = [{ kinds: [9735], '#p': [pubkey] }];
console.debug('[nostr-data] Requesting incoming zap receipts from relays', {
filters,
relays: this.activeReadRelays,
});
if (this._incomingZapsNetworkSub) {
this._incomingZapsNetworkSub.unsubscribe();
this._incomingZapsNetworkSub = null;
}
// 1. Populate the store from the local Nostr IDB cache (instant)
try {
await this._cachePromise;
const cachedEvents = await this.cache.query(filters);
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
} catch (e) {
console.warn(
'[nostr-data] Failed to read incoming zap receipts from local Nostr IDB cache',
e
);
}
// 2. Request fresh events from the network in the background
this._incomingZapsNetworkSub = this._requestContentWithProvenance(
this.activeReadRelays,
filters,
'[nostr-data] Error fetching incoming zap receipts:'
);
}
loadProfiles(pubkeys) {
@@ -409,6 +633,8 @@ export default class NostrDataService extends Service {
// Reset state
this.profile = null;
this.mailboxes = null;
this.contacts = null;
this._contactPubkeys = null;
this.blossomServers = [];
this._cleanupSubscriptions();
@@ -427,6 +653,14 @@ export default class NostrDataService extends Service {
this.mailboxes = mailboxesData;
});
this._contactsSub = this.store
.model(ContactsModel, pubkey)
.subscribe((contacts) => {
this.contacts = contacts;
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
this._updatePlacePhotos();
});
this._blossomSub = this.store
.replaceable(10063, pubkey)
.subscribe((event) => {
@@ -437,6 +671,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
@@ -446,7 +690,7 @@ export default class NostrDataService extends Service {
const cachedEvents = await this.cache.query([
{
authors: [pubkey],
kinds: [0, 10002, 10063],
kinds: [0, 3, 10002, 10063],
},
]);
@@ -467,7 +711,7 @@ export default class NostrDataService extends Service {
.request(profileRelays, [
{
authors: [pubkey],
kinds: [0, 10002, 10063],
kinds: [0, 3, 10002, 10063],
},
])
.subscribe({
@@ -629,6 +873,11 @@ export default class NostrDataService extends Service {
this._mailboxesSub.unsubscribe();
this._mailboxesSub = null;
}
if (this._contactsSub) {
this._contactsSub.unsubscribe();
this._contactsSub = null;
this._contactPubkeys = null;
}
if (this._blossomSub) {
this._blossomSub.unsubscribe();
this._blossomSub = null;
@@ -643,6 +892,10 @@ export default class NostrDataService extends Service {
}
this._cleanupZapReceiptSubs();
this._clearZapRefreshTimer();
if (this._incomingZapsNetworkSub) {
this._incomingZapsNetworkSub.unsubscribe();
this._incomingZapsNetworkSub = null;
}
}
willDestroy() {
@@ -650,6 +903,11 @@ export default class NostrDataService extends Service {
this._cleanupSubscriptions();
this._clearProfileSubs();
if (this._deletionsSub) {
this._deletionsSub.unsubscribe();
this._deletionsSub = null;
}
if (this._stopPersisting) {
this._stopPersisting();
}
+20 -5
View File
@@ -457,11 +457,11 @@ out center;
// request using comma-separated IDs (max ~50 per request).
//
// Note: the multi-fetch endpoint returns ways/relations WITHOUT their child
// nodes, so normalized data may lack lat/lon and geometry. We intentionally
// do NOT write these results to the general OSM cache — only the returned
// Map is used by the caller (the contributions service manages its own
// name cache). This prevents incomplete data from breaking place detail
// navigation, which needs the full single-object endpoint.
// nodes, so normalized data may lack lat/lon and geometry.
// We only write complete results (those with lat/lon) to the general OSM
// cache. Nodes from the batch endpoint always include coordinates, so they
// are cached. Ways/relations without child node coordinates are not cached,
// preserving the full single-object endpoint for place detail navigation.
const MAX_IDS_PER_REQUEST = 50;
for (let i = 0; i < group.length; i += MAX_IDS_PER_REQUEST) {
@@ -488,6 +488,21 @@ out center;
);
if (normalized) {
result.set(item.cacheKey, normalized);
// Warm the OSM cache for complete results (nodes always have
// lat/lon from the batch endpoint; ways/relations only when
// child nodes are included in the response).
if (
normalized.lat != null &&
normalized.lon != null &&
normalized.title
) {
this._storeInMemoryAndLocalStorage(
item.cacheKey,
osmType,
item.osmId,
normalized
);
}
}
}
} catch (e) {
+196
View File
@@ -0,0 +1,196 @@
import Service, { service } from '@ember/service';
const NAME_CACHE_STORE = 'place-name-cache';
export default class PlaceNameResolverService extends Service {
@service storage;
@service osm;
@service localForage;
_pendingBatchPromise = null;
_lastBatchSignature = '';
_unresolvable = new Set();
/**
* Synchronous bookmark lookup. Returns the bookmark title if found, null otherwise.
*
* @param {string} osmId The OSM entity ID (e.g. "123456")
* @returns {string|null}
*/
resolveBookmark(osmId) {
const bookmark = this.storage.findPlaceById(osmId);
return bookmark?.title || null;
}
/**
* Asynchronous name resolution from caches (localForage → OSM IDB cache).
* Returns the resolved name or null if not in cache.
*
* @param {object} entry Entry with placeIdentifier, osmType, osmId
* @returns {Promise<string|null>}
*/
async _resolveCachedName(entry) {
// 1. Try the persistent name cache (IndexedDB, per-entry, survives sessions)
const cachedName = await this.localForage.get(
NAME_CACHE_STORE,
entry.placeIdentifier
);
if (cachedName) return cachedName;
// 2. Try OSM IndexedDB cache (from place detail visits)
const cached = await this.osm.getCachedOsmObject(
entry.osmType,
entry.osmId
);
return cached?.title || null;
}
/**
* Resolves names for entries in the background.
* Fire-and-forget: resolves from caches, then falls through to batch OSM fetch
* for anything still unresolved. Mutates entry.placeName and entry.placeNameLoading.
* The caller is responsible for triggering a re-render (e.g. `this.items = [...this.items]`).
*
* @param {Array} entries Entries with placeIdentifier, osmType, osmId, placeName, placeNameLoading
* @returns {Promise<void>}
*/
async resolveInBackground(entries) {
const pending = entries.filter((e) => e.placeNameLoading);
if (pending.length === 0) {
this._maybeBatchFetchNames(entries);
return;
}
await Promise.all(
pending.map(async (entry) => {
const name = await this._resolveCachedName(entry);
if (name) {
entry.placeName = name;
entry.placeNameLoading = false;
}
})
);
// Trigger re-render via caller, then kick off the network batch for
// entries that are still loading.
this._maybeBatchFetchNames(entries);
}
_isUnresolvable(entry) {
return this._unresolvable.has(entry.placeIdentifier);
}
_maybeBatchFetchNames(entries) {
const unresolved = entries.filter(
(e) => e.placeNameLoading && !this._isUnresolvable(e)
);
// Apply fallbacks for items already marked unresolvable in this session
for (const entry of entries) {
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
if (unresolved.length === 0) {
return;
}
// Build a stable signature so we only fire one batch request per unique set
const signature = unresolved
.map((e) => e.placeIdentifier)
.sort()
.join('|');
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
return;
}
this._lastBatchSignature = signature;
this._pendingBatchPromise = this._batchResolveNames(unresolved)
.then((nameMap) => {
// Merge resolved names back into the entries
for (const entry of entries) {
if (!entry.placeNameLoading) continue;
if (nameMap.has(entry.placeIdentifier)) {
const name = nameMap.get(entry.placeIdentifier);
entry.placeName = name;
entry.placeNameLoading = false;
} else {
// Could not be resolved (deleted object, network error for this item).
// Mark as unresolvable for this session and use a fallback.
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
})
.catch((e) => {
console.error('[place-name-resolver] Batch name resolution failed', e);
// On a total failure, mark all as unresolvable and apply fallbacks
for (const entry of entries) {
if (entry.placeNameLoading) {
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
})
.finally(() => {
this._pendingBatchPromise = null;
});
}
_fallbackName(entry) {
return `OSM ${entry.osmType} ${entry.osmId}`;
}
async _batchResolveNames(entries) {
const nameMap = new Map();
const toFetch = [];
// Re-check cache in case it was populated between the trigger and now
for (const entry of entries) {
const cached = await this._resolveCachedName(entry);
if (cached) {
nameMap.set(entry.placeIdentifier, cached);
} else {
toFetch.push({ osmType: entry.osmType, osmId: entry.osmId });
}
}
if (toFetch.length === 0) return nameMap;
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
// Persist each newly-resolved name to the IndexedDB name cache as its own
// entry (per-placeIdentifier key) so we don't re-fetch next session.
const writePromises = [];
for (const entry of entries) {
if (nameMap.has(entry.placeIdentifier)) continue;
const cacheKey = `${entry.osmType}:${entry.osmId}`;
const place = places.get(cacheKey);
if (place?.title) {
nameMap.set(entry.placeIdentifier, place.title);
writePromises.push(
this.localForage.set(
NAME_CACHE_STORE,
entry.placeIdentifier,
place.title
)
);
}
}
await Promise.all(writePromises);
return nameMap;
}
/**
* Resets per-session state. Called when the owning service stops.
*/
reset() {
this._unresolvable.clear();
this._lastBatchSignature = '';
this._pendingBatchPromise = null;
}
}
+13
View File
@@ -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));
+2
View File
@@ -61,6 +61,7 @@ export default class StorageService extends Service {
this.rs.on('connected', () => {
this.connected = true;
this.userAddress = this.rs.remote.userAddress;
this.initialSyncDone = false;
if (this.isNewConnection) {
this.toast.show('Remote storage connected', 3000);
@@ -71,6 +72,7 @@ export default class StorageService extends Service {
});
this.rs.on('not-connected', () => {
this.initialSyncDone = true;
this.loadLists();
});
+245 -3
View File
@@ -13,6 +13,10 @@
--danger-color: var(--marker-color-primary);
--danger-color-dark: var(--marker-color-dark);
--default-list-color: #fc3;
--secondary-text-color: #898989;
--border-color: #d8d8d8;
--success-color: #2e7d32;
--secondary-background-color: #f0f0f0;
}
html,
@@ -611,14 +615,28 @@ body {
.relay-list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 0.25rem 0;
border-radius: 4px;
font-size: 0.9rem;
word-break: break-all;
}
.relay-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
.relay-list-hint {
margin: 0 0 0.5rem;
font-size: 0.8rem;
color: var(--secondary-text-color);
}
.btn-remove-relay {
display: flex;
align-items: center;
@@ -639,12 +657,19 @@ body {
stroke: currentcolor;
}
.btn-remove-relay:hover,
.btn-remove-relay:active {
.btn-remove-relay:hover:not(:disabled),
.btn-remove-relay:active:not(:disabled) {
background-color: var(--danger-color);
color: var(--primary-background-color);
}
.btn-remove-relay:disabled {
border-color: var(--border-color);
color: var(--secondary-text-color);
cursor: default;
opacity: 0.6;
}
.add-relay-input {
display: flex;
gap: 0.5rem;
@@ -655,6 +680,64 @@ body {
font-size: 0.85rem;
}
.btn-trust-relay {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: var(--primary-background-color);
border: 1px solid var(--border-color);
color: var(--secondary-text-color);
cursor: pointer;
padding: 0;
transition: all 0.1s ease;
flex-shrink: 0;
}
.btn-trust-relay svg {
stroke: currentcolor;
}
.btn-trust-relay.is-trusted {
border-color: var(--success-color);
color: var(--success-color);
}
.btn-trust-relay:hover,
.btn-trust-relay:active {
background-color: var(--secondary-background-color);
}
.btn-trust-relay:disabled {
cursor: default;
opacity: 0.6;
}
.tooltip {
position: fixed;
padding: 4px 8px;
border-radius: 4px;
background: var(--body-text-color);
color: var(--primary-background-color);
font-size: 0.85rem;
white-space: nowrap;
pointer-events: none;
z-index: 9999;
animation: tooltip-fade-in 0.15s ease;
}
@keyframes tooltip-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes details-slide-down {
from {
opacity: 0;
@@ -1093,6 +1176,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;
@@ -2572,3 +2660,157 @@ button.create-place {
color: var(--body-text-color);
}
}
/* Activity Timeline — zap activity list rendered in the sidebar */
.activity-list {
list-style: none;
padding: 0;
margin: -1rem -1rem 0;
}
.activity-zap-item {
width: 100%;
text-align: left;
border: none;
border-bottom: 1px solid var(--divider-color);
background: var(--primary-background-color);
color: var(--body-text-color);
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
display: flex;
flex-direction: column;
gap: 4px;
&:hover {
background: var(--hover-bg);
}
& .zap-sender-avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
background: #f0f0f0;
}
& .zap-sender-avatar-placeholder {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
color: #999;
}
& .zap-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
& .zap-sender-line {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem;
& .zap-sender-name {
font-weight: bold;
}
& .zap-action {
color: #666;
font-weight: normal;
}
}
& .zap-amount {
flex-shrink: 0;
font-weight: bold;
font-size: 0.95rem;
color: var(--body-text-color);
white-space: nowrap;
}
& .zap-message {
color: var(--body-text-color);
font-size: 0.85rem;
font-style: italic;
position: relative;
background: var(--secondary-background-color);
border-radius: 8px;
padding: 0.5rem 0.75rem;
margin-top: 4px;
&::before {
content: '';
position: absolute;
top: -5px;
left: 11px;
width: 10px;
height: 10px;
background: var(--secondary-background-color);
transform: rotate(45deg);
}
}
& .zap-context {
display: flex;
align-items: center;
gap: 6px;
color: #999;
font-size: 0.8rem;
margin-top: 8px;
& .zap-context-images {
display: flex;
align-items: center;
gap: 0.5rem;
}
& .zap-context-thumb {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
background: #f0f0f0;
& img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
& .zap-context-text {
display: flex;
align-items: baseline;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
& .zap-place-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
}
& .zap-date {
flex-shrink: 0;
white-space: nowrap;
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import ActivityTimeline from '#components/activity-timeline';
<template>
{{#if @controller.mapUi.isSidebarVisible}}
<ActivityTimeline
@items={{@controller.items}}
@isLoading={{@controller.loadTask.isRunning}}
@isConnected={{@controller.isConnected}}
@scrollTop={{@controller.scrollTop}}
@onSelect={{@controller.selectItem}}
@onBack={{@controller.backToMenu}}
@onClose={{@controller.close}}
@onNostrConnected={{@controller.onNostrConnected}}
/>
{{/if}}
</template>
+3
View File
@@ -23,6 +23,7 @@ export default class ApplicationComponent extends Component {
name === 'search' ||
name === 'menu' ||
name === 'contributions' ||
name === 'activity' ||
name.startsWith('lists'))
);
}
@@ -51,6 +52,7 @@ export default class ApplicationComponent extends Component {
name === 'place' ||
name === 'menu' ||
name === 'contributions' ||
name === 'activity' ||
name.startsWith('lists')
) {
this.mapUi.clearSelection();
@@ -58,6 +60,7 @@ export default class ApplicationComponent extends Component {
if (
name === 'menu' ||
name === 'contributions' ||
name === 'activity' ||
name.startsWith('lists')
) {
this.router.transitionTo('index');
+142
View File
@@ -0,0 +1,142 @@
/**
* Utilities for parsing social activity events into timeline entries.
*
* An `ActivityEntry` represents a single social interaction related to the
* user's content (e.g. a zap received on one of their photos). Entries are
* ordered newest-first.
*/
import { tracked } from '@glimmer/tracking';
import {
getZapSender,
getZapRecipient,
getZapPayment,
getZapEventPointer,
getZapRequest,
} from 'applesauce-common/helpers';
import { parsePhotoFromEvent } from './contributions';
/**
* A single activity timeline entry.
*
* `senderName`, `senderAvatar`, `senderProfileLoading`, `placeName`,
* and `placeNameLoading` are tracked so the row re-renders when the sender's
* profile or place name is resolved asynchronously.
*/
export class ActivityEntry {
type = 'zap';
photoEventId;
photo;
placeIdentifier;
osmType;
osmId;
senderPubkey;
amountSats;
message;
createdAt;
@tracked senderName = null;
@tracked senderAvatar = null;
@tracked senderProfileLoading = true;
@tracked placeName = null;
@tracked placeNameLoading = true;
constructor({
photoEventId,
photo,
placeIdentifier,
senderPubkey,
amountSats,
message,
createdAt,
}) {
this.photoEventId = photoEventId;
this.photo = photo;
this.placeIdentifier = placeIdentifier;
this.senderPubkey = senderPubkey;
this.amountSats = amountSats;
this.message = message;
this.createdAt = createdAt;
}
}
// TODO: Place-name resolution. Add a shared place-name-resolver util co-used by
// services/contributions.js and this service. Each ActivityEntry should get
// @tracked placeName / placeNameLoading, resolved via the existing cascade
// (bookmarks → localForage cache → osm cache → batch OSM fetch). Extract from
// contributions.js rather than duplicating.
/**
* Parses a kind 9735 (Zap Receipt) event into an `ActivityEntry`, given the
* user's pubkey (to confirm they are the recipient).
*
* Returns `null` if the receipt is malformed, not directed at the user, or
* does not reference a zapped event (kind 360 photo).
*
* @param {object} receipt A NIP-57 zap receipt event (kind 9735)
* @param {string} userPubkey The logged-in user's pubkey
* @returns {ActivityEntry|null}
*/
export function parseZapReceipt(receipt, userPubkey) {
if (!receipt || receipt.kind !== 9735) return null;
const recipient = getZapRecipient(receipt);
if (!recipient || recipient !== userPubkey) return null;
const sender = getZapSender(receipt);
if (!sender) return null;
const payment = getZapPayment(receipt);
if (!payment || !payment.amount) return null;
const amountSats = Math.round(payment.amount / 1000);
const eventPointer = getZapEventPointer(receipt);
if (!eventPointer || !eventPointer.id) return null;
const zapRequest = getZapRequest(receipt);
const message = zapRequest?.content || null;
return new ActivityEntry({
photoEventId: eventPointer.id,
photo: null,
placeIdentifier: null,
senderPubkey: sender,
amountSats,
message,
createdAt: receipt.created_at,
});
}
/**
* Enriches an `ActivityEntry` with photo and place data from the zapped kind
* 360 event, if it is available in the store.
*
* Returns `true` if the entry was enriched (i.e. the zapped event was found
* and is a kind 360 authored by the user), or `false` if the entry should be
* discarded (the zapped event is not one of the user's photos).
*
* @param {ActivityEntry} entry
* @param {object} store The applesauce EventStore to look up the zapped event
* @param {string} userPubkey The logged-in user's pubkey
* @returns {boolean}
*/
export function enrichWithPhoto(entry, store, userPubkey) {
const event = store.getEvent?.(entry.photoEventId);
if (!event || event.kind !== 360 || event.pubkey !== userPubkey) {
return false;
}
const photo = parsePhotoFromEvent(event);
if (!photo) return false;
entry.photo = photo;
entry.placeIdentifier = photo.placeIdentifier || null;
// Parse osmType and osmId from placeIdentifier (e.g. "osm:node:123" → "node", "123")
if (entry.placeIdentifier) {
const [, osmType, osmId] = entry.placeIdentifier.split(':');
entry.osmType = osmType;
entry.osmId = osmId;
}
return true;
}
+1 -1
View File
@@ -54,7 +54,7 @@ export class ContributionEntry {
* @param {object} event A NIP-360 (kind 360) Nostr event
* @returns {object|null} A photo object, or null if no usable `imeta` was found
*/
function parsePhotoFromEvent(event) {
export function parsePhotoFromEvent(event) {
const tags = event.tags || [];
const eventTags = tags
+2 -1
View File
@@ -26,9 +26,10 @@ export function formatRelativeDate(timestamp) {
if (diffMin < 60) return `${diffMin} min ago`;
if (diffHr < 24) return `${diffHr} hr ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
const isCurrentYear = date.getFullYear() === now.getFullYear();
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
...(isCurrentYear ? {} : { year: 'numeric' }),
});
}
+25
View File
@@ -34,10 +34,12 @@ 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';
import user from 'feather-icons/dist/icons/user.svg?raw';
import userCheck from 'feather-icons/dist/icons/user-check.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import check from 'feather-icons/dist/icons/check.svg?raw';
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
@@ -47,9 +49,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 +66,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 +79,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 +99,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 +142,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 +157,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 +192,7 @@ const ICONS = {
cleaver,
cloth,
cocktail,
couch,
clock,
'coffee-bean': coffeeBean,
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
@@ -183,6 +200,7 @@ const ICONS = {
'cup-and-saucer': cupAndSaucer,
database,
donut,
'dollar-swap-to-euro': dollarSwapToEuro,
edit,
eyeglasses,
facebook,
@@ -195,8 +213,10 @@ const ICONS = {
'flower-bouquet': flowerBouquet,
'fork-and-knife': forkAndKnife,
fort,
'gasoline-pump': gasolinePump,
gift,
globe,
grapes,
gravestone,
'grecian-vase': grecianVase,
'greek-cross': greekCross,
@@ -262,18 +282,23 @@ const ICONS = {
'upload-cloud': uploadCloud,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user,
'user-check': userCheck,
'village-buildings': villageBuildings,
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
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,
};
+21
View File
@@ -38,6 +38,27 @@ export function excludeRequiredRelays(customRelays = [], requiredRelays = []) {
});
}
/**
* Returns true if `relayUrl` matches any entry in `trustedRelays`.
*
* Comparison is performed on normalized relay URLs (lowercased, `wss://`
* scheme ensured, trailing slashes stripped) via `normalizeRelayUrl`, so
* callers may pass raw `wss://` URLs received from relays or bare hostnames.
*
* @param {string} relayUrl The relay URL a content event was seen on
* @param {Array<string>} trustedRelays List of trusted relay URLs
* @returns {boolean}
*/
export function relayUrlMatches(relayUrl, trustedRelays = []) {
if (!relayUrl) return false;
const target = normalizeRelayUrl(relayUrl);
if (!target) return false;
const trustedSet = new Set(
uniqNormalizedRelays(trustedRelays || []).filter(Boolean)
);
return trustedSet.has(target);
}
/**
* Extracts and normalizes photo data from NIP-360 (Place Photos) events.
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
+16
View File
@@ -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
View File
@@ -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',
+5 -5
View File
@@ -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) {
+1 -1
View File
@@ -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"]',
],
+71
View File
@@ -0,0 +1,71 @@
# Relay Trust in Marco
## Purpose
Marco fetches Nostr content (currently kind 360 place photos, later kind 30360 reviews) from the user's read relays. Because anyone can run a relay and publish spam, Marco only displays content by default when it was seen on a **trusted relay** — a relay the user (or Marco's defaults) treats as sufficiently moderated.
## Trust Model
| Rule | Description |
| ------------------------ | ------------------------------------------------------------------------------------------------------------ |
| **Required relays** | Always trusted (e.g. `wss://nostr.kosmos.org`). Cannot be untrusted. |
| **Custom relays** | Untrusted by default. Users toggle them to trusted in **Settings → Nostr → Read Relays**. |
| **Own content** | Events authored by the connected user's pubkey are always trusted. |
| **Non-content kinds** | Kind 5 deletions and metadata always pass through unfiltered. |
| **Provenance upgrades** | If an event is first seen on an untrusted relay and later on a trusted one, it becomes visible. |
| **Hiding, not blocking** | Untrusted content is cached and stored; it is filtered at presentation time so it can be revealed on demand. |
## How It Works Under the Hood
### 1. Provenance Capture
- Instead of `pool.request()` (which drops relay origin via an internal `map(m => m.event)`), Marco uses `RelayPool.req()` from `applesauce-relay`.
- Each `EVENT` message includes `from: relayUrl`.
- Every sighting is recorded in an in-memory map: `eventId → Set<relayUrl>`.
- Duplicate deliveries are _not_ deduplicated at the stream level, so full provenance across all responding relays is preserved.
### 2. Persistence
- Provenance is persisted to IndexedDB via `localForage` under the store `event-relay-provenance`.
- On app start, it is hydrated into memory so cached photos render instantly with correct trust state before relay connections are established.
### 3. Trust Evaluation
- `trustedRelays` = required read relays + `settings.nostrTrustedRelays` (user-marked custom relays).
- An event is trusted if any relay URL in its provenance set matches `trustedRelays` (compared via normalized URLs).
- Trust is evaluated when presenting the photo timeline for a place.
### 4. Presentation Filtering
- `NostrDataService.placePhotos` contains only trusted events.
- `untrustedContentCount` tracks how many are hidden.
- A session-only toggle `showUntrustedContent` reveals them via the "Show N hidden photo(s) (untrusted relays)" button in place details.
### 5. Deletions
- When a kind 5 deletion event enters the store, provenance entries for the referenced event IDs are removed from memory and IndexedDB.
## Key Code Paths
| File | What it does |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `app/services/nostr-data.js` | `_requestContentWithProvenance()`, `isTrustedEvent()`, `partitionByTrust()`, `trustedRelays` getter, provenance hydration/persistence |
| `app/services/settings.js` | `nostrTrustedRelays` setting (null = required relays only) |
| `app/components/app-menu/settings/nostr.gjs` | Per-relay shield toggle in read-relay list |
| `app/components/place-details.gjs` | Hidden-content reveal link in meta section |
| `app/utils/nostr.js` | `relayUrlMatches()` normalization/matching helper |
| `app/utils/icons.js` | `shield` icon used for trusted state |
| `app/styles/app.css` | `.btn-trust-relay` styles in relay settings |
## Extending the Feature
- **Add kind 30360 reviews:** Add `30360` to `TRUSTED_CONTENT_KINDS` in `app/services/nostr-data.js`. Reuse `partitionByTrust` in the reviews timeline.
- **Followee trust:** Extend `isTrustedEvent()` to also check `event.pubkey` against a contact/follow list.
- **Spam eviction from cache:** Provenance data already supports this; future work can purge cached events from `nostr-idb` if they only appear on relays marked untrusted.
- **Provenance UI:** The stored `eventId → relayUrls` map can power a "Seen on these relays" tooltip or details pane.
## Known Limitations / Future Work
- Provenance store is currently unbounded; add LRU/TTL cleanup.
- Untrusting a relay hides its content immediately, but does not purge the `nostr-idb` event cache.
- Relay settings UI is currently the only way to manage trust; no global "trust all" toggle.
+3
View File
@@ -6,6 +6,9 @@
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Fediverse account -->
<link rel="me" href="https://kosmos.social/@marco">
<!-- Open Graph -->
<meta property="og:title" content="Marco">
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.29.0",
"version": "1.32.0",
"private": true,
"description": "Unhosted maps app",
"repository": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -6,6 +6,9 @@
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Fediverse account -->
<link rel="me" href="https://kosmos.social/@marco">
<!-- Open Graph -->
<meta property="og:title" content="Marco">
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
@@ -39,8 +42,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-Znuwpp9Y.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CEhcTO-R.css">
</head>
<body>
<div id="modal-portal"></div>
-6
View File
@@ -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",
+141
View File
@@ -0,0 +1,141 @@
import { module, test } from 'qunit';
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
class MockActivityService extends Service {
@tracked items = [
{
type: 'zap',
photoEventId: 'photo-1',
photo: {
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
},
placeIdentifier: 'osm:node:123',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: 'Great photo!',
createdAt: Math.floor(Date.now() / 1000) - 3600,
senderName: 'Alice',
senderAvatar: 'https://x.com/avatar.jpg',
senderProfileLoading: false,
},
];
async load() {}
stop() {
this.items = [];
}
}
class MockNostrAuthService extends Service {
@tracked pubkey = 'test-pubkey';
get isConnected() {
return true;
}
}
class MockStorageService extends Service {
initialSyncDone = true;
savedPlaces = [];
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
loadPlacesInBounds() {
return [];
}
get placesInView() {
return [];
}
rs = {
on: () => {},
};
}
class MockOsmService extends Service {
async fetchOsmObject() {
return {
osmId: '123',
osmType: 'node',
lat: 1,
lon: 1,
osmTags: { name: 'Test Place', amenity: 'cafe' },
title: 'Test Place',
};
}
getCachedOsmObject() {
return Promise.resolve(null);
}
}
module('Acceptance | activity', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:activity', MockActivityService);
this.owner.register('service:nostrAuth', MockNostrAuthService);
this.owner.register('service:storage', MockStorageService);
this.owner.register('service:osm', MockOsmService);
});
test('visiting /activity renders the sidebar with activity items', async function (assert) {
await visit('/activity');
assert.strictEqual(currentURL(), '/activity');
assert.dom('.sidebar').exists('Sidebar is rendered');
assert.dom('.sidebar-header-text-centered').includesText('Activity');
});
test('activity items are rendered as zap rows', async function (assert) {
await visit('/activity');
await waitFor('.activity-zap-item');
assert.dom('.activity-zap-item').exists({ count: 1 });
assert.dom('.zap-sender-name').hasText('Alice');
assert.dom('.zap-action').includesText('zapped your photo');
assert.dom('.zap-amount').includesText('21 ⚡');
assert.dom('.zap-message').includesText('Great photo!');
});
test('closing the sidebar returns to index', async function (assert) {
await visit('/activity');
assert.strictEqual(currentURL(), '/activity');
await click('.sidebar-header .close-btn');
assert.strictEqual(currentURL(), '/', 'Returns to index');
});
test('clicking back button returns to menu', async function (assert) {
await visit('/activity');
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/menu', 'Returns to menu');
});
test('clicking a zap item navigates to the place', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
await visit('/activity');
await waitFor('.activity-zap-item');
await click('.activity-zap-item');
assert.ok(
currentURL().includes('/place/osm:node:123'),
'Transitions to place details'
);
assert.deepEqual(
mapUi.returnToRoute,
{ name: 'activity' },
'returnToRoute is set to activity'
);
});
});
@@ -0,0 +1,176 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import ActivityTimeline from 'marco/components/activity-timeline';
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
function noop() {}
module('Integration | Component | activity-timeline', function (hooks) {
setupRenderingTest(hooks);
setupNostrMocks(hooks);
hooks.beforeEach(function () {
this.noop = noop;
this.emptyItems = [];
});
test('it renders a loading state', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{true}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.sidebar-loading').exists();
assert.dom('.activity-list').doesNotExist();
});
test('it renders a not-connected state', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.empty-state').includesText('Connect your Nostr account');
});
test('clicking "Connect your Nostr account" opens the Nostr connect modal', async function (assert) {
await render(
<template>
<div id="modal-portal"></div>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.nostr-connect-modal').doesNotExist();
await click('.empty-state a');
assert.dom('.nostr-connect-modal').exists();
});
test('it renders an empty state when connected but no activity', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.empty-state').includesText('No activity yet');
});
test('it renders activity zap items', async function (assert) {
this.items = [
{
type: 'zap',
photoEventId: 'photo-1',
photo: {
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
},
placeIdentifier: 'osm:node:111',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: 'Nice!',
createdAt: 2000,
senderName: 'Alice',
senderAvatar: 'https://x.com/avatar.jpg',
senderProfileLoading: false,
},
{
type: 'zap',
photoEventId: 'photo-2',
photo: {
url: 'https://x.com/photo2.jpg',
thumbUrl: 'https://x.com/thumb2.jpg',
},
placeIdentifier: 'osm:node:222',
senderPubkey: 'c'.repeat(64),
amountSats: 100,
message: null,
createdAt: 1000,
senderName: 'Bob',
senderAvatar: null,
senderProfileLoading: false,
},
];
await render(
<template>
<ActivityTimeline
@items={{this.items}}
@isLoading={{false}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.activity-zap-item').exists({ count: 2 });
assert.dom(this.element).includesText('Alice');
assert.dom(this.element).includesText('21 ⚡');
assert.dom(this.element).includesText('Bob');
assert.dom(this.element).includesText('100 ⚡');
});
test('clicking the back button fires @onBack', async function (assert) {
let backClicked = false;
this.handleBack = () => {
backClicked = true;
};
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@onSelect={{this.noop}}
@onBack={{this.handleBack}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
await click('.sidebar-header .back-btn');
assert.true(backClicked);
});
});
@@ -0,0 +1,296 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import ActivityZapItem from 'marco/components/activity-zap-item';
import { ActivityEntry } from 'marco/utils/activity';
function noop() {}
module('Integration | Component | activity-zap-item', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.noop = noop;
});
test('it renders sender name, action, amount, and message', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: {
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
},
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: 'Great shot!',
createdAt: Math.floor(Date.now() / 1000) - 3600,
});
this.item.senderName = 'Alice';
this.item.senderAvatar = 'https://x.com/avatar.jpg';
this.item.senderProfileLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.zap-sender-name').hasText('Alice');
assert.dom('.zap-action').includesText('zapped your photo');
assert.dom('.zap-amount').hasText('21 ⚡');
assert.dom('.zap-message').includesText('Great shot!');
assert
.dom('.zap-sender-avatar')
.hasAttribute('src', 'https://x.com/avatar.jpg');
assert
.dom('.zap-context-thumb img')
.hasAttribute('src', 'https://x.com/thumb.jpg');
});
test('it shows avatar placeholder when no avatar', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 100,
message: null,
createdAt: 1000,
});
this.item.senderName = 'Bob';
this.item.senderAvatar = null;
this.item.senderProfileLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.zap-sender-avatar-placeholder').exists();
assert.dom('.zap-sender-avatar').doesNotExist();
});
test('it omits the message line when there is no message', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 50,
message: null,
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.zap-message').doesNotExist();
});
test('it omits the context thumbnail when there is no photo', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 50,
message: 'Hi',
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.zap-context-thumb').doesNotExist();
assert.dom('.zap-context-text').exists();
});
test('clicking the item fires @onSelect with the item', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: 'Test',
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
let selected = null;
this.handleSelect = (item) => {
selected = item;
};
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.handleSelect}} />
</template>
);
await click('.activity-zap-item');
assert.strictEqual(selected, this.item);
});
test('it shows resolved place name when placeName is set', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: null,
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
this.item.placeName = 'Café Central';
this.item.placeNameLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert
.dom('.zap-context-text')
.includesText('Café Central', 'Resolved place name is displayed');
assert
.dom('.contribution-name-loading')
.doesNotExist('No loading/fallback text shown');
});
test('it shows "Loading…" when placeNameLoading is true', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: null,
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
this.item.placeName = null;
this.item.placeNameLoading = true;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert
.dom('.zap-context-text .contribution-name-loading')
.hasText('Loading…', 'Loading state is displayed');
});
test('it shows "Unnamed place" when not loading and no placeName', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: null,
createdAt: 1000,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
this.item.placeName = null;
this.item.placeNameLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert
.dom('.zap-context-text .contribution-name-loading')
.hasText('Unnamed place', 'Fallback name is displayed');
});
test('it displays place name in zap-place-name and date in zap-date', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: null,
createdAt: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
this.item.placeName = 'Café Central';
this.item.placeNameLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
const placeNameEl = this.element.querySelector('.zap-place-name');
const dateEl = this.element.querySelector('.zap-date');
assert.ok(placeNameEl, 'zap-place-name element exists');
assert.ok(dateEl, 'zap-date element exists');
assert.ok(
placeNameEl.textContent.includes('Café Central'),
'Place name is in zap-place-name'
);
assert.ok(dateEl.textContent.includes('hr ago'), 'Date contains hr ago');
assert.notOk(
placeNameEl.textContent.includes('hr ago'),
'Place name does not contain date'
);
assert.notOk(
dateEl.textContent.includes('Café Central'),
'Date does not contain place name'
);
});
test('it truncates long place names with ellipsis while keeping date visible', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
placeIdentifier: 'osm:node:12345',
senderPubkey: 'b'.repeat(64),
amountSats: 21,
message: null,
createdAt: Math.floor(Date.now() / 1000) - 3600,
});
this.item.senderName = 'Alice';
this.item.senderProfileLoading = false;
this.item.placeName =
'A Very Long Place Name That Should Be Truncated With Ellipsis';
this.item.placeNameLoading = false;
await render(
<template>
<ActivityZapItem @item={{this.item}} @onSelect={{this.noop}} />
</template>
);
assert.dom('.zap-place-name').exists('zap-place-name element exists');
assert.dom('.zap-date').hasText('1 hr ago', 'Date is still visible');
});
});
@@ -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,105 @@ 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();
assert.dom('.tooltip', document.body).exists('tooltip appears on hover');
assert.dom('.tooltip', document.body).hasText('Remove relay');
removeBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
await settled();
assert
.dom('.tooltip', document.body)
.doesNotExist('tooltip removed on mouseleave');
});
});
@@ -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) {
+273
View File
@@ -0,0 +1,273 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { parseZapReceipt } from 'marco/utils/activity';
import {
setVerifyWrappedEventMethod,
fakeVerifyEvent,
} from 'applesauce-core/helpers/event';
setVerifyWrappedEventMethod(fakeVerifyEvent);
const USER_PUBKEY = 'a'.repeat(64);
const SENDER_PUBKEY = 'b'.repeat(64);
const PHOTO_EVENT_ID_1 = '1'.repeat(64);
const PHOTO_EVENT_ID_2 = '2'.repeat(64);
const PHOTO_EVENT_ID_OTHER = '3'.repeat(64);
const TEXT_EVENT_ID = '4'.repeat(64);
const RECEIPT_ID_1 = '5'.repeat(64);
const RECEIPT_ID_2 = '6'.repeat(64);
const ZAP_REQ_ID = '7'.repeat(64);
const BOLT11_INVOICE =
'lnbc20u1p3y0x3hpp5743k2g0fsqqxj7n8qzuhns5gmkk4djeejk3wkp64ppevgekvc0jsdqcve5kzar2v9nr5gpqd4hkuetesp5ez2g297jduwc20t6lmqlsg3man0vf2jfd8ar9fh8fhn2g8yttfkqxqy9gcqcqzys9qrsgqrzjqtx3k77yrrav9hye7zar2rtqlfkytl094dsp0ms5majzth6gt7ca6uhdkxl983uywgqqqqlgqqqvx5qqjqrzjqd98kxkpyw0l9tyy8r8q57k7zpy9zjmh6sez752wj6gcumqnj3yxzhdsmg6qq56utgqqqqqqqqqqqeqqjq7jd56882gtxhrjm03c93aacyfy306m4fq0tskf83c0nmet8zc2lxyyg3saz8x6vwcp26xnrlagf9semau3qm2glysp7sv95693fphvsp54l567';
function makeZapReceiptEvent(opts = {}) {
const recipient = opts.recipient || USER_PUBKEY;
const sender = opts.sender || SENDER_PUBKEY;
const zappedEventId = opts.zappedEventId || PHOTO_EVENT_ID_1;
const description = JSON.stringify({
kind: 9734,
pubkey: sender,
content: opts.message || '',
id: ZAP_REQ_ID,
created_at: 10000,
sig: 'fake',
tags: [
['p', recipient],
['relays', ['wss://relay.example.com']],
],
});
return {
id: opts.id || RECEIPT_ID_1,
kind: 9735,
pubkey: 'zap-service-pubkey',
created_at: opts.created_at || 10000,
tags: [
['p', recipient],
['P', sender],
['e', zappedEventId],
['bolt11', BOLT11_INVOICE],
['description', description],
],
content: '',
sig: 'sig',
};
}
function makePhotoEvent(opts = {}) {
return {
id: opts.id || PHOTO_EVENT_ID_1,
pubkey: opts.author || USER_PUBKEY,
kind: 360,
created_at: 5000,
tags: [
['i', opts.placeIdentifier || 'osm:node:123'],
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
],
content: '',
sig: 'sig',
};
}
class MockNostrDataService extends Service {
@tracked profiles = {};
store = {
events: new Map(),
add(event) {
this.events.set(event.id, event);
},
getEvent(id) {
return this.events.get(id);
},
getReplaceable(kind, pubkey) {
// Find a replaceable event (kind 0 profile) by pubkey
for (const event of this.events.values()) {
if (event.kind === kind && event.pubkey === pubkey) {
return event;
}
}
return undefined;
},
timeline() {
return {
subscribe(callback) {
callback([]);
return { unsubscribe() {} };
},
};
},
model() {
return {
subscribe() {
return { unsubscribe() {} };
},
};
},
};
loadProfiles() {}
getProfile(pubkey) {
return this.profiles[pubkey];
}
async loadIncomingZaps() {}
}
module('Unit | Service | activity', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:nostrData', MockNostrDataService);
});
test('_updateItems parses receipts and enriches with photos from the store', function (assert) {
const service = this.owner.lookup('service:activity');
const photoEvent = makePhotoEvent({
id: PHOTO_EVENT_ID_1,
placeIdentifier: 'osm:node:42',
});
service.nostrData.store.add(photoEvent);
const receipt = makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_1,
message: 'Love it!',
});
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 1);
assert.strictEqual(service.items[0].type, 'zap');
assert.strictEqual(service.items[0].senderPubkey, SENDER_PUBKEY);
assert.strictEqual(service.items[0].amountSats, 2000);
assert.strictEqual(service.items[0].message, 'Love it!');
assert.strictEqual(service.items[0].placeIdentifier, 'osm:node:42');
assert.ok(service.items[0].photo, 'photo is populated');
});
test('_updateItems filters out zaps for non-photo events', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add({
id: TEXT_EVENT_ID,
pubkey: USER_PUBKEY,
kind: 1,
created_at: 5000,
tags: [],
});
const receipt = makeZapReceiptEvent({ zappedEventId: TEXT_EVENT_ID });
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 0, 'non-photo zap filtered out');
});
test('_updateItems filters out zaps for photos not authored by the user', function (assert) {
const service = this.owner.lookup('service:activity');
const otherUserPhoto = makePhotoEvent({
id: PHOTO_EVENT_ID_OTHER,
author: 'z'.repeat(64),
});
service.nostrData.store.add(otherUserPhoto);
const receipt = makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_OTHER,
});
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(
service.items.length,
0,
"zap for someone else's photo filtered out"
);
});
test('_updateItems filters out zaps not directed at the user', function (assert) {
const service = this.owner.lookup('service:activity');
const photoEvent = makePhotoEvent({ id: PHOTO_EVENT_ID_1 });
service.nostrData.store.add(photoEvent);
const receipt = makeZapReceiptEvent({
recipient: 'c'.repeat(64),
zappedEventId: PHOTO_EVENT_ID_1,
});
service._updateItems([receipt], USER_PUBKEY);
assert.strictEqual(
service.items.length,
0,
'zap directed at someone else filtered out'
);
});
test('_updateItems sorts entries newest-first', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 }));
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_2 }));
const oldReceipt = makeZapReceiptEvent({
id: RECEIPT_ID_1,
zappedEventId: PHOTO_EVENT_ID_1,
created_at: 1000,
});
const newReceipt = makeZapReceiptEvent({
id: RECEIPT_ID_2,
zappedEventId: PHOTO_EVENT_ID_2,
created_at: 9000,
});
service._updateItems([oldReceipt, newReceipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 2);
assert.strictEqual(service.items[0].createdAt, 9000, 'newest first');
assert.strictEqual(service.items[1].createdAt, 1000, 'oldest second');
});
test('stop clears items and resets state', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 }));
service._updateItems(
[makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID_1 })],
USER_PUBKEY
);
assert.strictEqual(service.items.length, 1);
service.stop();
assert.strictEqual(service.items.length, 0, 'items cleared');
assert.strictEqual(service._userPubkey, null);
});
test('load with no pubkey clears items', async function (assert) {
const service = this.owner.lookup('service:activity');
service.items = [{ fake: true }];
await service.load(null);
assert.strictEqual(service.items.length, 0);
});
test('_resolveSender applies profile when available', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.profiles[SENDER_PUBKEY] = {
name: 'Alice',
picture: 'https://x.com/avatar.jpg',
};
const entry = parseZapReceipt(makeZapReceiptEvent(), USER_PUBKEY);
service._resolveSender(entry);
assert.strictEqual(entry.senderName, 'Alice');
assert.strictEqual(entry.senderAvatar, 'https://x.com/avatar.jpg');
assert.false(entry.senderProfileLoading);
});
});
+27 -188
View File
@@ -51,12 +51,6 @@ class MockOsmService extends Service {
}
}
const NAME_CACHE_STORE = 'contributions-name-cache';
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve, 50));
}
module('Unit | Service | contributions', function (hooks) {
setupTest(hooks);
@@ -66,212 +60,57 @@ module('Unit | Service | contributions', function (hooks) {
this.owner.register('service:osm', MockOsmService);
});
test('_updateItems resolves names from bookmarks immediately', function (assert) {
test('_updateItems groups events into entries and resolves bookmark names', function (assert) {
const events = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
const service = this.owner.lookup('service:contributions');
service.storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
service.storage.findPlaceById = (id) =>
const storage = this.owner.lookup('service:storage');
storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
storage.findPlaceById = (id) =>
id === '123' ? { id: '123', title: 'Bookmarked Café' } : null;
service._updateItems(events);
assert.strictEqual(service.items.length, 1);
assert.false(service.items[0].placeNameLoading, 'Not loading');
assert.strictEqual(
service.items[0].placeName,
'Bookmarked Café',
'Resolved from bookmark'
);
});
test('_updateItems resolves names from the persistent name cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:999',
}),
];
const service = this.owner.lookup('service:contributions');
await service.localForage.set(
NAME_CACHE_STORE,
'osm:node:999',
'Cached Park'
);
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items[0].placeName, 'Cached Park');
assert.false(service.items[0].placeNameLoading);
assert.strictEqual(service.items[0].placeName, 'Bookmarked Café');
});
test('resolved names are persisted to the IndexedDB name cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:42',
}),
];
test('stop clears items and resets resolver state', async function (assert) {
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => {
const map = new Map();
map.set('node:42', { title: 'Test Place' });
return map;
};
const events = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items.length, 1);
service.stop();
assert.strictEqual(service.items.length, 0, 'items cleared');
const resolver = this.owner.lookup('service:place-name-resolver');
assert.strictEqual(
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:42'),
'Test Place',
'Name is persisted to the IndexedDB name cache'
resolver._lastBatchSignature,
'',
'resolver state reset'
);
});
test('names stored in a previous session are resolved from the IndexedDB cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:77',
}),
];
test('multiple _updateItems calls re-group events correctly', function (assert) {
const service = this.owner.lookup('service:contributions');
await service.localForage.set(
NAME_CACHE_STORE,
'osm:node:77',
'Persisted Place'
);
const events1 = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
const events2 = [makePhotoEvent({ id: 'e2', created_at: 2000 })];
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items[0].placeName, 'Persisted Place');
assert.false(service.items[0].placeNameLoading);
});
test('batch fetch failure applies a fallback name so items do not stay loading', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:555',
}),
];
const service = this.owner.lookup('service:contributions');
// OSM batch returns empty (simulating all-failed fetch)
service.osm.fetchOsmObjectsBatch = async () => new Map();
service._updateItems(events);
// Wait for the background batch to complete
await flushPromises();
assert.false(
service.items[0].placeNameLoading,
'Item is no longer loading after batch failure'
);
service._updateItems(events1);
assert.strictEqual(
service.items[0].placeName,
'OSM node 555',
'Fallback name is applied'
service.items.length,
1,
'first update creates one entry'
);
});
test('fallback names are NOT persisted to the name cache (so they can be re-fetched next session)', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:666',
}),
];
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => new Map();
service._updateItems(events);
await flushPromises();
// Fallback should be shown but NOT cached
assert.strictEqual(service.items[0].placeName, 'OSM node 666');
assert.notOk(
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:666'),
'No fallback name persisted in the IndexedDB name cache'
);
assert.true(
service._unresolvable.has('osm:node:666'),
'Item is marked unresolvable for this session'
);
});
test('unresolvable items are not re-fetched within the same session', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:777',
}),
];
let fetchCount = 0;
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
return new Map();
};
// First update triggers a fetch
service._updateItems(events);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'First update triggers a fetch');
assert.strictEqual(service.items[0].placeName, 'OSM node 777');
// Second update should NOT trigger another fetch (already unresolvable)
service._updateItems(events);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'Second update does not re-fetch');
service._updateItems(events2);
assert.strictEqual(
service.items[0].placeName,
'OSM node 777',
'Fallback is shown immediately'
);
});
test('successful batch resolution stores names in the name cache', async function (assert) {
const events = [
makePhotoEvent({
id: 'e1',
created_at: 1000,
placeIdentifier: 'osm:node:111',
}),
];
const service = this.owner.lookup('service:contributions');
service.osm.fetchOsmObjectsBatch = async () => {
const map = new Map();
map.set('node:111', { title: 'Resolved Café' });
return map;
};
service._updateItems(events);
await flushPromises();
assert.strictEqual(service.items[0].placeName, 'Resolved Café');
assert.strictEqual(
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:111'),
'Resolved Café',
'Name is stored in the name cache'
service.items.length,
1,
'second update replaces with new group'
);
});
});
+912
View File
@@ -0,0 +1,912 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import { Subject, EMPTY } from 'rxjs';
import Service from '@ember/service';
import NostrDataService from 'marco/services/nostr-data';
import { getGeohashPrefixesInBbox } from 'marco/utils/geohash-coverage';
function makePubkey(n) {
return n.toString(16).padStart(64, '0');
}
function makeEventId(n) {
return `e${n.toString(16).padStart(63, '0')}`;
}
function makeContactsEvent(pubkey, contactPubkeys, opts = {}) {
const id = opts.id || makeEventId(1);
const createdAt = opts.created_at || 1000;
return {
id,
pubkey,
kind: 3,
created_at: createdAt,
tags: contactPubkeys.map((pk) => ['p', pk, 'wss://relay.example']),
content: '',
sig: 'sig',
};
}
function makePhotoEvent(pubkey, placeId, opts = {}) {
const id = opts.id || makeEventId(100);
const createdAt = opts.created_at || 2000;
return {
id,
pubkey,
kind: 360,
created_at: createdAt,
tags: [
['i', placeId],
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
],
content: '',
sig: 'sig',
};
}
function makePhotoEventWithGeohash(pubkey, placeId, geohash, opts = {}) {
const event = makePhotoEvent(pubkey, placeId, opts);
event.tags.push(['g', geohash]);
return event;
}
function makeDeletionEvent(pubkey, eventIds, opts = {}) {
const id = opts.id || makeEventId(50);
return {
id,
pubkey,
kind: 5,
created_at: opts.created_at || 5000,
tags: eventIds.map((eid) => ['e', eid]),
content: '',
sig: 'sig',
};
}
function makeZapReceiptEvent(pubkey, photoEventIds, opts = {}) {
const id = opts.id || makeEventId(600);
return {
id,
pubkey,
kind: 9735,
created_at: opts.created_at || 6000,
tags: photoEventIds.map((eid) => ['e', eid]),
content: '',
sig: 'sig',
};
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function setupNostrDataService(hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
const requestedFilters = [];
const reqCalls = [];
const reqMessages = new Subject();
class StubNostrRelayService extends Service {
pool = {
relays$: new Subject(),
request: (_relays, filters) => {
requestedFilters.push(...filters);
return EMPTY;
},
req: (_relays, filters) => {
reqCalls.push(true);
requestedFilters.push(...filters);
return reqMessages;
},
publish: () => Promise.resolve([{ ok: true }]),
};
}
this.owner.register('service:nostrRelay', StubNostrRelayService);
this.owner.register('service:nostrData', NostrDataService);
this.requestedFilters = requestedFilters;
this.reqCalls = reqCalls;
this.reqMessages = reqMessages;
const service = this.owner.lookup('service:nostr-data');
service.store.verifyEvent = undefined;
});
hooks.afterEach(async function () {
const service = this.owner.lookup('service:nostr-data');
await service.clearCache();
await service.localForage.clear('event-relay-provenance');
});
}
module('Unit | Service | nostr-data | contacts', function (hooks) {
setupNostrDataService(hooks);
test('loadProfile populates contacts from store via ContactsModel', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pubkey = makePubkey(1);
const contactA = makePubkey(2);
const contactB = makePubkey(3);
service.store.add(makeContactsEvent(pubkey, [contactA, contactB]));
await service.loadProfile(pubkey);
assert.ok(service.contacts, 'contacts is populated');
assert.strictEqual(service.contacts.length, 2, 'two contacts');
const pubkeys = service.contacts.map((c) => c.pubkey).sort();
assert.deepEqual(
pubkeys,
[contactA, contactB].sort(),
'contact pubkeys match'
);
});
test('loadProfile tears down previous contacts subscription when called with a different pubkey', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pubkeyA = makePubkey(1);
const pubkeyB = makePubkey(4);
const contactA = makePubkey(2);
service.store.add(makeContactsEvent(pubkeyA, [contactA]));
await service.loadProfile(pubkeyA);
assert.strictEqual(
service.contacts.length,
1,
'contacts populated for pubkeyA'
);
// Switch to a different pubkey — this should tear down pubkeyA's subscription
await service.loadProfile(pubkeyB);
assert.deepEqual(
service.contacts,
[],
'contacts is empty for pubkeyB (no kind 3 event)'
);
// Add a new contacts event for pubkeyA after the switch
const newerEvent = makeContactsEvent(pubkeyA, [makePubkey(9)], {
created_at: 2000,
});
service.store.add(newerEvent);
// Give the subscriptions a tick to propagate
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(
service.contacts,
[],
'contacts remains empty — old subscription was torn down'
);
});
test('loadProfile network request includes kind 3', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pubkey = makePubkey(1);
await service.loadProfile(pubkey);
const kindsFilter = this.requestedFilters.find((f) =>
f.authors?.includes(pubkey)
);
assert.ok(kindsFilter, 'filter with authors was requested');
assert.ok(kindsFilter.kinds.includes(3), 'kinds includes 3 (contacts)');
assert.deepEqual(
kindsFilter.kinds.sort(),
[0, 3, 10002, 10063].sort(),
'kinds match expected set'
);
});
test('kind 3 events are persisted to IDB cache', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
// Wait for the IDB cache to be ready before adding events
await service._cachePromise;
const pubkey = makePubkey(1);
const contactA = makePubkey(2);
const event = makeContactsEvent(pubkey, [contactA], { id: makeEventId(5) });
service.store.add(event);
// Wait for the persistEventsToCache batch (1s) plus some margin
await new Promise((r) => setTimeout(r, 1500));
const cached = await service.cache.query([
{ kinds: [3], authors: [pubkey] },
]);
assert.strictEqual(cached.length, 1, 'kind 3 event is in IDB cache');
assert.strictEqual(cached[0].id, event.id, 'cached event id matches');
});
});
module('Unit | Service | nostr-data | relay configuration', function (hooks) {
setupNostrDataService(hooks);
test('mailboxReadRelays normalizes mailbox inbox URLs', function (assert) {
const service = this.owner.lookup('service:nostr-data');
assert.deepEqual(service.mailboxReadRelays, [], 'empty when no mailboxes');
service.mailboxes = {
inboxes: ['WSS://Relay.Example.COM/', 'relay.two.example'],
outboxes: [],
};
assert.deepEqual(
service.mailboxReadRelays,
['wss://relay.example.com', 'wss://relay.two.example'],
'normalizes and filters invalid URLs'
);
});
test('mailboxWriteRelays returns empty array without mailboxes', function (assert) {
const service = this.owner.lookup('service:nostr-data');
assert.deepEqual(service.mailboxWriteRelays, [], 'empty when no mailboxes');
service.mailboxes = {
inboxes: [],
outboxes: ['WSS://Outbox.Example.COM/'],
};
assert.deepEqual(
service.mailboxWriteRelays,
['wss://outbox.example.com'],
'normalizes outbox URLs'
);
});
test('configuredReadRelays merges mailbox and custom relays with dedupe and exclusions', function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.mailboxes = { inboxes: ['wss://a.example'], outboxes: [] };
service.settings.nostrReadRelays = ['wss://a.example/', 'wss://b.example'];
assert.deepEqual(
service.configuredReadRelays,
['wss://a.example', 'wss://b.example'],
'merges and deduplicates (normalized)'
);
service.settings.nostrReadRelayExclusions = ['wss://a.example'];
assert.deepEqual(
service.configuredReadRelays,
['wss://b.example'],
'exclusions remove mailbox relays'
);
});
test('configuredWriteRelays merges mailbox outboxes with custom write relays', function (assert) {
const service = this.owner.lookup('service:nostr-data');
service.mailboxes = { inboxes: [], outboxes: ['wss://out.example'] };
service.settings.nostrWriteRelays = ['wss://custom.example'];
assert.deepEqual(
service.configuredWriteRelays,
['wss://out.example', 'wss://custom.example'],
'merges mailbox and custom write relays'
);
service.settings.nostrWriteRelayExclusions = ['wss://out.example'];
assert.deepEqual(
service.configuredWriteRelays,
['wss://custom.example'],
'exclusions apply to write relays'
);
});
test('activeReadRelays puts required relays first and appends custom', function (assert) {
const service = this.owner.lookup('service:nostr-data');
assert.deepEqual(
service.activeReadRelays,
['wss://nostr.kosmos.org'],
'default is required only'
);
service.settings.nostrReadRelays = ['wss://custom.example'];
assert.deepEqual(
service.activeReadRelays,
['wss://nostr.kosmos.org', 'wss://custom.example'],
'required first, custom appended'
);
});
test('activeWriteRelays returns empty when nothing configured', function (assert) {
const service = this.owner.lookup('service:nostr-data');
assert.deepEqual(
service.activeWriteRelays,
[],
'no required write relays by default'
);
});
test('trustedRelays includes required read relays plus user-marked trusted relays', function (assert) {
const service = this.owner.lookup('service:nostr-data');
assert.deepEqual(
service.trustedRelays,
['wss://nostr.kosmos.org'],
'default is required read relays only'
);
service.settings.nostrTrustedRelays = ['wss://custom.example'];
assert.deepEqual(
service.trustedRelays,
['wss://nostr.kosmos.org', 'wss://custom.example'],
'merges custom trusted relays'
);
});
});
module('Unit | Service | nostr-data | provenance', function (hooks) {
setupNostrDataService(hooks);
test('_recordProvenance accumulates relays for an event and persists them', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const eventId = makeEventId(900);
service._recordProvenance(eventId, 'wss://one.example');
service._recordProvenance(eventId, 'wss://two.example');
service._recordProvenance(eventId, 'wss://one.example'); // duplicate
const relays = service._eventRelays.get(eventId);
assert.strictEqual(relays.size, 2, 'accumulates unique relays');
assert.true(relays.has('wss://one.example'), 'has first relay');
assert.true(relays.has('wss://two.example'), 'has second relay');
const persisted = await service.localForage.get(
'event-relay-provenance',
eventId
);
assert.deepEqual(
persisted.sort(),
['wss://one.example', 'wss://two.example'],
'persists to localForage'
);
});
test('kind 5 deletion events remove provenance for referenced events', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const deadId = makeEventId(901);
const author = makePubkey(50);
service._recordProvenance(deadId, 'wss://one.example');
// The store routes kind-5 events to its DeleteManager, which emits
// deleted$ synchronously from add() — provenance is dropped immediately.
service.store.add(makeDeletionEvent(author, [deadId]));
assert.false(
service._eventRelays.has(deadId),
'provenance removed from memory'
);
const persisted = await service.localForage.get(
'event-relay-provenance',
deadId
);
assert.strictEqual(persisted, null, 'provenance removed from localForage');
});
test('_hydrateProvenance restores persisted provenance', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const eventId = makeEventId(902);
await service.localForage.set('event-relay-provenance', eventId, [
'wss://x.example',
]);
await service._hydrateProvenance();
const relays = service._eventRelays.get(eventId);
assert.ok(relays, 'provenance restored');
assert.true(relays.has('wss://x.example'), 'contains the relay');
});
test('_requestContentWithProvenance records provenance and adds events to the store', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const eventId = makeEventId(903);
const pubkey = makePubkey(60);
service._requestContentWithProvenance(
['wss://relay.test'],
[{ kinds: [360] }],
'test'
);
const photoEvent = makePhotoEvent(pubkey, 'osm:node:1', { id: eventId });
this.reqMessages.next({
type: 'EVENT',
event: photoEvent,
from: 'wss://relay.test',
});
await wait(50);
const relays = service._eventRelays.get(eventId);
assert.ok(relays, 'provenance recorded');
assert.true(
relays.has('wss://relay.test'),
'contains the relay the event came from'
);
assert.true(service.store.hasEvent(eventId), 'event added to store');
});
});
module('Unit | Service | nostr-data | trust evaluation', function (hooks) {
setupNostrDataService(hooks);
test('isTrustedEvent trusts content from followed contacts', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
const followedPubkey = makePubkey(2);
// User follows followedPubkey
service.store.add(makeContactsEvent(userPubkey, [followedPubkey]));
await service.loadProfile(userPubkey);
// Photo from followed contact (no trusted relay provenance)
const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:123', {
id: makeEventId(200),
});
service.store.add(photoEvent);
assert.true(
service.isTrustedEvent(photoEvent),
'photo from followed contact is trusted'
);
});
test('isTrustedEvent does not trust content from unfollowed pubkeys', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
const followedPubkey = makePubkey(2);
const unfollowedPubkey = makePubkey(3);
// User follows followedPubkey (but NOT unfollowedPubkey)
service.store.add(makeContactsEvent(userPubkey, [followedPubkey]));
await service.loadProfile(userPubkey);
// Photo from unfollowed pubkey (no trusted relay provenance)
const photoEvent = makePhotoEvent(unfollowedPubkey, 'osm:node:123', {
id: makeEventId(201),
});
service.store.add(photoEvent);
assert.false(
service.isTrustedEvent(photoEvent),
'photo from unfollowed pubkey is not trusted'
);
});
test('isTrustedEvent still trusts own content (regression)', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
service.nostrAuth = { pubkey: userPubkey };
await service.loadProfile(userPubkey);
// Photo from the user themselves
const photoEvent = makePhotoEvent(userPubkey, 'osm:node:123', {
id: makeEventId(202),
});
service.store.add(photoEvent);
assert.true(service.isTrustedEvent(photoEvent), 'own photo is trusted');
});
test('isTrustedEvent still trusts content from trusted relays (regression)', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
const randomPubkey = makePubkey(99);
await service.loadProfile(userPubkey);
// Photo from random pubkey
const photoEvent = makePhotoEvent(randomPubkey, 'osm:node:123', {
id: makeEventId(203),
});
service.store.add(photoEvent);
// Simulate provenance: photo was seen on a trusted relay
const trustedRelay = 'wss://nostr.kosmos.org';
service._recordProvenance(photoEvent.id, trustedRelay);
assert.true(
service.isTrustedEvent(photoEvent),
'photo from trusted relay is trusted'
);
});
test('isTrustedEvent re-evaluates when contacts change', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
const followedPubkey = makePubkey(2);
// Load profile first (no contacts yet)
await service.loadProfile(userPubkey);
// Create a photo from followedPubkey (who is not yet followed)
const photoEvent = makePhotoEvent(followedPubkey, 'osm:node:456', {
id: makeEventId(204),
});
// Photo should be untrusted initially (no contacts loaded yet)
assert.false(
service.isTrustedEvent(photoEvent),
'photo is untrusted before contacts load'
);
// Now load contacts (user follows followedPubkey)
service.store.add(
makeContactsEvent(userPubkey, [followedPubkey], {
id: makeEventId(300),
created_at: 3000,
})
);
// Give the contacts subscription a tick to propagate
await new Promise((r) => setTimeout(r, 50));
// Photo should now be trusted (contacts loaded)
assert.true(
service.isTrustedEvent(photoEvent),
'photo is trusted after contacts load'
);
});
test('partitionByTrust always passes kind 5 deletions through as trusted', function (assert) {
const service = this.owner.lookup('service:nostr-data');
const untrustedPk = makePubkey(70);
const authorPk = makePubkey(71);
const untrustedPhoto = makePhotoEvent(untrustedPk, 'osm:node:1', {
id: makeEventId(904),
});
const deletion = makeDeletionEvent(authorPk, [untrustedPhoto.id], {
id: makeEventId(905),
});
const { trusted, untrusted } = service.partitionByTrust([
untrustedPhoto,
deletion,
]);
assert.strictEqual(trusted.length, 1, 'one trusted event');
assert.strictEqual(trusted[0].kind, 5, 'deletion is trusted');
assert.strictEqual(untrusted.length, 1, 'one untrusted event');
assert.strictEqual(untrusted[0].kind, 360, 'photo is untrusted');
});
});
module('Unit | Service | nostr-data | geohash loading', function (hooks) {
setupNostrDataService(hooks);
const BERLIN_BBOX = {
minLat: 52.5,
minLon: 13.4,
maxLat: 52.55,
maxLon: 13.45,
};
test('requests kind 360 events for missing geohash prefixes and marks them loaded', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const expected = getGeohashPrefixesInBbox(BERLIN_BBOX);
assert.ok(expected.length > 0, 'bbox produces prefixes');
await service.loadPlacesInBounds(BERLIN_BBOX);
assert.strictEqual(this.reqCalls.length, 1, 'one network request made');
const filter = this.requestedFilters.find((f) => f['#g']);
assert.ok(filter, 'geohash filter was requested');
assert.deepEqual(filter.kinds, [360], 'requests kind 360');
assert.deepEqual(
filter['#g'].sort(),
expected.sort(),
'covers all prefixes'
);
for (const p of expected) {
assert.true(
service.loadedGeohashPrefixes.has(p),
`prefix ${p} marked loaded`
);
}
});
test('skips prefixes that were already loaded', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
await service.loadPlacesInBounds(BERLIN_BBOX);
const firstReqCalls = this.reqCalls.length;
await service.loadPlacesInBounds(BERLIN_BBOX);
assert.strictEqual(
this.reqCalls.length,
firstReqCalls,
'no additional network request'
);
});
test('hydrates matching cached photos into the store', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
await service._cachePromise;
const pk = makePubkey(80);
const geohash = getGeohashPrefixesInBbox(BERLIN_BBOX)[0];
const cached = makePhotoEventWithGeohash(pk, 'osm:node:700', geohash, {
id: makeEventId(700),
created_at: 4000,
});
await service.cache.add(cached);
await service.loadPlacesInBounds(BERLIN_BBOX);
assert.true(
service.store.hasEvent(cached.id),
'cached photo added to store'
);
});
});
module('Unit | Service | nostr-data | place photos', function (hooks) {
setupNostrDataService(hooks);
test('sets entity id and streams photos for the place', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pk = makePubkey(81);
const photoId = makeEventId(800);
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
assert.strictEqual(
service._currentPlaceEntityId,
'osm:node:800',
'entity id set'
);
service._recordProvenance(photoId, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: photoId }));
assert.strictEqual(service.placePhotos.length, 1, 'photo visible');
});
test('calling with the same place twice does not tear down subscriptions', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
const firstSub = service._photosSub;
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
assert.strictEqual(
service._photosSub,
firstSub,
'same subscription retained'
);
});
test('switching places resets state and re-subscribes', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pk = makePubkey(82);
const idA = makeEventId(801);
const idB = makeEventId(802);
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
service._recordProvenance(idA, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: idA }));
assert.strictEqual(service.placePhotos.length, 1, 'place 800 has photo');
await service.loadPhotosForPlace({ osmId: '801', osmType: 'node' });
assert.deepEqual(service.placePhotos, [], 'photos cleared');
assert.strictEqual(
service._currentPlaceEntityId,
'osm:node:801',
'entity id updated'
);
service._recordProvenance(idB, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pk, 'osm:node:801', { id: idB }));
assert.strictEqual(service.placePhotos.length, 1, 'new place has photo');
assert.strictEqual(service.placePhotos[0].id, idB);
});
test('null place clears state and tears down subscription', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pk = makePubkey(83);
const idA = makeEventId(803);
await service.loadPhotosForPlace({ osmId: '800', osmType: 'node' });
service._recordProvenance(idA, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pk, 'osm:node:800', { id: idA }));
assert.strictEqual(service.placePhotos.length, 1);
await service.loadPhotosForPlace(null);
assert.deepEqual(service.placePhotos, [], 'photos cleared');
assert.strictEqual(
service._currentPlaceEntityId,
null,
'entity id cleared'
);
assert.strictEqual(service._photosSub, null, 'subscription torn down');
});
test('placePhotos hides untrusted photos by default and counts them', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pkA = makePubkey(84);
const pkB = makePubkey(85);
const trustedId = makeEventId(900);
const untrustedId = makeEventId(901);
await service.loadPhotosForPlace({ osmId: '900', osmType: 'node' });
service._recordProvenance(trustedId, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pkA, 'osm:node:900', { id: trustedId }));
service.store.add(makePhotoEvent(pkB, 'osm:node:900', { id: untrustedId }));
assert.deepEqual(
service.placePhotos.map((e) => e.id),
[trustedId],
'only trusted shown'
);
assert.strictEqual(service.untrustedContentCount, 1, 'untrusted counted');
});
test('toggleShowUntrustedContent reveals all photos and toggles back', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pkA = makePubkey(86);
const pkB = makePubkey(87);
const trustedId = makeEventId(902);
const untrustedId = makeEventId(903);
await service.loadPhotosForPlace({ osmId: '901', osmType: 'node' });
service._recordProvenance(trustedId, 'wss://nostr.kosmos.org');
service.store.add(makePhotoEvent(pkA, 'osm:node:901', { id: trustedId }));
service.store.add(makePhotoEvent(pkB, 'osm:node:901', { id: untrustedId }));
service.toggleShowUntrustedContent();
assert.strictEqual(service.placePhotos.length, 2, 'all photos shown');
assert.strictEqual(service.untrustedContentCount, 1, 'count unchanged');
service.toggleShowUntrustedContent();
assert.strictEqual(service.placePhotos.length, 1, 'back to trusted only');
});
test('own photos are shown without provenance', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const myPubkey = makePubkey(95);
service.nostrAuth = { pubkey: myPubkey };
const ownId = makeEventId(950);
await service.loadPhotosForPlace({ osmId: '950', osmType: 'node' });
service.store.add(makePhotoEvent(myPubkey, 'osm:node:950', { id: ownId }));
assert.strictEqual(
service.placePhotos.length,
1,
'own photo shown without provenance'
);
assert.strictEqual(service.untrustedContentCount, 0);
});
});
module('Unit | Service | nostr-data | my contributions', function (hooks) {
setupNostrDataService(hooks);
test('returns early when pubkey is null', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
await service.loadMyContributions(null);
assert.deepEqual(service.myContributionEvents, [], 'no events loaded');
assert.strictEqual(
service._contributionsSub,
null,
'no subscription created'
);
});
test('loads own photos from store into myContributionEvents', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const myPk = makePubkey(60);
const photoId = makeEventId(600);
service.store.add(makePhotoEvent(myPk, 'osm:node:600', { id: photoId }));
await service.loadMyContributions(myPk);
assert.strictEqual(
service.myContributionEvents.length,
1,
'photo in contributions'
);
assert.strictEqual(service.myContributionEvents[0].id, photoId);
});
test('requests own contributions from network with correct filter', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const myPk = makePubkey(61);
await service.loadMyContributions(myPk);
const filter = this.requestedFilters.find((f) => f.authors?.includes(myPk));
assert.ok(filter, 'filter for own pubkey requested');
assert.deepEqual(filter.kinds.sort(), [360, 5].sort(), 'kinds 360 and 5');
});
});
module('Unit | Service | nostr-data | zap receipts', function (hooks) {
setupNostrDataService(hooks);
test('_refreshZapReceiptSubscription batches >100 IDs into multiple filters', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const photoIds = Array.from({ length: 250 }, (_, i) =>
makeEventId(1000 + i)
);
service._refreshZapReceiptSubscription(photoIds);
const filters = this.requestedFilters.filter((f) =>
f.kinds?.includes(9735)
);
assert.strictEqual(filters.length, 3, '250 IDs → 3 filters (100+100+50)');
assert.strictEqual(filters[0]['#e'].length, 100);
assert.strictEqual(filters[1]['#e'].length, 100);
assert.strictEqual(filters[2]['#e'].length, 50);
});
test('_updateZapReceipts groups receipts by photo event id', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const photoA = makeEventId(2000);
const photoB = makeEventId(2001);
const receipt1 = makeZapReceiptEvent(makePubkey(70), [photoA], {
id: makeEventId(2100),
});
const receipt2 = makeZapReceiptEvent(makePubkey(71), [photoA, photoB], {
id: makeEventId(2101),
});
const receipt3 = makeZapReceiptEvent(makePubkey(72), [photoB], {
id: makeEventId(2102),
});
service._updateZapReceipts([receipt1, receipt2, receipt3]);
assert.strictEqual(
service.zapReceipts[photoA].length,
2,
'photoA has 2 receipts'
);
assert.strictEqual(
service.zapReceipts[photoB].length,
2,
'photoB has 2 receipts'
);
assert.deepEqual(
service.zapReceipts[photoA].map((r) => r.id).sort(),
[makeEventId(2100), makeEventId(2101)],
'receipts for photoA match'
);
});
});
+30 -5
View File
@@ -329,7 +329,7 @@ module('Unit | Service | osm', function (hooks) {
);
});
test('fetchOsmObjectsBatch does not write to the general OSM cache', async function (assert) {
test('fetchOsmObjectsBatch writes complete results to the general OSM cache', async function (assert) {
let service = this.owner.lookup('service:osm');
service.fetchWithRetry = async () => ({
@@ -343,13 +343,38 @@ module('Unit | Service | osm', function (hooks) {
await service.fetchOsmObjectsBatch([{ osmType: 'node', osmId: '100' }]);
assert.false(
assert.true(
service.cachedPlaces.has('node:100'),
'Batch fetch does not write to the in-memory OSM cache'
'Batch fetch writes complete results (with lat/lon) to the in-memory OSM cache'
);
assert.ok(
await service.localForage.get('osm-cache', 'node:100'),
'Batch fetch writes complete results to the persistent OSM cache'
);
});
test('fetchOsmObjectsBatch does NOT cache incomplete results without lat/lon', async function (assert) {
let service = this.owner.lookup('service:osm');
// Way without child nodes in the response -> no lat/lon in normalized result
service.fetchWithRetry = async () => ({
ok: true,
json: async () => ({
elements: [
{ id: 200, type: 'way', nodes: [], tags: { name: 'Way 200' } },
],
}),
});
await service.fetchOsmObjectsBatch([{ osmType: 'way', osmId: '200' }]);
assert.false(
service.cachedPlaces.has('way:200'),
'Batch fetch does NOT cache incomplete results (ways without child nodes)'
);
assert.notOk(
await service.localForage.get('osm-cache', 'node:100'),
'Batch fetch does not write to the persistent OSM cache'
await service.localForage.get('osm-cache', 'way:200'),
'Batch fetch does NOT write incomplete results to persistent cache'
);
});
@@ -0,0 +1,210 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import Service from '@ember/service';
function makeEntry(opts = {}) {
const placeIdentifier = opts.placeIdentifier || 'osm:node:123';
const [, osmType, osmId] = placeIdentifier.split(':');
return {
placeIdentifier,
osmType: opts.osmType || osmType,
osmId: opts.osmId || osmId,
placeName: opts.placeName || null,
placeNameLoading: opts.placeNameLoading ?? true,
};
}
class MockStorageService extends Service {
savedPlaces = [];
findPlaceById() {
return null;
}
}
class MockOsmService extends Service {
async getCachedOsmObject() {
return null;
}
async fetchOsmObjectsBatch() {
return new Map();
}
}
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve, 50));
}
module('Unit | Service | place-name-resolver', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:storage', MockStorageService);
this.owner.register('service:osm', MockOsmService);
});
test('resolveBookmark returns bookmark title when found', function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const storage = this.owner.lookup('service:storage');
storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
storage.findPlaceById = (id) =>
id === '123' ? { id: '123', title: 'Bookmarked Café' } : null;
const name = resolver.resolveBookmark('123');
assert.strictEqual(name, 'Bookmarked Café');
});
test('resolveBookmark returns null when no bookmark', function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const name = resolver.resolveBookmark('999');
assert.notOk(name);
});
test('resolveInBackground resolves names from the persistent name cache', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
const entry = makeEntry({ placeIdentifier: 'osm:node:999' });
await localForage.set('place-name-cache', 'osm:node:999', 'Cached Park');
await resolver.resolveInBackground([entry]);
assert.strictEqual(entry.placeName, 'Cached Park');
assert.false(entry.placeNameLoading);
});
test('resolved names are persisted to the IndexedDB name cache', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
const entry = makeEntry({ placeIdentifier: 'osm:node:42' });
resolver.osm.fetchOsmObjectsBatch = async () => {
const map = new Map();
map.set('node:42', { title: 'Test Place' });
return map;
};
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.strictEqual(
await localForage.get('place-name-cache', 'osm:node:42'),
'Test Place',
'Name is persisted to the IndexedDB name cache'
);
});
test('names stored in a previous session are resolved from the IndexedDB cache', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
const entry = makeEntry({ placeIdentifier: 'osm:node:77' });
await localForage.set('place-name-cache', 'osm:node:77', 'Persisted Place');
await resolver.resolveInBackground([entry]);
assert.strictEqual(entry.placeName, 'Persisted Place');
assert.false(entry.placeNameLoading);
});
test('batch fetch failure applies a fallback name so items do not stay loading', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const entry = makeEntry({ placeIdentifier: 'osm:node:555' });
resolver.osm.fetchOsmObjectsBatch = async () => new Map();
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.false(
entry.placeNameLoading,
'Item is no longer loading after batch failure'
);
assert.strictEqual(
entry.placeName,
'OSM node 555',
'Fallback name is applied'
);
});
test('fallback names are NOT persisted to the name cache (so they can be re-fetched next session)', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
const entry = makeEntry({ placeIdentifier: 'osm:node:666' });
resolver.osm.fetchOsmObjectsBatch = async () => new Map();
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.strictEqual(entry.placeName, 'OSM node 666');
assert.notOk(
await localForage.get('place-name-cache', 'osm:node:666'),
'No fallback name persisted in the IndexedDB name cache'
);
assert.true(
resolver._unresolvable.has('osm:node:666'),
'Item is marked unresolvable for this session'
);
});
test('unresolvable items are not re-fetched within the same session', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const entry = makeEntry({ placeIdentifier: 'osm:node:777' });
let fetchCount = 0;
resolver.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
return new Map();
};
// First call triggers a fetch
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'First call triggers a fetch');
assert.strictEqual(entry.placeName, 'OSM node 777');
// Second call should NOT trigger another fetch (already unresolvable)
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'Second call does not re-fetch');
assert.strictEqual(
entry.placeName,
'OSM node 777',
'Fallback is shown immediately'
);
});
test('successful batch resolution stores names in the name cache', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
const entry = makeEntry({ placeIdentifier: 'osm:node:111' });
resolver.osm.fetchOsmObjectsBatch = async () => {
const map = new Map();
map.set('node:111', { title: 'Resolved Café' });
return map;
};
await resolver.resolveInBackground([entry]);
await flushPromises();
assert.strictEqual(entry.placeName, 'Resolved Café');
assert.strictEqual(
await localForage.get('place-name-cache', 'osm:node:111'),
'Resolved Café',
'Name is stored in the name cache'
);
});
test('reset clears per-session state', function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
resolver._unresolvable.add('test-id');
resolver._lastBatchSignature = 'sig';
resolver._pendingBatchPromise = Promise.resolve();
resolver.reset();
assert.strictEqual(resolver._unresolvable.size, 0);
assert.strictEqual(resolver._lastBatchSignature, '');
assert.strictEqual(resolver._pendingBatchPromise, null);
});
});
+211
View File
@@ -0,0 +1,211 @@
import { module, test } from 'qunit';
import {
ActivityEntry,
parseZapReceipt,
enrichWithPhoto,
} from 'marco/utils/activity';
import {
setVerifyWrappedEventMethod,
fakeVerifyEvent,
} from 'applesauce-core/helpers/event';
setVerifyWrappedEventMethod(fakeVerifyEvent);
const USER_PUBKEY = 'a'.repeat(64);
const SENDER_PUBKEY = 'b'.repeat(64);
const PHOTO_EVENT_ID = '1'.repeat(64);
const RECEIPT_ID = '2'.repeat(64);
const ZAP_REQ_ID_1 = '3'.repeat(64);
const ZAP_REQ_ID_2 = '4'.repeat(64);
const PHOTO_XYZ_ID = '5'.repeat(64);
const OTHER_EVENT_ID = '6'.repeat(64);
const BOLT11_INVOICE =
'lnbc20u1p3y0x3hpp5743k2g0fsqqxj7n8qzuhns5gmkk4djeejk3wkp64ppevgekvc0jsdqcve5kzar2v9nr5gpqd4hkuetesp5ez2g297jduwc20t6lmqlsg3man0vf2jfd8ar9fh8fhn2g8yttfkqxqy9gcqcqzys9qrsgqrzjqtx3k77yrrav9hye7zar2rtqlfkytl094dsp0ms5majzth6gt7ca6uhdkxl983uywgqqqqlgqqqvx5qqjqrzjqd98kxkpyw0l9tyy8r8q57k7zpy9zjmh6sez752wj6gcumqnj3yxzhdsmg6qq56utgqqqqqqqqqqqeqqjq7jd56882gtxhrjm03c93aacyfy306m4fq0tskf83c0nmet8zc2lxyyg3saz8x6vwcp26xnrlagf9semau3qm2glysp7sv95693fphvsp54l567';
function makeZapReceiptEvent(opts = {}) {
const recipient = opts.recipient || USER_PUBKEY;
const sender = opts.sender || SENDER_PUBKEY;
const zappedEventId = opts.zappedEventId || PHOTO_EVENT_ID;
const description = JSON.stringify({
kind: 9734,
pubkey: sender,
content: opts.message || '',
id: ZAP_REQ_ID_1,
created_at: 10000,
sig: 'fake',
tags: [
['p', recipient],
['relays', ['wss://relay.example.com']],
],
});
return {
id: opts.id || RECEIPT_ID,
kind: 9735,
pubkey: opts.receiptAuthor || 'zap-service-pubkey',
created_at: opts.created_at || 10000,
tags: [
['p', recipient],
['P', sender],
['e', zappedEventId],
['bolt11', BOLT11_INVOICE],
['description', description],
],
content: '',
sig: 'sig',
};
}
function makePhotoEvent(opts = {}) {
return {
id: opts.id || PHOTO_EVENT_ID,
pubkey: opts.author || USER_PUBKEY,
kind: 360,
created_at: opts.created_at || 5000,
tags: [
['i', opts.placeIdentifier || 'osm:node:123'],
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
],
content: '',
sig: 'sig',
};
}
module('Unit | Utility | activity', function () {
test('ActivityEntry has type "zap" and tracked sender fields', function (assert) {
const entry = new ActivityEntry({
photoEventId: '1'.repeat(64),
photo: null,
placeIdentifier: 'osm:node:1',
senderPubkey: SENDER_PUBKEY,
amountSats: 21,
message: 'Nice!',
createdAt: 10000,
});
assert.strictEqual(entry.type, 'zap');
assert.strictEqual(entry.senderPubkey, SENDER_PUBKEY);
assert.strictEqual(entry.amountSats, 21);
assert.strictEqual(entry.message, 'Nice!');
assert.true(entry.senderProfileLoading, 'senderProfileLoading starts true');
});
test('parseZapReceipt extracts sender, amount, and zapped event id', function (assert) {
const receipt = makeZapReceiptEvent({ message: 'Great photo!' });
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.ok(entry, 'returns an entry');
assert.strictEqual(entry.senderPubkey, SENDER_PUBKEY);
assert.strictEqual(entry.photoEventId, PHOTO_EVENT_ID);
assert.strictEqual(entry.amountSats, 2000, '2000000 msat → 2000 sats');
assert.strictEqual(entry.message, 'Great photo!');
assert.strictEqual(entry.createdAt, 10000);
});
test('parseZapReceipt returns null when recipient is not the user', function (assert) {
const receipt = makeZapReceiptEvent({
recipient: 'c'.repeat(64),
});
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.notOk(entry, 'not directed at the user');
});
test('parseZapReceipt returns null for wrong kind', function (assert) {
const receipt = makeZapReceiptEvent();
receipt.kind = 1;
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.notOk(entry);
});
test('parseZapReceipt returns null when no event pointer', function (assert) {
const receipt = makeZapReceiptEvent();
receipt.tags = receipt.tags.filter((t) => t[0] !== 'e');
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.notOk(entry);
});
test('parseZapReceipt handles null message', function (assert) {
const description = JSON.stringify({
kind: 9734,
pubkey: SENDER_PUBKEY,
content: '',
id: ZAP_REQ_ID_2,
created_at: 10000,
sig: 'fake',
tags: [
['p', USER_PUBKEY],
['relays', ['wss://relay.example.com']],
],
});
const receipt = makeZapReceiptEvent({ description });
const entry = parseZapReceipt(receipt, USER_PUBKEY);
assert.ok(entry);
assert.strictEqual(entry.message, null, 'empty content → null message');
});
test('enrichWithPhoto populates photo and placeIdentifier from the store', function (assert) {
const photoEvent = makePhotoEvent({
id: PHOTO_XYZ_ID,
placeIdentifier: 'osm:way:456',
});
const mockStore = {
getEvent: (id) => (id === PHOTO_XYZ_ID ? photoEvent : undefined),
};
const entry = parseZapReceipt(
makeZapReceiptEvent({ zappedEventId: PHOTO_XYZ_ID }),
USER_PUBKEY
);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.true(result, 'enrichment succeeded');
assert.ok(entry.photo, 'photo is populated');
assert.strictEqual(
entry.placeIdentifier,
'osm:way:456',
'placeIdentifier extracted from photo'
);
assert.strictEqual(entry.photo.url, 'https://x.com/photo.jpg');
});
test('enrichWithPhoto returns false when zapped event is not a kind 360', function (assert) {
const nonPhotoEvent = { id: OTHER_EVENT_ID, pubkey: USER_PUBKEY, kind: 1 };
const mockStore = { getEvent: () => nonPhotoEvent };
const entry = parseZapReceipt(
makeZapReceiptEvent({ zappedEventId: OTHER_EVENT_ID }),
USER_PUBKEY
);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.false(result, 'not a kind 360');
assert.notOk(entry.photo);
});
test('enrichWithPhoto returns false when zapped event is not authored by the user', function (assert) {
const otherUserPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
const mockStore = { getEvent: () => otherUserPhoto };
const entry = parseZapReceipt(
makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID }),
USER_PUBKEY
);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.false(result, 'not authored by the user');
});
test('enrichWithPhoto returns false when zapped event not in store', function (assert) {
const mockStore = { getEvent: () => undefined };
const entry = parseZapReceipt(makeZapReceiptEvent(), USER_PUBKEY);
const result = enrichWithPhoto(entry, mockStore, USER_PUBKEY);
assert.false(result, 'event not in store');
});
});
+25 -3
View File
@@ -55,12 +55,34 @@ module('Unit | Utility | format-text', function () {
test('formatRelativeDate returns an absolute date for timestamps older than a week', function (assert) {
const now = Math.floor(Date.now() / 1000);
const oldTimestamp = now - 86400 * 30; // 30 days ago
const oldTimestamp = now - 86400 * 400; // 400 days ago (previous year)
const result = formatRelativeDate(oldTimestamp);
// The exact format depends on the locale, but it should contain a year
assert.ok(
result.includes(String(new Date().getFullYear())),
'Contains the year'
result.includes(String(new Date().getFullYear() - 1)),
'Contains the previous year'
);
});
test('formatRelativeDate omits year for same-year dates older than a week', function (assert) {
const now = Math.floor(Date.now() / 1000);
const sameYearTimestamp = now - 86400 * 30; // 30 days ago (same year)
const result = formatRelativeDate(sameYearTimestamp);
// Should NOT contain the current year
assert.notOk(
result.includes(String(new Date().getFullYear())),
'Does not contain the current year'
);
// Should contain month and day
const expectedMonth = new Date(sameYearTimestamp * 1000).toLocaleDateString(
undefined,
{ month: 'short' }
);
const expectedDay = new Date(sameYearTimestamp * 1000).toLocaleDateString(
undefined,
{ day: 'numeric' }
);
assert.ok(result.includes(expectedMonth), 'Contains month');
assert.ok(result.includes(expectedDay), 'Contains day');
});
});
+41
View File
@@ -4,6 +4,7 @@ import {
mergeRequiredRelays,
normalizeRelayUrl,
parsePlacePhotos,
relayUrlMatches,
uniqNormalizedRelays,
} from 'marco/utils/nostr';
@@ -324,4 +325,44 @@ module('Unit | Utility | nostr', function () {
'wss://custom.example.com',
]);
});
test('relayUrlMatches returns true for normalized trusted relays', function (assert) {
const trusted = ['wss://nostr.kosmos.org', 'wss://relay.damus.io'];
assert.true(
relayUrlMatches('wss://nostr.kosmos.org', trusted),
'exact match'
);
assert.true(
relayUrlMatches('wss://nostr.kosmos.org/', trusted),
'trailing slash normalized'
);
assert.true(
relayUrlMatches('WSS://Nostr.Kosmos.org/', trusted),
'case-insensitive'
);
assert.true(
relayUrlMatches('nostr.kosmos.org', trusted),
'bare hostname gets wss:// prefix'
);
});
test('relayUrlMatches returns false for non-trusted relays', function (assert) {
const trusted = ['wss://nostr.kosmos.org'];
assert.false(
relayUrlMatches('wss://spam.example.com', trusted),
'untrusted relay'
);
assert.false(relayUrlMatches('', trusted), 'empty url');
assert.false(relayUrlMatches(null, trusted), 'null url');
assert.false(
relayUrlMatches('wss://nostr.kosmos.org', []),
'empty trust list'
);
assert.false(
relayUrlMatches('wss://nostr.kosmos.org', null),
'null trust list'
);
});
});