Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9200e6f5c8
|
||
|
|
bf09101a24
|
||
|
|
df720d1b21
|
||
|
|
e74c01b1d1
|
||
|
|
0f6272006c
|
||
|
|
85de25032e
|
||
|
|
b9103b28a9
|
||
|
|
b8a9c71657
|
||
|
|
a7b0b52a58
|
||
|
|
12728f449b
|
||
|
|
fe5e85b029
|
||
|
|
c6a0b0da5e
|
||
|
|
553faa5edd
|
||
|
|
dda0a9846d
|
||
|
|
4f423c8abc
|
||
|
|
231a08c2f9
|
||
|
|
7fccc56c92
|
||
|
|
878387b9a8
|
||
|
|
0f6bfb7e74
|
||
|
|
faea3d26cd
|
||
|
|
a0eea6024a
|
||
|
|
ec0751de14
|
||
|
|
b295636799
|
||
|
|
7a8d4531f7
|
||
|
|
12e746c2a3
|
||
|
|
cb60e1d9b5
|
||
|
|
75c0862134
|
||
|
|
6ba2f1d132
|
||
|
|
a41ad8c99c
|
||
|
|
081138bfc0
|
||
|
|
1a490c5d04
|
||
|
|
f7eacf58d1
|
||
|
|
c8b9ddc1e5
|
||
|
|
e9be615c9a
|
||
|
|
a4e7e721ac
|
||
|
|
7d497c9afb
|
||
|
|
0d92fc9937
|
||
|
|
7e2bce84db
|
||
|
|
dccad0b47f
|
||
|
|
05160eb5f1
|
||
|
|
fb3b8bef39
|
||
|
|
c870a71e30
|
||
|
|
ddce25f43a
|
||
|
|
107340d26e
|
||
|
|
4653947454
|
||
|
|
20fa3e00ce
|
||
|
|
7dfd58d075
|
||
|
|
9d928217ca
|
||
|
|
1b60915e3f
|
||
|
|
39e5240af2
|
||
|
|
35aa3c950f
|
||
|
|
a4a8d123d3
|
||
|
|
92c6190d28
|
||
|
|
36989e1dc2
|
||
|
|
256659fbfb
|
||
|
|
ad762b138f
|
||
|
|
2d3808a097
|
||
|
|
6aa13d7243
|
||
|
|
6804e80528
|
||
|
|
da752f7807
|
||
|
|
76867db1dd
|
||
|
|
23444a49a8
|
||
|
|
919c0335eb
|
||
|
|
d0252309c4
|
||
|
|
c7fc98d4bd
|
||
|
|
3f6bd5267f
|
||
|
|
fd1b570717
|
||
|
|
6d68a6240b
|
||
|
|
0ebe565cbc
|
||
|
|
e7020469c3
|
||
|
|
bf3011115b
|
||
|
|
8c98e0757b
|
||
|
|
2ae9a78f5d
|
||
|
|
252f41f897
|
||
|
|
572213eda2
|
||
|
|
ac7a4eb262
|
||
|
|
7d19068777
|
||
|
|
17dd721986
|
||
|
|
474bc8cd63
|
||
|
|
3cc5e8834e
|
||
|
|
45c18100d3
|
||
|
|
17db6a5f65
|
||
|
|
7f9ee29136
|
||
|
|
81bbc03f54
|
||
|
|
bf1fcbcb40
|
||
|
|
5d9c6ebc89
|
||
|
|
2690c5470f
|
No files matched your search
@@ -1,359 +0,0 @@
|
|||||||
---
|
|
||||||
name: nak
|
|
||||||
description: Interact with Nostr protocol using the nak CLI tool. Use to generate Nostr secret keys, encode and decode Nostr identifiers (hex/npub/nsec/nip05/etc), fetch events from relays, sign and publish Nostr events, and more.
|
|
||||||
license: CC-BY-SA-4.0
|
|
||||||
---
|
|
||||||
|
|
||||||
# nak - Nostr Army Knife
|
|
||||||
|
|
||||||
Work with the Nostr protocol using the `nak` CLI tool.
|
|
||||||
|
|
||||||
GitHub: https://github.com/fiatjaf/nak
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
To install (or upgrade) nak:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Concepts
|
|
||||||
|
|
||||||
Based on analyzing extensive real-world usage, here are the fundamental concepts distilled down, then branched out:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CONCEPT 1: Query & Discovery
|
|
||||||
*"Finding events and data on Nostr"*
|
|
||||||
|
|
||||||
**Basic:**
|
|
||||||
- Fetch by identifier: "Get event/profile without knowing relays"
|
|
||||||
- `nak fetch nevent1...` (uses embedded relay hints)
|
|
||||||
- `nak fetch alex@gleasonator.dev` (NIP-05 resolution)
|
|
||||||
- Query by event ID: "I want THIS specific event from a relay"
|
|
||||||
- `nak req -i <event_id> <relay>`
|
|
||||||
- Query by author: "Show me everything from this person"
|
|
||||||
- `nak req -a <pubkey> <relay>`
|
|
||||||
- Query by kind: "Show me all profiles/notes/videos"
|
|
||||||
- `nak req -k <kind> <relay>`
|
|
||||||
|
|
||||||
**Intermediate:**
|
|
||||||
- Fetch addressable events: "Get event by naddr/nprofile"
|
|
||||||
- `nak fetch naddr1...` (kind, author, identifier encoded)
|
|
||||||
- `nak fetch -r relay.primal.net naddr1...` (override relays)
|
|
||||||
- Filter by multiple criteria: "Find posts by author X of kind Y"
|
|
||||||
- `nak req -k 1 -a <pubkey> <relay>`
|
|
||||||
- Tag-based queries: "Find events tagged with bitcoin"
|
|
||||||
- `nak req --tag t=bitcoin <relay>`
|
|
||||||
|
|
||||||
**Advanced:**
|
|
||||||
- Search with ranking: "Find trending/top content"
|
|
||||||
- `nak req --search "sort:hot" <relay>`
|
|
||||||
- `nak req --search "popular:24h" <relay>`
|
|
||||||
- Live monitoring: "Watch events in real-time"
|
|
||||||
- `nak req --stream <relay>`
|
|
||||||
- Cross-protocol queries: "Find bridged Bluesky content"
|
|
||||||
- `nak req --tag "proxy=at://..." <relay>`
|
|
||||||
|
|
||||||
**Use cases:** Discovering content, debugging relay data, testing search algorithms, monitoring live feeds
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CONCEPT 2: Broadcast & Migration
|
|
||||||
*"Moving/copying events between relays"*
|
|
||||||
|
|
||||||
**Basic:**
|
|
||||||
- Publish a single event: "Put this on relay X"
|
|
||||||
- `cat event.json | nak event <relay>`
|
|
||||||
- Query and republish: "Copy event from relay A to relay B"
|
|
||||||
- `nak req -i <id> relay1 | nak event relay2`
|
|
||||||
|
|
||||||
**Intermediate:**
|
|
||||||
- Batch migration: "Copy all events of kind X"
|
|
||||||
- `nak req -k 30717 source_relay | nak event dest_relay`
|
|
||||||
- Paginated backup: "Download everything from a relay"
|
|
||||||
- `nak req --paginate source_relay | nak event backup_relay`
|
|
||||||
- Multi-relay broadcast: "Publish to multiple relays at once"
|
|
||||||
- `cat event.json | nak event relay1 relay2 relay3`
|
|
||||||
|
|
||||||
**Advanced:**
|
|
||||||
- Selective migration: "Copy follow list members' data"
|
|
||||||
- Loop through follow list, query each author, republish to new relay
|
|
||||||
- Filter and migrate: "Copy only tagged/searched content"
|
|
||||||
- `nak req --tag client=X relay1 | nak event relay2`
|
|
||||||
- Cross-relay synchronization: "Keep two relays in sync"
|
|
||||||
- `nak sync source_relay dest_relay`
|
|
||||||
|
|
||||||
**Use cases:** Seeding new relays, backing up data, migrating content between relays, bridging Mostr/Fediverse content
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CONCEPT 3: Identity & Encoding
|
|
||||||
*"Working with Nostr identifiers and keys"*
|
|
||||||
|
|
||||||
**Basic:**
|
|
||||||
- Decode identifiers: "What's inside this npub/nevent?"
|
|
||||||
- `nak decode npub1...`
|
|
||||||
- `nak decode user@domain.com`
|
|
||||||
- Encode identifiers: "Turn this pubkey into npub"
|
|
||||||
- `nak encode npub <hex_pubkey>`
|
|
||||||
|
|
||||||
**Intermediate:**
|
|
||||||
- Generate keys: "Create a new identity"
|
|
||||||
- `nak key generate`
|
|
||||||
- `nak key generate | nak key public | nak encode npub`
|
|
||||||
- Extract hex from NIP-05: "Get the raw pubkey from an address"
|
|
||||||
- `nak decode user@domain.com | jq -r .pubkey`
|
|
||||||
- Create shareable references: "Make a nevent with relay hints"
|
|
||||||
- `nak encode nevent <event_id> --relay <relay>`
|
|
||||||
|
|
||||||
**Advanced:**
|
|
||||||
- Complex naddr creation: "Create addressable event reference with metadata"
|
|
||||||
- `nak encode naddr -k 30717 -a <author> -d <identifier> -r <relay>`
|
|
||||||
- Multi-relay nprofile: "Create profile with multiple relay hints"
|
|
||||||
- `nak encode nprofile <pubkey> -r relay1 -r relay2 -r relay3`
|
|
||||||
|
|
||||||
**Use cases:** Converting between formats, sharing references with relay hints, managing multiple identities, extracting pubkeys for scripting
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CONCEPT 4: Event Creation & Publishing
|
|
||||||
*"Creating and signing new events"*
|
|
||||||
|
|
||||||
**Basic:**
|
|
||||||
- Interactive creation: "Create an event with prompts"
|
|
||||||
- `nak event --prompt-sec <relay>`
|
|
||||||
- Simple note: "Publish a text note"
|
|
||||||
- `nak event -k 1 -c "Hello Nostr" <relay>`
|
|
||||||
|
|
||||||
**Intermediate:**
|
|
||||||
- Events with tags: "Create tagged content"
|
|
||||||
- `nak event -k 1 -t t=bitcoin -t t=nostr <relay>`
|
|
||||||
- Event deletion: "Delete a previous event"
|
|
||||||
- `nak event -k 5 -e <event_id> --prompt-sec <relay>`
|
|
||||||
- Replaceable events: "Create/update a profile or app data"
|
|
||||||
- `nak event -k 10019 -t mint=<url> -t pubkey=<key> <relay>`
|
|
||||||
|
|
||||||
**Advanced:**
|
|
||||||
- Remote signing with bunker: "Sign without exposing keys"
|
|
||||||
- `nak event --connect "bunker://..." <relay>`
|
|
||||||
- Batch event creation: "Generate many events via script"
|
|
||||||
- `for i in {1..100}; do nak event localhost:8000; done`
|
|
||||||
- Complex events from JSON: "Craft specific event structure"
|
|
||||||
- Modify JSON, then `cat event.json | nak event <relay>`
|
|
||||||
|
|
||||||
**Use cases:** Testing event creation, developing apps (kind 31990, 37515), wallet integration (kind 10019), moderation (kind 5), bunker/remote signing implementation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CONCEPT 5: Development & Testing
|
|
||||||
*"Building on Nostr"*
|
|
||||||
|
|
||||||
**Basic:**
|
|
||||||
- Local relay testing: "Test against dev relay"
|
|
||||||
- `nak req localhost:8000`
|
|
||||||
- `nak event ws://127.0.0.1:7777`
|
|
||||||
|
|
||||||
**Intermediate:**
|
|
||||||
- Inspect JSON: "Examine event structure"
|
|
||||||
- `nak req -i <id> <relay> | jq .`
|
|
||||||
- Test search: "Verify search functionality"
|
|
||||||
- `nak req --search "<query>" localhost:8000`
|
|
||||||
- Admin operations: "Manage relay content"
|
|
||||||
- `nak admin --prompt-sec banevent --id <id> <relay>`
|
|
||||||
|
|
||||||
**Advanced:**
|
|
||||||
- Protocol bridging: "Query/test ATProto integration"
|
|
||||||
- `nak req --tag "proxy=at://..." eclipse.pub/relay`
|
|
||||||
- Git over Nostr: "Use git with Nostr transport"
|
|
||||||
- `nak git clone nostr://...`
|
|
||||||
- `nak req -k 30617 git.shakespeare.diy`
|
|
||||||
- Performance testing: "Measure query speed"
|
|
||||||
- `time nak req -k 0 -a <pubkey> <relay>`
|
|
||||||
- Custom event kinds: "Test proprietary event types"
|
|
||||||
- `nak req -k 37515 -a <author> -d <id> ditto.pub/relay`
|
|
||||||
|
|
||||||
**Use cases:** Relay development (Ditto), testing bridges (Mostr/Bluesky), developing video platforms, implementing Git-over-Nostr, testing search ranking algorithms, performance benchmarking
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CONCEPT 6: Analytics & Monitoring
|
|
||||||
*"Understanding Nostr data"*
|
|
||||||
|
|
||||||
**Basic:**
|
|
||||||
- Count results: "How many events match?"
|
|
||||||
- `nak req -k 1 <relay> | wc -l`
|
|
||||||
- `nak count -k 7 -e <event_id> <relay>`
|
|
||||||
|
|
||||||
**Intermediate:**
|
|
||||||
- Live monitoring: "Watch relay activity"
|
|
||||||
- `nak req --stream <relay>`
|
|
||||||
- `nak req -l 0 --stream relay1 relay2 relay3`
|
|
||||||
- Client analytics: "What apps are posting?"
|
|
||||||
- `nak req --tag client=<app> <relay>`
|
|
||||||
|
|
||||||
**Advanced:**
|
|
||||||
- Event chain analysis: "Track engagement"
|
|
||||||
- `nak count -k 7 -e <event_id> <relay>` (reactions)
|
|
||||||
- `nak req -k 6 -k 7 -e <event_id> <relay>` (reposts + reactions)
|
|
||||||
- Content ranking: "Find top/hot content"
|
|
||||||
- `nak req --search "sort:top" <relay>`
|
|
||||||
- Cross-relay comparison: "Compare event availability"
|
|
||||||
- Query same event from multiple relays, compare results
|
|
||||||
|
|
||||||
**Use cases:** Monitoring relay health, tracking client usage (Ditto, noStrudel, moStard), analyzing engagement, testing ranking algorithms
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary: The 6 Core Mental Models
|
|
||||||
|
|
||||||
1. **Query & Discovery**: "How do I find things?"
|
|
||||||
2. **Broadcast & Migration**: "How do I move things?"
|
|
||||||
3. **Identity & Encoding**: "How do I represent things?"
|
|
||||||
4. **Event Creation**: "How do I make things?"
|
|
||||||
5. **Development & Testing**: "How do I build things?"
|
|
||||||
6. **Analytics & Monitoring**: "How do I measure things?"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Command Shapes and Edge-Cases
|
|
||||||
|
|
||||||
Non-obvious patterns and edge cases for nak commands.
|
|
||||||
|
|
||||||
### Signing Methods
|
|
||||||
|
|
||||||
**Using environment variable:**
|
|
||||||
```bash
|
|
||||||
export NOSTR_SECRET_KEY=<hex_key>
|
|
||||||
nak event -c "hello" # Automatically uses $NOSTR_SECRET_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
**Reading key from file:**
|
|
||||||
```bash
|
|
||||||
nak event -c "hello" --sec $(cat /path/to/key.txt)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Content from File
|
|
||||||
|
|
||||||
**Using @ prefix to read content from file:**
|
|
||||||
```bash
|
|
||||||
echo "hello world" > content.txt
|
|
||||||
nak event -c @content.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tag Syntax
|
|
||||||
|
|
||||||
**Tag with multiple values (semicolon-separated):**
|
|
||||||
```bash
|
|
||||||
nak event -t custom="value1;value2;value3"
|
|
||||||
# Creates: ["custom", "value1", "value2", "value3"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Filter Output Modes
|
|
||||||
|
|
||||||
**Print bare filter (JSON only):**
|
|
||||||
```bash
|
|
||||||
nak req -k 1 -l 5 --bare
|
|
||||||
# Output: {"kinds":[1],"limit":5}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Filter from stdin can be modified with flags:**
|
|
||||||
```bash
|
|
||||||
echo '{"kinds": [1]}' | nak req -l 5 -k 3 --bare
|
|
||||||
```
|
|
||||||
|
|
||||||
**Unlimited stream:**
|
|
||||||
```bash
|
|
||||||
nak req -l 0 --stream wss://relay.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### Relay Specification
|
|
||||||
|
|
||||||
**Local relays and WebSocket schemes:**
|
|
||||||
```bash
|
|
||||||
nak req localhost:8000
|
|
||||||
nak req ws://127.0.0.1:7777
|
|
||||||
nak req relay.example.com # Assumes wss:// if not specified
|
|
||||||
```
|
|
||||||
|
|
||||||
### Encoding
|
|
||||||
|
|
||||||
**Encode from JSON stdin (auto-detects type):**
|
|
||||||
```bash
|
|
||||||
echo '{"pubkey":"<hex>","relays":["wss://relay.example.com"]}' | nak encode
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Operations
|
|
||||||
|
|
||||||
**Complete key generation pipeline:**
|
|
||||||
```bash
|
|
||||||
nak key generate | tee secret.key | nak key public | nak encode npub
|
|
||||||
# Saves private key to file AND prints the public npub
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
**Verify and pipe:**
|
|
||||||
```bash
|
|
||||||
nak event -c "test" --sec <key> | nak verify && echo "Valid"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fetch vs Req
|
|
||||||
|
|
||||||
**nak fetch uses relay hints from identifiers:**
|
|
||||||
```bash
|
|
||||||
nak fetch nevent1... # Uses relays encoded in nevent
|
|
||||||
nak fetch naddr1... # Uses relays encoded in naddr
|
|
||||||
nak fetch alex@gleasonator.dev # Resolves NIP-05
|
|
||||||
nak fetch -r relay.primal.net naddr1... # Override relays
|
|
||||||
```
|
|
||||||
|
|
||||||
**nak req requires explicit relay specification:**
|
|
||||||
```bash
|
|
||||||
nak req -i <event_id> wss://relay.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
**No relays specified (prints event without publishing):**
|
|
||||||
```bash
|
|
||||||
nak event -c "test" --sec <key> # Just prints the event JSON
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tag order matters for addressable events:**
|
|
||||||
```bash
|
|
||||||
# The first 'd' tag is the identifier
|
|
||||||
nak event -k 30023 -d first -d second # "first" is the identifier
|
|
||||||
```
|
|
||||||
|
|
||||||
**Timestamp override:**
|
|
||||||
```bash
|
|
||||||
nak event --ts 1700000000 -c "backdated" --sec <key>
|
|
||||||
nak event --ts 0 -c "genesis" --sec <key> # Event at Unix epoch
|
|
||||||
```
|
|
||||||
|
|
||||||
**Kind 0 (profile) requires JSON content:**
|
|
||||||
```bash
|
|
||||||
nak event -k 0 -c '{"name":"Alice","about":"Developer"}' --sec <key>
|
|
||||||
```
|
|
||||||
|
|
||||||
**POW (Proof of Work):**
|
|
||||||
```bash
|
|
||||||
nak event --pow 20 -c "mined event" --sec <key>
|
|
||||||
# Will compute hash until difficulty target is met
|
|
||||||
```
|
|
||||||
|
|
||||||
**NIP-42 AUTH:**
|
|
||||||
```bash
|
|
||||||
nak req --auth -k 1 wss://relay.example.com
|
|
||||||
nak event --auth -c "test" --sec <key> wss://relay.example.com
|
|
||||||
# Automatically handles AUTH challenges
|
|
||||||
```
|
|
||||||
|
|
||||||
**Stdin takes precedence over flags:**
|
|
||||||
```bash
|
|
||||||
echo '{"content":"from stdin"}' | nak event -c "from flag" --sec <key>
|
|
||||||
# Uses "from stdin" (stdin overrides flags)
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -8,8 +8,11 @@
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
Marco (as in [Marco Polo][1]) is an unhosted maps application that respects
|
Marco (as in [Marco Polo][1]) is an unhosted maps application that respects
|
||||||
your privacy and choices. It allows users to connect their own remote storage
|
your privacy and choices.
|
||||||
to sync place bookmarks across apps and devices.
|
|
||||||
|
It allows users to connect their own remote storage to sync place bookmarks
|
||||||
|
across apps and devices. It also supports the decentralized sharing of place
|
||||||
|
photos (and soon reviews) via [Nostr][2].
|
||||||
|
|
||||||
This README outlines the details of collaborating on this Ember application, or
|
This README outlines the details of collaborating on this Ember application, or
|
||||||
just building and deploying it for yourself.
|
just building and deploying it for yourself.
|
||||||
@@ -79,3 +82,4 @@ To run the script, you need `imagemagick` and `librsvg` installed:
|
|||||||
- [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
|
- [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
|
||||||
|
|
||||||
[1]: https://en.wikipedia.org/wiki/Marco_Polo
|
[1]: https://en.wikipedia.org/wiki/Marco_Polo
|
||||||
|
[2]: https://nostr.org
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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 ActivityPhotoItem 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.photos.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get photos() {
|
||||||
|
const photos = this.item?.photos;
|
||||||
|
if (photos && photos.length > 0) return photos;
|
||||||
|
const single = this.item?.photo;
|
||||||
|
return single ? [single] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get primaryPhoto() {
|
||||||
|
return this.photos[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
get extraPhotoCount() {
|
||||||
|
return Math.max(0, this.photos.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
get photoCountText() {
|
||||||
|
const count = this.photos.length;
|
||||||
|
return count === 1 ? 'a photo' : `${count} photos`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get photoThumbUrl() {
|
||||||
|
return this.primaryPhoto?.thumbUrl || this.primaryPhoto?.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
get placeName() {
|
||||||
|
return this.item?.placeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
get placeNameLoading() {
|
||||||
|
return this.item?.placeNameLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="activity-item"
|
||||||
|
{{on "click" (fn @onSelect @item)}}
|
||||||
|
>
|
||||||
|
<div class="header">
|
||||||
|
<span class="sender-line">
|
||||||
|
<span class="sender-name">{{this.senderDisplayName}}</span>
|
||||||
|
{{! template-lint-disable no-whitespace-for-layout }}
|
||||||
|
<span class="action"> shared {{this.photoCountText}}</span>
|
||||||
|
</span>
|
||||||
|
<span class="amount">
|
||||||
|
<Icon
|
||||||
|
@name="feather-camera"
|
||||||
|
@size={{16}}
|
||||||
|
@color="var(--secondary-text-color)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="context">
|
||||||
|
<div class="context-images">
|
||||||
|
{{#if this.senderAvatar}}
|
||||||
|
<img
|
||||||
|
class="sender-avatar"
|
||||||
|
src={{this.senderAvatar}}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
|
<div class="sender-avatar-placeholder">
|
||||||
|
<Icon @name="user" @size={{16}} @color="#999" />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if this.hasPhoto}}
|
||||||
|
<div class="context-thumb">
|
||||||
|
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
|
||||||
|
{{#if (gt this.extraPhotoCount 0)}}
|
||||||
|
<span
|
||||||
|
class="context-thumb-badge"
|
||||||
|
>+{{this.extraPhotoCount}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="context-text">
|
||||||
|
<span class="activity-place-name">
|
||||||
|
{{#if this.placeName}}
|
||||||
|
<span class="place-name-text">{{this.placeName}}</span>
|
||||||
|
{{else if this.placeNameLoading}}{{else}}
|
||||||
|
<span class="place-name-text">Unnamed place</span>
|
||||||
|
{{/if}}
|
||||||
|
</span>
|
||||||
|
<span class="activity-date">{{formatRelativeDate
|
||||||
|
@item.createdAt
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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 TabNav from './tab-nav';
|
||||||
|
import ActivityZapItem from './activity-zap-item';
|
||||||
|
import ActivityPhotoItem from './activity-photo-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 and from 'ember-truth-helpers/helpers/and';
|
||||||
|
import restoreScroll from '../modifiers/restore-scroll';
|
||||||
|
import observeIntersection from '../modifiers/observe-intersection';
|
||||||
|
|
||||||
|
export default class ActivityTimelineComponent extends Component {
|
||||||
|
@tracked isNostrConnectModalOpen = false;
|
||||||
|
|
||||||
|
tabs = [
|
||||||
|
{ label: 'Home', value: 'home' },
|
||||||
|
{ label: 'Explore', value: 'explore' },
|
||||||
|
];
|
||||||
|
|
||||||
|
@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>
|
||||||
|
|
||||||
|
<TabNav
|
||||||
|
@tabs={{this.tabs}}
|
||||||
|
@active={{@sourceMode}}
|
||||||
|
@onChange={{@onSetSourceMode}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
||||||
|
{{#if @isLoading}}
|
||||||
|
<div class="sidebar-loading">
|
||||||
|
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||||
|
</div>
|
||||||
|
{{else if (and (not @isConnected) (eq @sourceMode "home"))}}
|
||||||
|
<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}} />
|
||||||
|
{{else if (eq item.type "photo")}}
|
||||||
|
<ActivityPhotoItem @item={{item}} @onSelect={{@onSelect}} />
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
<li class="activity-load-more" {{observeIntersection @onLoadMore}}>
|
||||||
|
{{#if @isLoadingMore}}
|
||||||
|
<div class="activity-load-more-spinner">
|
||||||
|
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.isNostrConnectModalOpen}}
|
||||||
|
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||||
|
<NostrConnect @onConnect={{this.onNostrConnected}} />
|
||||||
|
</Modal>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -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-item"
|
||||||
|
{{on "click" (fn @onSelect @item)}}
|
||||||
|
>
|
||||||
|
<div class="header">
|
||||||
|
<span class="sender-line">
|
||||||
|
<span class="sender-name">{{this.senderDisplayName}}</span>
|
||||||
|
{{! template-lint-disable no-whitespace-for-layout }}
|
||||||
|
<span class="action"> zapped your photo</span>
|
||||||
|
</span>
|
||||||
|
<span class="amount">{{this.item.amountSats}} ⚡</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="context">
|
||||||
|
<div class="context-images">
|
||||||
|
{{#if this.senderAvatar}}
|
||||||
|
<img
|
||||||
|
class="sender-avatar"
|
||||||
|
src={{this.senderAvatar}}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{{else}}
|
||||||
|
<div class="sender-avatar-placeholder">
|
||||||
|
<Icon @name="user" @size={{16}} @color="#999" />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if this.hasPhoto}}
|
||||||
|
<div class="context-thumb">
|
||||||
|
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="context-text">
|
||||||
|
<span class="activity-place-name">
|
||||||
|
{{#if this.placeName}}
|
||||||
|
<span class="place-name-text">{{this.placeName}}</span>
|
||||||
|
{{else if this.placeNameLoading}}{{else}}
|
||||||
|
<span class="place-name-text">Unnamed place</span>
|
||||||
|
{{/if}}
|
||||||
|
</span>
|
||||||
|
<span class="activity-date">{{formatRelativeDate
|
||||||
|
@item.createdAt
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.item.message}}
|
||||||
|
<div class="message">{{this.item.message}}</div>
|
||||||
|
{{/if}}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import SearchBox from '#components/search-box';
|
|||||||
import CategoryChips from '#components/category-chips';
|
import CategoryChips from '#components/category-chips';
|
||||||
import { and } from 'ember-truth-helpers';
|
import { and } from 'ember-truth-helpers';
|
||||||
import cachedImage from '../modifiers/cached-image';
|
import cachedImage from '../modifiers/cached-image';
|
||||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
import { getCategoryById } from '../utils/poi-categories';
|
||||||
|
|
||||||
export default class AppHeaderComponent extends Component {
|
export default class AppHeaderComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@@ -43,7 +43,7 @@ export default class AppHeaderComponent extends Component {
|
|||||||
if (qp?.q) {
|
if (qp?.q) {
|
||||||
this.searchQuery = qp.q;
|
this.searchQuery = qp.q;
|
||||||
} else if (qp?.category) {
|
} else if (qp?.category) {
|
||||||
const category = POI_CATEGORIES.find((c) => c.id === qp.category);
|
const category = getCategoryById(qp.category);
|
||||||
this.searchQuery = category ? category.label : qp.category;
|
this.searchQuery = category ? category.label : qp.category;
|
||||||
} else {
|
} else {
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
|
|||||||
@@ -22,12 +22,18 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
|
|||||||
<li>
|
<li>
|
||||||
<button type="button" {{on "click" @onSavedPlaces}}>
|
<button type="button" {{on "click" @onSavedPlaces}}>
|
||||||
<Icon @name="bookmark" @size={{20}} />
|
<Icon @name="bookmark" @size={{20}} />
|
||||||
<span>Collections</span>
|
<span>Saved Places</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" {{on "click" @onActivity}}>
|
||||||
|
<Icon @name="activity" @size={{20}} />
|
||||||
|
<span>Activity</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" {{on "click" @onContributions}}>
|
<button type="button" {{on "click" @onContributions}}>
|
||||||
<Icon @name="activity" @size={{20}} />
|
<Icon @name="user-check" @size={{20}} />
|
||||||
<span>My Contributions</span>
|
<span>My Contributions</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export default class AppMenu extends Component {
|
|||||||
this.router.transitionTo('contributions');
|
this.router.transitionTo('contributions');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
goToActivity() {
|
||||||
|
this.router.transitionTo('activity');
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar app-menu-pane">
|
<div class="sidebar app-menu-pane">
|
||||||
{{#if (eq this.currentView "menu")}}
|
{{#if (eq this.currentView "menu")}}
|
||||||
@@ -36,6 +41,7 @@ export default class AppMenu extends Component {
|
|||||||
@onClose={{@onClose}}
|
@onClose={{@onClose}}
|
||||||
@onSavedPlaces={{this.goToSavedPlaces}}
|
@onSavedPlaces={{this.goToSavedPlaces}}
|
||||||
@onContributions={{this.goToContributions}}
|
@onContributions={{this.goToContributions}}
|
||||||
|
@onActivity={{this.goToActivity}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{{else if (eq this.currentView "settings")}}
|
{{else if (eq this.currentView "settings")}}
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import {
|
|||||||
excludeRequiredRelays,
|
excludeRequiredRelays,
|
||||||
mergeRequiredRelays,
|
mergeRequiredRelays,
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
|
uniqNormalizedRelays,
|
||||||
} from '../../../utils/nostr';
|
} from '../../../utils/nostr';
|
||||||
import { DEFAULT_BLOSSOM_SERVER } from '../../../services/blossom';
|
import { DEFAULT_BLOSSOM_SERVER } from '../../../services/blossom';
|
||||||
|
import tooltip from '../../../modifiers/tooltip';
|
||||||
|
|
||||||
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||||
|
|
||||||
export default class AppMenuSettingsNostr extends Component {
|
export default class AppMenuSettingsNostr extends Component {
|
||||||
@service settings;
|
@service settings;
|
||||||
@service nostrData;
|
@service nostrData;
|
||||||
|
@service nostrAuth;
|
||||||
@service toast;
|
@service toast;
|
||||||
|
|
||||||
@tracked newReadRelay = '';
|
@tracked newReadRelay = '';
|
||||||
@@ -49,6 +52,10 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
return new Set(this.nostrData.requiredReadRelays.filter(Boolean));
|
return new Set(this.nostrData.requiredReadRelays.filter(Boolean));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get trustedRelaySet() {
|
||||||
|
return new Set(this.nostrData.trustedRelays.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
get requiredWriteRelaySet() {
|
get requiredWriteRelaySet() {
|
||||||
return new Set(this.nostrData.requiredWriteRelays.filter(Boolean));
|
return new Set(this.nostrData.requiredWriteRelays.filter(Boolean));
|
||||||
}
|
}
|
||||||
@@ -78,6 +85,7 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
isRequired: this.requiredReadRelaySet.has(url),
|
isRequired: this.requiredReadRelaySet.has(url),
|
||||||
|
isTrusted: this.trustedRelaySet.has(url),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -182,6 +190,26 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
this.settings.update('nostrReadRelayExclusions', null);
|
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
|
@action
|
||||||
addWriteRelay() {
|
addWriteRelay() {
|
||||||
const url = normalizeRelayUrl(this.newWriteRelay);
|
const url = normalizeRelayUrl(this.newWriteRelay);
|
||||||
@@ -251,7 +279,7 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{! template-lint-disable no-nested-interactive }}
|
{{! template-lint-disable no-nested-interactive no-unsupported-role-attributes }}
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
<Icon @name="zap" @size={{20}} />
|
<Icon @name="zap" @size={{20}} />
|
||||||
@@ -260,21 +288,56 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
<div class="details-content form-layout">
|
<div class="details-content form-layout">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="new-read-relay">Read Relays</label>
|
<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">
|
<ul class="relay-list">
|
||||||
{{#each this.readRelaysForDisplay as |relay|}}
|
{{#each this.readRelaysForDisplay as |relay|}}
|
||||||
<li>
|
<li>
|
||||||
<span>{{stripProtocol relay.url}}</span>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-remove-relay"
|
class="btn-remove-relay"
|
||||||
title="Remove relay"
|
|
||||||
aria-label="Remove"
|
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)}}
|
{{on "click" (fn this.removeReadRelay relay.url)}}
|
||||||
>
|
>
|
||||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
<Icon @name="x" @size={{14}} @color="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
{{/unless}}
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -305,103 +368,111 @@ export default class AppMenuSettingsNostr extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
{{#if this.nostrAuth.isConnected}}
|
||||||
<label for="new-write-relay">Write Relays</label>
|
|
||||||
<ul class="relay-list">
|
|
||||||
{{#each this.writeRelaysForDisplay as |relay|}}
|
|
||||||
<li>
|
|
||||||
<span>{{stripProtocol relay.url}}</span>
|
|
||||||
{{#unless relay.isRequired}}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-remove-relay"
|
|
||||||
title="Remove relay"
|
|
||||||
aria-label="Remove"
|
|
||||||
{{on "click" (fn this.removeWriteRelay relay.url)}}
|
|
||||||
>
|
|
||||||
<Icon @name="x" @size={{14}} @color="currentColor" />
|
|
||||||
</button>
|
|
||||||
{{/unless}}
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
<div class="add-relay-input">
|
|
||||||
<input
|
|
||||||
id="new-write-relay"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="relay.example.com"
|
|
||||||
value={{this.newWriteRelay}}
|
|
||||||
{{on "input" this.updateNewWriteRelay}}
|
|
||||||
{{on "keydown" this.handleWriteRelayKeydown}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
{{on "click" this.addWriteRelay}}
|
|
||||||
>Add</button>
|
|
||||||
</div>
|
|
||||||
{{#if this.hasWriteOverrides}}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-link reset-relays"
|
|
||||||
{{on "click" this.resetWriteRelays}}
|
|
||||||
>
|
|
||||||
Reset to Defaults
|
|
||||||
</button>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="nostr-media-server">Media server</label>
|
|
||||||
<select
|
|
||||||
id="nostr-media-server"
|
|
||||||
class="form-control"
|
|
||||||
{{on "change" (fn @onChange "nostrMediaServer")}}
|
|
||||||
>
|
|
||||||
{{#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">
|
<div class="form-group">
|
||||||
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
|
<label for="new-write-relay">Write Relays</label>
|
||||||
servers</label>
|
<ul class="relay-list">
|
||||||
|
{{#each this.writeRelaysForDisplay as |relay|}}
|
||||||
|
<li>
|
||||||
|
<span>{{stripProtocol relay.url}}</span>
|
||||||
|
<div class="relay-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-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>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
<div class="add-relay-input">
|
||||||
|
<input
|
||||||
|
id="new-write-relay"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="relay.example.com"
|
||||||
|
value={{this.newWriteRelay}}
|
||||||
|
{{on "input" this.updateNewWriteRelay}}
|
||||||
|
{{on "keydown" this.handleWriteRelayKeydown}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
{{on "click" this.addWriteRelay}}
|
||||||
|
>Add</button>
|
||||||
|
</div>
|
||||||
|
{{#if this.hasWriteOverrides}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-link reset-relays"
|
||||||
|
{{on "click" this.resetWriteRelays}}
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="nostr-media-server">Media server</label>
|
||||||
<select
|
<select
|
||||||
id="nostr-photo-fallback-uploads"
|
id="nostr-media-server"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
|
{{on "change" (fn @onChange "nostrMediaServer")}}
|
||||||
>
|
>
|
||||||
<option
|
{{#each this.mediaServerOptions as |server|}}
|
||||||
value="true"
|
<option
|
||||||
selected={{if
|
value={{server}}
|
||||||
this.settings.nostrPhotoFallbackUploads
|
selected={{if
|
||||||
"selected"
|
(eq server this.settings.nostrMediaServer)
|
||||||
}}
|
"selected"
|
||||||
>
|
}}
|
||||||
Yes
|
>
|
||||||
</option>
|
{{server}}
|
||||||
<option
|
</option>
|
||||||
value="false"
|
{{/each}}
|
||||||
selected={{unless
|
|
||||||
this.settings.nostrPhotoFallbackUploads
|
|
||||||
"selected"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default class ContributionsTimelineComponent extends Component {
|
|||||||
</button>
|
</button>
|
||||||
<h2 class="sidebar-header-text-centered">
|
<h2 class="sidebar-header-text-centered">
|
||||||
<span class="sidebar-header-icon-wrapper">
|
<span class="sidebar-header-icon-wrapper">
|
||||||
<Icon @name="activity" @size={{20}} @color="#898989" />
|
<Icon @name="user-check" @size={{20}} @color="#898989" />
|
||||||
</span>
|
</span>
|
||||||
My Contributions
|
My Contributions
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
+20
-1
@@ -2,6 +2,16 @@ import Component from '@glimmer/component';
|
|||||||
import { htmlSafe } from '@ember/template';
|
import { htmlSafe } from '@ember/template';
|
||||||
import { getIcon, isIconFilled } from '../utils/icons';
|
import { getIcon, isIconFilled } from '../utils/icons';
|
||||||
|
|
||||||
|
function formatDimension(dim) {
|
||||||
|
if (typeof dim === 'number') {
|
||||||
|
return `${dim}px`;
|
||||||
|
}
|
||||||
|
if (typeof dim === 'string' && /^\d+(\.\d+)?$/.test(dim.trim())) {
|
||||||
|
return `${dim.trim()}px`;
|
||||||
|
}
|
||||||
|
return dim;
|
||||||
|
}
|
||||||
|
|
||||||
export default class IconComponent extends Component {
|
export default class IconComponent extends Component {
|
||||||
get svg() {
|
get svg() {
|
||||||
return getIcon(this.args.name);
|
return getIcon(this.args.name);
|
||||||
@@ -11,13 +21,21 @@ export default class IconComponent extends Component {
|
|||||||
return this.args.size || 16;
|
return this.args.size || 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get width() {
|
||||||
|
return this.args.width !== undefined ? this.args.width : this.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get height() {
|
||||||
|
return this.args.height !== undefined ? this.args.height : this.size;
|
||||||
|
}
|
||||||
|
|
||||||
get color() {
|
get color() {
|
||||||
return this.args.color || '#898989';
|
return this.args.color || '#898989';
|
||||||
}
|
}
|
||||||
|
|
||||||
get style() {
|
get style() {
|
||||||
return htmlSafe(
|
return htmlSafe(
|
||||||
`width:${this.size}px;height:${this.size}px;color:${this.color}`
|
`width:${formatDimension(this.width)};height:${formatDimension(this.height)};color:${this.color}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +53,7 @@ export default class IconComponent extends Component {
|
|||||||
class="icon {{if this.isFilled 'icon-filled'}}"
|
class="icon {{if this.isFilled 'icon-filled'}}"
|
||||||
style={{this.style}}
|
style={{this.style}}
|
||||||
title={{this.title}}
|
title={{this.title}}
|
||||||
|
...attributes
|
||||||
>
|
>
|
||||||
{{htmlSafe this.svg}}
|
{{htmlSafe this.svg}}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+116
-1
@@ -21,6 +21,7 @@ import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
|
|||||||
import { apply } from 'ol-mapbox-style';
|
import { apply } from 'ol-mapbox-style';
|
||||||
import { getIcon } from '../utils/icons';
|
import { getIcon } from '../utils/icons';
|
||||||
import { getIconNameForTags } from '../utils/osm-icons';
|
import { getIconNameForTags } from '../utils/osm-icons';
|
||||||
|
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||||
|
|
||||||
export default class MapComponent extends Component {
|
export default class MapComponent extends Component {
|
||||||
@service osm;
|
@service osm;
|
||||||
@@ -1005,6 +1006,62 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
decodeVectorTileOsmFeature(feature) {
|
||||||
|
if (!feature?.getId || !feature?.get) {
|
||||||
|
return { decoded: null, reason: 'feature does not expose get()/getId()' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ol-mapbox-style stores the source-layer name on `mvt:layer`.
|
||||||
|
const sourceLayer = feature.get('mvt:layer') || feature.get('layer');
|
||||||
|
|
||||||
|
// OpenFreeMap uses Planetiler/OpenMapTiles tiles, which encode OSM-backed
|
||||||
|
// feature ids as (osmId * 10) + sourceType.
|
||||||
|
if (sourceLayer !== 'poi') {
|
||||||
|
return {
|
||||||
|
decoded: null,
|
||||||
|
reason: `unsupported source layer: ${sourceLayer || 'unknown'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawId = feature.getId();
|
||||||
|
const encodedId = Number(rawId);
|
||||||
|
if (!Number.isSafeInteger(encodedId) || encodedId <= 0) {
|
||||||
|
return {
|
||||||
|
decoded: null,
|
||||||
|
reason: `feature id is not a positive safe integer: ${rawId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const osmType = {
|
||||||
|
1: 'node',
|
||||||
|
2: 'way',
|
||||||
|
3: 'relation',
|
||||||
|
}[encodedId % 10];
|
||||||
|
|
||||||
|
if (!osmType) {
|
||||||
|
return {
|
||||||
|
decoded: null,
|
||||||
|
reason: `feature id suffix ${encodedId % 10} is not an OSM type`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const osmId = Math.floor(encodedId / 10);
|
||||||
|
if (osmId <= 0) {
|
||||||
|
return {
|
||||||
|
decoded: null,
|
||||||
|
reason: `decoded OSM id is invalid: ${osmId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decoded: {
|
||||||
|
osmId: String(osmId),
|
||||||
|
osmType,
|
||||||
|
},
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
animateToCrosshair(targetCoords) {
|
animateToCrosshair(targetCoords) {
|
||||||
if (!this.mapInstance || !this.crosshairElement) return;
|
if (!this.mapInstance || !this.crosshairElement) return;
|
||||||
|
|
||||||
@@ -1141,6 +1198,7 @@ export default class MapComponent extends Component {
|
|||||||
});
|
});
|
||||||
let clickedBookmark = null;
|
let clickedBookmark = null;
|
||||||
let clickedSearchResult = null;
|
let clickedSearchResult = null;
|
||||||
|
let clickedTileOsmFeature = null;
|
||||||
let selectedFeatureName = null;
|
let selectedFeatureName = null;
|
||||||
|
|
||||||
if (features && features.length > 0) {
|
if (features && features.length > 0) {
|
||||||
@@ -1155,6 +1213,35 @@ export default class MapComponent extends Component {
|
|||||||
clickedBookmark = bookmarkFeature.get('originalPlace');
|
clickedBookmark = bookmarkFeature.get('originalPlace');
|
||||||
} else if (searchResultFeature) {
|
} else if (searchResultFeature) {
|
||||||
clickedSearchResult = searchResultFeature.get('originalPlace');
|
clickedSearchResult = searchResultFeature.get('originalPlace');
|
||||||
|
} else {
|
||||||
|
for (const feature of features) {
|
||||||
|
const sourceLayer =
|
||||||
|
feature.get?.('mvt:layer') || feature.get?.('layer');
|
||||||
|
const featureName = feature.get?.('name');
|
||||||
|
const featureId = feature.getId?.();
|
||||||
|
const { decoded, reason } = this.decodeVectorTileOsmFeature(feature);
|
||||||
|
|
||||||
|
if (decoded) {
|
||||||
|
console.debug(
|
||||||
|
'Decoded vector tile feature to explicit OSM place:',
|
||||||
|
{
|
||||||
|
sourceLayer,
|
||||||
|
featureName,
|
||||||
|
featureId,
|
||||||
|
decoded,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
clickedTileOsmFeature = decoded;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('Vector tile feature could not be decoded directly:', {
|
||||||
|
sourceLayer,
|
||||||
|
featureName,
|
||||||
|
featureId,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Also get visual props for standard map click logic later
|
// Also get visual props for standard map click logic later
|
||||||
const props = features[0].getProperties();
|
const props = features[0].getProperties();
|
||||||
@@ -1179,6 +1266,19 @@ export default class MapComponent extends Component {
|
|||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const transitionToExplicitOsmPlace = ({ osmId, osmType }) => {
|
||||||
|
if (
|
||||||
|
this.router.currentRouteName === 'search' ||
|
||||||
|
(this.mapUi.currentSearch && this.mapUi.searchResults.length > 0)
|
||||||
|
) {
|
||||||
|
this.mapUi.returnToSearch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
this.router.transitionTo('place', `osm:${osmType}:${osmId}`);
|
||||||
|
};
|
||||||
|
|
||||||
// Special handling when sidebar is OPEN
|
// Special handling when sidebar is OPEN
|
||||||
if (this.args.isSidebarOpen) {
|
if (this.args.isSidebarOpen) {
|
||||||
// If it's a bookmark or search result, we allow "switching" to it even if sidebar is open
|
// If it's a bookmark or search result, we allow "switching" to it even if sidebar is open
|
||||||
@@ -1192,6 +1292,15 @@ export default class MapComponent extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clickedTileOsmFeature) {
|
||||||
|
console.debug(
|
||||||
|
'Clicked vector tile POI while sidebar open (switching):',
|
||||||
|
clickedTileOsmFeature
|
||||||
|
);
|
||||||
|
transitionToExplicitOsmPlace(clickedTileOsmFeature);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Otherwise (empty map or non-bookmark feature), close the sidebar
|
// Otherwise (empty map or non-bookmark feature), close the sidebar
|
||||||
if (this.args.onOutsideClick) {
|
if (this.args.onOutsideClick) {
|
||||||
this.args.onOutsideClick();
|
this.args.onOutsideClick();
|
||||||
@@ -1212,6 +1321,12 @@ export default class MapComponent extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clickedTileOsmFeature) {
|
||||||
|
console.debug('Clicked vector tile POI:', clickedTileOsmFeature);
|
||||||
|
transitionToExplicitOsmPlace(clickedTileOsmFeature);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.mapUi.searchResults && this.mapUi.searchResults.length > 0) {
|
if (this.mapUi.searchResults && this.mapUi.searchResults.length > 0) {
|
||||||
console.debug('Clearing active search and markers on map click');
|
console.debug('Clearing active search and markers on map click');
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
@@ -1255,7 +1370,7 @@ export default class MapComponent extends Component {
|
|||||||
lat: lat.toFixed(6),
|
lat: lat.toFixed(6),
|
||||||
lon: lon.toFixed(6),
|
lon: lon.toFixed(6),
|
||||||
q: null, // Clear q to force spatial search
|
q: null, // Clear q to force spatial search
|
||||||
category: null, // Clear category to force spatial search
|
category: NEARBY_CATEGORY.id, // Represent nearby as a first-class category
|
||||||
selected: selectedFeatureName || null,
|
selected: selectedFeatureName || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { service } from '@ember/service';
|
|||||||
import { modifier } from 'ember-modifier';
|
import { modifier } from 'ember-modifier';
|
||||||
import { task } from 'ember-concurrency';
|
import { task } from 'ember-concurrency';
|
||||||
import { EventFactory } from 'applesauce-core';
|
import { EventFactory } from 'applesauce-core';
|
||||||
|
import { encodePointer } from 'applesauce-core/helpers/pointers';
|
||||||
import or from 'ember-truth-helpers/helpers/or';
|
import or from 'ember-truth-helpers/helpers/or';
|
||||||
import config from 'marco/config/environment';
|
import config from 'marco/config/environment';
|
||||||
import DropdownMenu from './dropdown-menu';
|
import DropdownMenu from './dropdown-menu';
|
||||||
@@ -15,6 +16,8 @@ import ZapPhotoModal from './zap-photo-modal';
|
|||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import formatRelativeDate from '../helpers/format-relative-date';
|
import formatRelativeDate from '../helpers/format-relative-date';
|
||||||
|
|
||||||
|
const MAX_NEVENT_RELAY_HINTS = 3;
|
||||||
|
|
||||||
const GalleryContent = <template>
|
const GalleryContent = <template>
|
||||||
<div
|
<div
|
||||||
class="photo-gallery-overlay"
|
class="photo-gallery-overlay"
|
||||||
@@ -52,7 +55,7 @@ const GalleryContent = <template>
|
|||||||
<button
|
<button
|
||||||
class="dropdown-item text-danger"
|
class="dropdown-item text-danger"
|
||||||
type="button"
|
type="button"
|
||||||
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
{{on "click" (fn @deletePhoto closeMenu)}}
|
||||||
>Delete Photo</button>
|
>Delete Photo</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -236,8 +239,23 @@ export default class PhotoGallery extends Component {
|
|||||||
@action
|
@action
|
||||||
async copyEventId(closeMenu) {
|
async copyEventId(closeMenu) {
|
||||||
if (this.currentPhoto?.eventId) {
|
if (this.currentPhoto?.eventId) {
|
||||||
|
let value = this.currentPhoto.eventId;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(this.currentPhoto.eventId);
|
const nevent = encodePointer({
|
||||||
|
id: this.currentPhoto.eventId,
|
||||||
|
relays: this.nostrData
|
||||||
|
.getEventRelays(this.currentPhoto.eventId)
|
||||||
|
.slice(0, MAX_NEVENT_RELAY_HINTS),
|
||||||
|
author: this.currentPhoto.pubkey,
|
||||||
|
kind: 360,
|
||||||
|
});
|
||||||
|
if (nevent) value = nevent;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to encode nevent, copying raw event ID:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
this.toast.show('Event ID copied to clipboard');
|
this.toast.show('Event ID copied to clipboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to copy event ID:', err);
|
console.error('Failed to copy event ID:', err);
|
||||||
@@ -259,21 +277,27 @@ export default class PhotoGallery extends Component {
|
|||||||
this.zapModalOpen = false;
|
this.zapModalOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
deletePhotoTask = task(async (closeMenu) => {
|
@action
|
||||||
|
deletePhoto(closeMenu, e) {
|
||||||
|
e?.stopPropagation();
|
||||||
|
if (closeMenu) closeMenu();
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
'Are you sure you want to delete this photo? This cannot be undone.'
|
'Are you sure you want to delete this photo? This cannot be undone.'
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (closeMenu) closeMenu();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.deletePhotoTask.perform();
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePhotoTask = task(async () => {
|
||||||
|
const deletedEventId = this.currentPhoto.eventId;
|
||||||
|
const deletedIndex = this.args.photos?.indexOf(this.currentPhoto) ?? -1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const eventId = this.currentPhoto.eventId;
|
|
||||||
|
|
||||||
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
|
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
|
||||||
const tags = [['e', eventId]];
|
const tags = [['e', deletedEventId]];
|
||||||
|
|
||||||
if (this.currentPhoto.placeIdentifier) {
|
if (this.currentPhoto.placeIdentifier) {
|
||||||
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
||||||
@@ -284,7 +308,14 @@ export default class PhotoGallery extends Component {
|
|||||||
.modifyPublicTags(() => tags)
|
.modifyPublicTags(() => tags)
|
||||||
.as(this.nostrAuth.signer)
|
.as(this.nostrAuth.signer)
|
||||||
.sign();
|
.sign();
|
||||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
const responses = await this.nostrRelay.publish(
|
||||||
|
this.nostrData.activeWriteRelays,
|
||||||
|
event
|
||||||
|
);
|
||||||
|
this.nostrData.recordPublishResult(event.id, responses);
|
||||||
|
if (!responses?.some((res) => res.ok)) {
|
||||||
|
throw new Error('Failed to publish deletion event.');
|
||||||
|
}
|
||||||
|
|
||||||
// Remove from local store by adding the kind 5 to it
|
// Remove from local store by adding the kind 5 to it
|
||||||
this.nostrData.store.add(event);
|
this.nostrData.store.add(event);
|
||||||
@@ -316,12 +347,20 @@ export default class PhotoGallery extends Component {
|
|||||||
|
|
||||||
this.toast.show('Photo deleted successfully');
|
this.toast.show('Photo deleted successfully');
|
||||||
|
|
||||||
if (closeMenu) closeMenu();
|
// Navigate to the next remaining photo, or close the gallery if none left.
|
||||||
this.handleClose();
|
// this.args.photos may be stale until Glimmer re-renders, so filter locally.
|
||||||
|
const remaining = (this.args.photos || []).filter(
|
||||||
|
(p) => p.eventId !== deletedEventId
|
||||||
|
);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
this.handleClose();
|
||||||
|
} else {
|
||||||
|
this.currentPhoto =
|
||||||
|
remaining[Math.min(deletedIndex, remaining.length - 1)];
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete photo:', e);
|
console.error('Failed to delete photo:', e);
|
||||||
this.toast.show('Failed to delete photo: ' + e.message);
|
this.toast.show('Failed to delete photo: ' + e.message);
|
||||||
if (closeMenu) closeMenu();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,7 +372,7 @@ export default class PhotoGallery extends Component {
|
|||||||
@handleKeydown={{this.handleKeydown}}
|
@handleKeydown={{this.handleKeydown}}
|
||||||
@copyEventId={{this.copyEventId}}
|
@copyEventId={{this.copyEventId}}
|
||||||
@canDeletePhoto={{this.canDeletePhoto}}
|
@canDeletePhoto={{this.canDeletePhoto}}
|
||||||
@deletePhotoTask={{this.deletePhotoTask}}
|
@deletePhoto={{this.deletePhoto}}
|
||||||
@handleClose={{this.handleClose}}
|
@handleClose={{this.handleClose}}
|
||||||
@photos={{@photos}}
|
@photos={{@photos}}
|
||||||
@currentPhoto={{this.currentPhoto}}
|
@currentPhoto={{this.currentPhoto}}
|
||||||
@@ -356,7 +395,7 @@ export default class PhotoGallery extends Component {
|
|||||||
@handleKeydown={{this.handleKeydown}}
|
@handleKeydown={{this.handleKeydown}}
|
||||||
@copyEventId={{this.copyEventId}}
|
@copyEventId={{this.copyEventId}}
|
||||||
@canDeletePhoto={{this.canDeletePhoto}}
|
@canDeletePhoto={{this.canDeletePhoto}}
|
||||||
@deletePhotoTask={{this.deletePhotoTask}}
|
@deletePhoto={{this.deletePhoto}}
|
||||||
@handleClose={{this.handleClose}}
|
@handleClose={{this.handleClose}}
|
||||||
@photos={{@photos}}
|
@photos={{@photos}}
|
||||||
@currentPhoto={{this.currentPhoto}}
|
@currentPhoto={{this.currentPhoto}}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import NostrConnect from './nostr-connect';
|
|||||||
import Modal from './modal';
|
import Modal from './modal';
|
||||||
import PhotoCarousel from './photo-carousel';
|
import PhotoCarousel from './photo-carousel';
|
||||||
import PhotoGallery from './photo-gallery';
|
import PhotoGallery from './photo-gallery';
|
||||||
|
import PlacePaymentMethods from './place-payment-methods';
|
||||||
|
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
@@ -113,6 +114,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
|
@action
|
||||||
startEditing() {
|
startEditing() {
|
||||||
if (!this.isSaved) return; // Only allow editing saved places
|
if (!this.isSaved) return; // Only allow editing saved places
|
||||||
@@ -304,6 +316,70 @@ export default class PlaceDetails extends Component {
|
|||||||
return this.formatMultiLine(val);
|
return this.formatMultiLine(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get operatorNames() {
|
||||||
|
const raw = this.tags.operator;
|
||||||
|
if (!raw) return null;
|
||||||
|
const values = raw
|
||||||
|
.split(';')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(
|
||||||
|
(value) =>
|
||||||
|
value && value.toLowerCase() !== 'yes' && value.toLowerCase() !== 'no'
|
||||||
|
);
|
||||||
|
if (values.length === 0) return null;
|
||||||
|
return this.formatMultiLine(values.join(';'));
|
||||||
|
}
|
||||||
|
|
||||||
|
get operatorWebsite() {
|
||||||
|
const val = this.tags['operator:website'];
|
||||||
|
return val ? val.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get operatorWebsiteLabel() {
|
||||||
|
return this.operatorWebsite ? this.getDomain(this.operatorWebsite) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get operatorWikidata() {
|
||||||
|
const val = this.tags['operator:wikidata'];
|
||||||
|
if (!val) return null;
|
||||||
|
return (
|
||||||
|
val
|
||||||
|
.split(';')
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean)[0] || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get operatorWikipedia() {
|
||||||
|
const val = this.tags['operator:wikipedia'];
|
||||||
|
if (!val) return null;
|
||||||
|
return (
|
||||||
|
val
|
||||||
|
.split(';')
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean)[0] || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get operatorWikipediaUrl() {
|
||||||
|
const val = this.operatorWikipedia;
|
||||||
|
if (!val) return null;
|
||||||
|
const match = val.match(/^([a-z-]+):(.+)$/i);
|
||||||
|
if (match) {
|
||||||
|
return `https://${match[1]}.wikipedia.org/wiki/${match[2]}`;
|
||||||
|
}
|
||||||
|
return `https://wikipedia.org/wiki/${val}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get showOperator() {
|
||||||
|
return !!(
|
||||||
|
this.operatorNames ||
|
||||||
|
this.operatorWebsite ||
|
||||||
|
this.operatorWikidata ||
|
||||||
|
this.operatorWikipedia
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
get cuisine() {
|
get cuisine() {
|
||||||
if (!this.tags.cuisine) return null;
|
if (!this.tags.cuisine) return null;
|
||||||
return this.tags.cuisine
|
return this.tags.cuisine
|
||||||
@@ -456,6 +532,8 @@ export default class PlaceDetails extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PlacePaymentMethods @tags={{this.tags}} />
|
||||||
|
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
|
|
||||||
{{#if this.cuisine}}
|
{{#if this.cuisine}}
|
||||||
@@ -557,6 +635,45 @@ export default class PlaceDetails extends Component {
|
|||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.showOperator}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="briefcase" @title="Operator" />
|
||||||
|
<span>
|
||||||
|
{{this.operatorNames}}
|
||||||
|
{{#if this.operatorWebsite}}
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
href={{this.operatorWebsite}}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{{this.operatorWebsiteLabel}}
|
||||||
|
</a>
|
||||||
|
{{/if}}
|
||||||
|
{{#if this.operatorWikidata}}
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
href="https://www.wikidata.org/wiki/{{this.operatorWikidata}}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Wikidata
|
||||||
|
</a>
|
||||||
|
{{/if}}
|
||||||
|
{{#if this.operatorWikipedia}}
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
href={{this.operatorWikipediaUrl}}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Wikipedia
|
||||||
|
</a>
|
||||||
|
{{/if}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
|
|
||||||
@@ -618,6 +735,23 @@ export default class PlaceDetails extends Component {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import Icon from './icon';
|
||||||
|
import { parsePaymentMethods } from '../utils/payment';
|
||||||
|
import tooltip from '../modifiers/tooltip';
|
||||||
|
|
||||||
|
export default class PlacePaymentMethods extends Component {
|
||||||
|
get payment() {
|
||||||
|
return parsePaymentMethods(this.args.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
get methods() {
|
||||||
|
const list = [];
|
||||||
|
if (this.payment.cash !== null) {
|
||||||
|
const isDenied = this.payment.cash === 'denied';
|
||||||
|
list.push({
|
||||||
|
id: 'cash',
|
||||||
|
isDenied,
|
||||||
|
icon: 'banknote',
|
||||||
|
color: 'currentColor',
|
||||||
|
description: isDenied ? 'No cash' : 'Cash accepted',
|
||||||
|
hasBadge: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.payment.cards !== null) {
|
||||||
|
const isDenied = this.payment.cards === 'denied';
|
||||||
|
list.push({
|
||||||
|
id: 'cards',
|
||||||
|
isDenied,
|
||||||
|
icon: 'payment-card',
|
||||||
|
color: 'currentColor',
|
||||||
|
description: isDenied ? 'No cards' : 'Cards accepted',
|
||||||
|
hasBadge: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.payment.bitcoin.status !== null) {
|
||||||
|
const isDenied = this.payment.bitcoin.status === 'denied';
|
||||||
|
let description = 'No Bitcoin';
|
||||||
|
if (!isDenied) {
|
||||||
|
description = this.payment.bitcoin.lightning
|
||||||
|
? 'Bitcoin (Lightning) accepted'
|
||||||
|
: 'Bitcoin (On-chain) accepted';
|
||||||
|
}
|
||||||
|
list.push({
|
||||||
|
id: 'bitcoin',
|
||||||
|
isDenied,
|
||||||
|
icon: 'bitcoin',
|
||||||
|
width: 17,
|
||||||
|
height: 22,
|
||||||
|
color: 'currentColor',
|
||||||
|
description,
|
||||||
|
hasBadge: !isDenied && this.payment.bitcoin.lightning,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{! template-lint-disable no-unsupported-role-attributes }}
|
||||||
|
{{#if this.payment.hasPaymentInfo}}
|
||||||
|
<div class="place-payment-methods" aria-label="Payment methods">
|
||||||
|
{{#each this.methods as |method|}}
|
||||||
|
<span
|
||||||
|
class="payment-method {{if method.isDenied 'is-denied'}}"
|
||||||
|
data-test-payment-method={{method.id}}
|
||||||
|
aria-label={{method.description}}
|
||||||
|
aria-description={{method.description}}
|
||||||
|
tabindex="0"
|
||||||
|
{{tooltip}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
@name={{method.icon}}
|
||||||
|
@size={{22}}
|
||||||
|
@width={{method.width}}
|
||||||
|
@height={{method.height}}
|
||||||
|
@color={{method.color}}
|
||||||
|
/>
|
||||||
|
{{#if method.hasBadge}}
|
||||||
|
<span
|
||||||
|
class="payment-method-badge"
|
||||||
|
data-test-payment-badge="lightning"
|
||||||
|
>
|
||||||
|
<Icon @name="zap" @size={{13}} @color="#fff" @filled={{true}} />
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</span>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -211,7 +211,14 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
.modifyPublicTags(() => tags)
|
.modifyPublicTags(() => tags)
|
||||||
.as(this.nostrAuth.signer)
|
.as(this.nostrAuth.signer)
|
||||||
.sign();
|
.sign();
|
||||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
const responses = await this.nostrRelay.publish(
|
||||||
|
this.nostrData.activeWriteRelays,
|
||||||
|
event
|
||||||
|
);
|
||||||
|
this.nostrData.recordPublishResult(event.id, responses);
|
||||||
|
if (!responses?.some((res) => res.ok)) {
|
||||||
|
throw new Error('Failed to publish event.');
|
||||||
|
}
|
||||||
this.nostrData.store.add(event);
|
this.nostrData.store.add(event);
|
||||||
|
|
||||||
this.toast.show('Photo published successfully');
|
this.toast.show('Photo published successfully');
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import PlaceDetails from './place-details';
|
|||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||||
import restoreScroll from '../modifiers/restore-scroll';
|
import restoreScroll from '../modifiers/restore-scroll';
|
||||||
|
|
||||||
export default class PlacesSidebar extends Component {
|
export default class PlacesSidebar extends Component {
|
||||||
@@ -150,7 +151,11 @@ export default class PlacesSidebar extends Component {
|
|||||||
|
|
||||||
get isNearbySearch() {
|
get isNearbySearch() {
|
||||||
const qp = this.router.currentRoute.queryParams;
|
const qp = this.router.currentRoute.queryParams;
|
||||||
return !qp.q && !qp.category && qp.lat && qp.lon;
|
// New searches use ?category=nearby; keep supporting legacy lat/lon-only URLs
|
||||||
|
return (
|
||||||
|
qp.category === NEARBY_CATEGORY.id ||
|
||||||
|
(!qp.q && !qp.category && qp.lat && qp.lon)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasHeaderPhoto() {
|
get hasHeaderPhoto() {
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ export default class SearchBoxComponent extends Component {
|
|||||||
(or
|
(or
|
||||||
(eq this.mapUi.loadingState.type "text")
|
(eq this.mapUi.loadingState.type "text")
|
||||||
(eq this.mapUi.loadingState.type "category")
|
(eq this.mapUi.loadingState.type "category")
|
||||||
|
(eq this.mapUi.loadingState.type "nearby")
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
<Icon @name="loading-ring" @size={{20}} />
|
<Icon @name="loading-ring" @size={{20}} />
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="tab-nav">
|
||||||
|
{{#each @tabs as |tab|}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab-nav-button {{if (eq @active tab.value) 'is-active'}}"
|
||||||
|
{{on "click" (fn @onChange tab.value)}}
|
||||||
|
>
|
||||||
|
{{tab.label}}
|
||||||
|
</button>
|
||||||
|
{{/each}}
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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) => {
|
||||||
|
await this.activity.load(pubkey);
|
||||||
|
});
|
||||||
|
|
||||||
|
get scrollTop() {
|
||||||
|
return this.mapUi.getScrollPosition('activity');
|
||||||
|
}
|
||||||
|
|
||||||
|
get items() {
|
||||||
|
return this.activity.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isLoading() {
|
||||||
|
return this.activity.isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected() {
|
||||||
|
return this.nostrAuth.isConnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
get sourceMode() {
|
||||||
|
return this.activity.sourceMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isLoadingMore() {
|
||||||
|
return this.activity.isLoadingMore;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setSourceMode(mode) {
|
||||||
|
this.activity.setSourceMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
loadMore() {
|
||||||
|
this.activity.loadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ function getPlaceTime(place) {
|
|||||||
return isNaN(parsed) ? 0 : parsed;
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SAVED_LIST_ID = 'saved';
|
||||||
|
|
||||||
export default class ListsListController extends Controller {
|
export default class ListsListController extends Controller {
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@@ -21,18 +23,34 @@ export default class ListsListController extends Controller {
|
|||||||
|
|
||||||
@tracked model;
|
@tracked model;
|
||||||
@tracked loadedPlaces = [];
|
@tracked loadedPlaces = [];
|
||||||
|
@tracked loadedListId = null;
|
||||||
|
|
||||||
get listId() {
|
get listId() {
|
||||||
return this.model?.list_id;
|
return this.model?.list_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isLoading() {
|
||||||
|
// Only show the spinner when there is nothing to display yet. On re-entry
|
||||||
|
// the previously loaded places (plus live storage state) render instantly,
|
||||||
|
// so we avoid a loading flash and preserve restored scroll position.
|
||||||
|
return this.loadPlacesTask.isRunning && this.places.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
loadPlacesTask = task({ restartable: true }, async (listId) => {
|
loadPlacesTask = task({ restartable: true }, async (listId) => {
|
||||||
|
// Already loaded this list: keep the existing places so returning from
|
||||||
|
// place details is instant (mirrors the activity service's load guard).
|
||||||
|
if (this.loadedListId === listId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadedListId = listId;
|
||||||
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
|
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
|
||||||
try {
|
try {
|
||||||
this.loadedPlaces = await this.storage.getPlacesInList(listId);
|
this.loadedPlaces = await this.storage.getPlacesInList(listId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load places in list', listId, e);
|
console.error('Failed to load places in list', listId, e);
|
||||||
this.loadedPlaces = [];
|
this.loadedPlaces = [];
|
||||||
|
this.loadedListId = null; // Allow a retry on next entry
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +59,12 @@ export default class ListsListController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get listColor() {
|
get listColor() {
|
||||||
|
if (this.listId === SAVED_LIST_ID) {
|
||||||
|
return getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--default-list-color')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||||
if (list && list.color) {
|
if (list && list.color) {
|
||||||
return list.color;
|
return list.color;
|
||||||
@@ -51,11 +75,21 @@ export default class ListsListController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get listTitle() {
|
get listTitle() {
|
||||||
|
if (this.listId === SAVED_LIST_ID) {
|
||||||
|
return 'Saved Places';
|
||||||
|
}
|
||||||
|
|
||||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||||
return list ? list.title : 'Collections';
|
return list ? list.title : 'Collections';
|
||||||
}
|
}
|
||||||
|
|
||||||
get places() {
|
get places() {
|
||||||
|
if (this.listId === SAVED_LIST_ID) {
|
||||||
|
return [...this.storage.savedPlaces].sort(
|
||||||
|
(a, b) => getPlaceTime(b) - getPlaceTime(a)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const currentList = this.storage.lists.find((l) => l.id === this.listId);
|
const currentList = this.storage.lists.find((l) => l.id === this.listId);
|
||||||
const placeRefsIds = new Set(
|
const placeRefsIds = new Set(
|
||||||
currentList?.placeRefs?.map((ref) => ref.id) || []
|
currentList?.placeRefs?.map((ref) => ref.id) || []
|
||||||
|
|||||||
+64
-40
@@ -2,6 +2,7 @@ import Controller from '@ember/controller';
|
|||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { task } from 'ember-concurrency';
|
import { task } from 'ember-concurrency';
|
||||||
import { getDistance } from '../utils/geo';
|
import { getDistance } from '../utils/geo';
|
||||||
|
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||||
|
|
||||||
export default class SearchController extends Controller {
|
export default class SearchController extends Controller {
|
||||||
@service osm;
|
@service osm;
|
||||||
@@ -19,6 +20,40 @@ export default class SearchController extends Controller {
|
|||||||
selected = null;
|
selected = null;
|
||||||
category = null;
|
category = null;
|
||||||
|
|
||||||
|
async fetchNearbyPois(lat, lon) {
|
||||||
|
const searchRadius = 50;
|
||||||
|
|
||||||
|
// Fetch POIs from Overpass
|
||||||
|
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||||
|
|
||||||
|
// Get cached/saved places in search radius
|
||||||
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||||
|
return dist <= searchRadius;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge local matches
|
||||||
|
localMatches.forEach((local) => {
|
||||||
|
const exists = pois.find(
|
||||||
|
(poi) =>
|
||||||
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
pois.push(local);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by distance from the search center
|
||||||
|
return pois
|
||||||
|
.map((p) => ({
|
||||||
|
...p,
|
||||||
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a._distance - b._distance);
|
||||||
|
}
|
||||||
|
|
||||||
fetchResultsTask = task({ restartable: true }, async (params) => {
|
fetchResultsTask = task({ restartable: true }, async (params) => {
|
||||||
// 1. Check if the incoming parameters match our currently loaded search
|
// 1. Check if the incoming parameters match our currently loaded search
|
||||||
const isSameSearch =
|
const isSameSearch =
|
||||||
@@ -50,8 +85,22 @@ export default class SearchController extends Controller {
|
|||||||
let loadingValue = null;
|
let loadingValue = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Case 0: Category Search (category parameter present)
|
// Case 0: Nearby Search (map click or ?category=nearby)
|
||||||
if (params.category && lat && lon) {
|
const isNearbySearch =
|
||||||
|
lat &&
|
||||||
|
lon &&
|
||||||
|
!params.q &&
|
||||||
|
(!params.category || params.category === NEARBY_CATEGORY.id);
|
||||||
|
|
||||||
|
if (isNearbySearch) {
|
||||||
|
loadingType = 'nearby';
|
||||||
|
loadingValue = 'nearby';
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
|
pois = await this.fetchNearbyPois(lat, lon);
|
||||||
|
}
|
||||||
|
// Case 1: Category Search (category parameter present)
|
||||||
|
else if (params.category && lat && lon) {
|
||||||
loadingType = 'category';
|
loadingType = 'category';
|
||||||
loadingValue = params.category;
|
loadingValue = params.category;
|
||||||
this.mapUi.startLoading(loadingType, loadingValue);
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
@@ -88,7 +137,7 @@ export default class SearchController extends Controller {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => a._distance - b._distance);
|
.sort((a, b) => a._distance - b._distance);
|
||||||
}
|
}
|
||||||
// Case 1: Text Search (q parameter present)
|
// Case 2: Text Search (q parameter present)
|
||||||
else if (params.q) {
|
else if (params.q) {
|
||||||
loadingType = 'text';
|
loadingType = 'text';
|
||||||
loadingValue = params.q;
|
loadingValue = params.q;
|
||||||
@@ -121,43 +170,6 @@ export default class SearchController extends Controller {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Case 2: Nearby Search (lat/lon present, no q)
|
|
||||||
else if (lat && lon) {
|
|
||||||
// Nearby search does NOT trigger loading state (pulse is used instead)
|
|
||||||
const searchRadius = 50; // Default radius
|
|
||||||
|
|
||||||
// Fetch POIs from Overpass
|
|
||||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
|
||||||
|
|
||||||
// Get cached/saved places in search radius
|
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
|
||||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
|
||||||
return dist <= searchRadius;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge local matches
|
|
||||||
localMatches.forEach((local) => {
|
|
||||||
const exists = pois.find(
|
|
||||||
(poi) =>
|
|
||||||
(local.osmId && poi.osmId === local.osmId) ||
|
|
||||||
(poi.id && poi.id === local.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
pois.push(local);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by distance from click
|
|
||||||
pois = pois
|
|
||||||
.map((p) => {
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a._distance - b._distance);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search request failed.', error);
|
console.error('Search request failed.', error);
|
||||||
this.toast.show('Search request failed. Please try again.');
|
this.toast.show('Search request failed. Please try again.');
|
||||||
@@ -176,6 +188,18 @@ export default class SearchController extends Controller {
|
|||||||
return saved || p;
|
return saved || p;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A search with results is a restorable context (e.g. for "up" navigation
|
||||||
|
// from place details). Empty searches are intentionally not restorable, so
|
||||||
|
// they don't reopen an invisible "no results" drawer later on.
|
||||||
|
if (pois.length > 0) {
|
||||||
|
this.mapUi.currentSearch = {
|
||||||
|
q: params.q,
|
||||||
|
category: params.category,
|
||||||
|
lat: params.lat,
|
||||||
|
lon: params.lon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const targetName = params.selected || params.q;
|
const targetName = params.selected || params.q;
|
||||||
|
|
||||||
if (targetName && pois.length > 0) {
|
if (targetName && pois.length > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="8.157 6 15.088 19.949">
|
||||||
|
<path d="M23.189 14.02c.314-2.096-1.283-3.223-3.465-3.975l.708-2.84-1.728-.43-.69 2.765c-.454-.114-.92-.22-1.385-.326l.695-2.783L15.596 6l-.708 2.839c-.376-.086-.746-.17-1.104-.26l.002-.009-2.384-.595-.46 1.846s1.283.294 1.256.312c.7.175.826.638.805 1.006l-.806 3.235c.048.012.11.03.18.057l-.183-.045-1.13 4.532c-.086.212-.303.531-.793.41.018.025-1.256-.313-1.256-.313l-.858 1.978 2.25.561c.418.105.828.215 1.231.318l-.715 2.872 1.727.43.708-2.84c.472.127.93.245 1.378.357l-.706 2.828 1.728.43.715-2.866c2.948.558 5.164.333 6.097-2.333.752-2.146-.037-3.385-1.588-4.192 1.13-.26 1.98-1.003 2.207-2.538zm-3.95 5.538c-.533 2.147-4.148.986-5.32.695l.95-3.805c1.172.293 4.929.872 4.37 3.11zm.535-5.569c-.487 1.953-3.495.96-4.47.717l.86-3.45c.975.243 4.118.696 3.61 2.733z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 872 B |
@@ -0,0 +1,31 @@
|
|||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
|
||||||
|
export default modifier((element, [callback, disabled]) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
let observer;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: '200px',
|
||||||
|
threshold: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ Router.map(function () {
|
|||||||
this.route('list', { path: '/:list_id' });
|
this.route('list', { path: '/:list_id' });
|
||||||
});
|
});
|
||||||
this.route('contributions');
|
this.route('contributions');
|
||||||
|
this.route('activity');
|
||||||
this.route('oauth', function () {
|
this.route('oauth', function () {
|
||||||
this.route('osm-callback', { path: '/osm/callback' });
|
this.route('osm-callback', { path: '/osm/callback' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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();
|
||||||
|
// Re-request profiles for senders that couldn't be resolved when the feed
|
||||||
|
// was first loaded (e.g. metadata published since then). No-op on the first
|
||||||
|
// entry, when there are no items yet.
|
||||||
|
void this.activity.retryUnresolvedProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupController(controller, model) {
|
||||||
|
super.setupController(controller, model);
|
||||||
|
if (controller && controller.loadTask) {
|
||||||
|
controller.loadTask.perform(this.nostrAuth.pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
// Don't call activity.stop() — keep state alive when navigating to place
|
||||||
|
// details and back. The service's willDestroy() handles final cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,22 @@
|
|||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { service } from '@ember/service';
|
|
||||||
|
|
||||||
export default class ListsListRoute extends Route {
|
export default class ListsListRoute extends Route {
|
||||||
@service storage;
|
|
||||||
|
|
||||||
model(params) {
|
model(params) {
|
||||||
// Resolve instantly so transition happens in 0ms!
|
// Resolve instantly so transition happens in 0ms!
|
||||||
return { list_id: params.list_id };
|
return { list_id: params.list_id };
|
||||||
}
|
}
|
||||||
|
|
||||||
setupController(controller, model) {
|
setupController(controller, model) {
|
||||||
console.debug('DEBUG: setupController controller is:', controller);
|
|
||||||
console.debug(
|
|
||||||
'DEBUG: controller.loadPlacesTask is:',
|
|
||||||
controller?.loadPlacesTask
|
|
||||||
);
|
|
||||||
controller.model = model;
|
controller.model = model;
|
||||||
super.setupController(controller, model);
|
super.setupController(controller, model);
|
||||||
|
if (model.list_id === 'saved') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (controller && controller.loadPlacesTask) {
|
if (controller && controller.loadPlacesTask) {
|
||||||
controller.loadPlacesTask.perform(model.list_id);
|
controller.loadPlacesTask.perform(model.list_id);
|
||||||
} else {
|
} else {
|
||||||
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
|
console.error('controller.loadPlacesTask is undefined');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,10 +24,6 @@ export default class SearchRoute extends Route {
|
|||||||
|
|
||||||
// Trigger the background task to fetch results
|
// Trigger the background task to fetch results
|
||||||
controller.fetchResultsTask.perform(model);
|
controller.fetchResultsTask.perform(model);
|
||||||
|
|
||||||
// Store current search params to allow "Up" navigation from place details
|
|
||||||
const { q, category, lat, lon } = this.paramsFor('search');
|
|
||||||
this.mapUi.currentSearch = { q, category, lat, lon };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetController(controller, isExiting) {
|
resetController(controller, isExiting) {
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
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,
|
||||||
|
groupSocialPhotos,
|
||||||
|
} from '../utils/activity';
|
||||||
|
|
||||||
|
const SINCE_WINDOW = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||||
|
const MIN_SINCE = Math.floor(new Date('2026-04-20').getTime() / 1000);
|
||||||
|
const MAX_WINDOW = 960 * 24 * 60 * 60; // 960 days in seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. Fetch kind 9735 zap receipts where the user is the recipient.
|
||||||
|
* 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 = [];
|
||||||
|
@tracked sourceMode = 'home';
|
||||||
|
@tracked isLoading = false;
|
||||||
|
@tracked isLoadingMore = false;
|
||||||
|
|
||||||
|
_profileSubs = new Map();
|
||||||
|
_userPubkey = null;
|
||||||
|
_zapItems = [];
|
||||||
|
_socialItems = [];
|
||||||
|
_lastSocialEvents = [];
|
||||||
|
_since = null;
|
||||||
|
_sourceMode = 'home';
|
||||||
|
_isLoadingMore = false;
|
||||||
|
_cacheReadyCallback = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the user's incoming zap receipts and social photo activity.
|
||||||
|
*
|
||||||
|
* When a Nostr account is connected, zaps received on the user's photos are
|
||||||
|
* loaded and the 'home' mode shows photos from followed contacts. When no
|
||||||
|
* account is connected, zaps and 'home' mode are skipped, but 'explore'
|
||||||
|
* mode still loads photos from trusted relays.
|
||||||
|
*
|
||||||
|
* If the initial 30-day window returns no events, the window expands
|
||||||
|
* exponentially (up to 960 days) until events are found or the minimum
|
||||||
|
* timestamp (2026-04-20) is reached.
|
||||||
|
*
|
||||||
|
* @param {string|null} pubkey The user's Nostr pubkey, or null
|
||||||
|
*/
|
||||||
|
async load(pubkey) {
|
||||||
|
if (this.items.length > 0) return;
|
||||||
|
this._userPubkey = pubkey || null;
|
||||||
|
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
|
||||||
|
this.isLoading = true;
|
||||||
|
this._cacheReadyCallback = () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pubkey) {
|
||||||
|
await this.nostrData.loadMyContributions(pubkey);
|
||||||
|
await this.nostrData.loadProfile(pubkey);
|
||||||
|
await this.nostrData.whenContactsLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._socialItems = [];
|
||||||
|
this._zapItems = [];
|
||||||
|
this._lastSocialEvents = [];
|
||||||
|
this._mergeItems();
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await this._fetchWithBackoff(this._since, now);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
this._cacheReadyCallback = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the activity source mode (e.g. 'home' for followee photos,
|
||||||
|
* 'explore' for all photos from trusted relays). Resets to a fresh
|
||||||
|
* 30-day window and re-fetches both zaps and photos with the new mode.
|
||||||
|
*
|
||||||
|
* @param {string} mode The new source mode
|
||||||
|
*/
|
||||||
|
async setSourceMode(mode) {
|
||||||
|
if (mode === this._sourceMode) return;
|
||||||
|
this._sourceMode = mode;
|
||||||
|
this.sourceMode = mode;
|
||||||
|
|
||||||
|
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
|
||||||
|
this._socialItems = [];
|
||||||
|
this._zapItems = [];
|
||||||
|
this._lastSocialEvents = [];
|
||||||
|
this._mergeItems();
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
this._cacheReadyCallback = () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await this._fetchWithBackoff(this._since, now);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
this._cacheReadyCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.retryUnresolvedProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-requests kind 0 metadata for senders whose profiles could not be
|
||||||
|
* resolved. Called when the activity view is re-entered or the source mode
|
||||||
|
* changes, so a profile published after the feed was first loaded can still
|
||||||
|
* show up in the same session.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async retryUnresolvedProfiles() {
|
||||||
|
const pubkeys = new Set();
|
||||||
|
for (const item of this.items) {
|
||||||
|
if (item.senderPubkey && item.senderProfileLoading) {
|
||||||
|
pubkeys.add(item.senderPubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pubkeys.size === 0) return;
|
||||||
|
await this.nostrData.refreshProfiles([...pubkeys]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads older events by extending the time window further back.
|
||||||
|
* Only processes newly fetched events — existing items are not re-processed.
|
||||||
|
*
|
||||||
|
* If an empty window is returned (no photos AND no zaps), the window size
|
||||||
|
* doubles (up to 960 days) and the fetch is retried automatically until
|
||||||
|
* events are found or the minimum timestamp (2026-04-20) is reached.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async loadMore() {
|
||||||
|
if (this._isLoadingMore || !this._since || !this.items.length) return;
|
||||||
|
this._isLoadingMore = true;
|
||||||
|
this.isLoadingMore = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startSince = this._since - SINCE_WINDOW;
|
||||||
|
await this._fetchWithBackoff(startSince, this._since);
|
||||||
|
} finally {
|
||||||
|
this._isLoadingMore = false;
|
||||||
|
this.isLoadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops subscriptions and clears the timeline. Called when leaving the
|
||||||
|
* activity route.
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
this._cleanupProfileSubs();
|
||||||
|
this._zapItems = [];
|
||||||
|
this._socialItems = [];
|
||||||
|
this._lastSocialEvents = [];
|
||||||
|
this.items = [];
|
||||||
|
this._userPubkey = null;
|
||||||
|
this._since = null;
|
||||||
|
this._isLoadingMore = false;
|
||||||
|
this.isLoadingMore = false;
|
||||||
|
this.isLoading = false;
|
||||||
|
this.placeNameResolver.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
this.stop();
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes photo events into social items and appends them to the timeline.
|
||||||
|
*
|
||||||
|
* @param {object[]} events Photo events to process
|
||||||
|
* @returns {boolean} True if any items were added
|
||||||
|
*/
|
||||||
|
_processPhotoEvents(events) {
|
||||||
|
if (events.length === 0) return false;
|
||||||
|
|
||||||
|
this._lastSocialEvents = [...this._lastSocialEvents, ...events];
|
||||||
|
|
||||||
|
const filtered = events.filter((e) => this._matchesSourceMode(e));
|
||||||
|
if (filtered.length === 0) return false;
|
||||||
|
|
||||||
|
const newEntries = groupSocialPhotos(filtered);
|
||||||
|
|
||||||
|
const seenPhotoIds = new Set();
|
||||||
|
for (const entry of this._socialItems) {
|
||||||
|
for (const photo of entry.photos) {
|
||||||
|
seenPhotoIds.add(photo.eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupedEntries = newEntries.filter(
|
||||||
|
(entry) => !entry.photos.every((photo) => seenPhotoIds.has(photo.eventId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dedupedEntries.length === 0) return false;
|
||||||
|
|
||||||
|
for (const entry of dedupedEntries) {
|
||||||
|
this._resolveSender(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of dedupedEntries) {
|
||||||
|
if (entry.osmId) {
|
||||||
|
const bookmarkName = this.placeNameResolver.resolveBookmark(
|
||||||
|
entry.osmId
|
||||||
|
);
|
||||||
|
if (bookmarkName) {
|
||||||
|
entry.placeName = bookmarkName;
|
||||||
|
entry.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._socialItems = [...this._socialItems, ...dedupedEntries];
|
||||||
|
this._mergeItems();
|
||||||
|
|
||||||
|
void this.placeNameResolver
|
||||||
|
.resolveInBackground(dedupedEntries)
|
||||||
|
.then(() => this._mergeItems());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes zap receipt events and appends them to the timeline.
|
||||||
|
*
|
||||||
|
* @param {object[]} events Zap receipt events to process
|
||||||
|
* @returns {boolean} True if any items were added
|
||||||
|
*/
|
||||||
|
_processZapEventsIntoTimeline(events) {
|
||||||
|
if (events.length === 0) return false;
|
||||||
|
|
||||||
|
const newZapEntries = this._processZapEvents(events, this._userPubkey);
|
||||||
|
if (newZapEntries.length === 0) return false;
|
||||||
|
|
||||||
|
this._zapItems = [...this._zapItems, ...newZapEntries];
|
||||||
|
this._mergeItems();
|
||||||
|
|
||||||
|
void this.placeNameResolver
|
||||||
|
.resolveInBackground(newZapEntries)
|
||||||
|
.then(() => this._mergeItems());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches photos and zaps for a single time window, processes them, and
|
||||||
|
* appends to the respective item arrays. Cache events are processed first
|
||||||
|
* (firing `_cacheReadyCallback` to dismiss the loading spinner), then
|
||||||
|
* network events are awaited and processed.
|
||||||
|
*
|
||||||
|
* @param {number} batchSince Start of the window (seconds)
|
||||||
|
* @param {number} batchUntil End of the window (seconds)
|
||||||
|
* @returns {Promise<boolean>} True if any events were found
|
||||||
|
*/
|
||||||
|
async _fetchAndProcessWindow(batchSince, batchUntil) {
|
||||||
|
const photoResult = await this.nostrData.fetchActivityPhotos(
|
||||||
|
batchSince,
|
||||||
|
batchUntil,
|
||||||
|
this._sourceMode
|
||||||
|
);
|
||||||
|
|
||||||
|
let zapResult = {
|
||||||
|
cacheEvents: [],
|
||||||
|
networkEvents: Promise.resolve([]),
|
||||||
|
};
|
||||||
|
if (this._sourceMode === 'home' && this._userPubkey) {
|
||||||
|
zapResult = await this.nostrData.fetchIncomingZaps(
|
||||||
|
this._userPubkey,
|
||||||
|
batchSince,
|
||||||
|
batchUntil
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Process cache events immediately
|
||||||
|
let added = false;
|
||||||
|
|
||||||
|
if (photoResult.cacheEvents.length > 0) {
|
||||||
|
added = this._processPhotoEvents(photoResult.cacheEvents) || added;
|
||||||
|
}
|
||||||
|
if (zapResult.cacheEvents.length > 0) {
|
||||||
|
added =
|
||||||
|
this._processZapEventsIntoTimeline(zapResult.cacheEvents) || added;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal that cached items are ready for display
|
||||||
|
if (added) {
|
||||||
|
this._cacheReadyCallback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Await network events and process them
|
||||||
|
const [networkPhotos, networkZaps] = await Promise.all([
|
||||||
|
photoResult.networkEvents,
|
||||||
|
zapResult.networkEvents,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (networkPhotos.length > 0) {
|
||||||
|
added = this._processPhotoEvents(networkPhotos) || added;
|
||||||
|
}
|
||||||
|
if (networkZaps.length > 0) {
|
||||||
|
added = this._processZapEventsIntoTimeline(networkZaps) || added;
|
||||||
|
}
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches events with exponential backoff. Starts with a 30-day window
|
||||||
|
* and doubles the window size on each empty result (up to 960 days),
|
||||||
|
* until events are found or MIN_SINCE is reached.
|
||||||
|
*
|
||||||
|
* @param {number} startSince Start of the first window (seconds)
|
||||||
|
* @param {number} startUntil End of the first window (seconds)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async _fetchWithBackoff(startSince, startUntil) {
|
||||||
|
let windowSize = SINCE_WINDOW;
|
||||||
|
let batchSince = startSince;
|
||||||
|
let batchUntil = startUntil;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (this._since === null) return; // stopped
|
||||||
|
|
||||||
|
// Clamp to MIN_SINCE — this is the final iteration if clamped
|
||||||
|
const isFinal = batchSince < MIN_SINCE;
|
||||||
|
if (isFinal) {
|
||||||
|
batchSince = MIN_SINCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await this._fetchAndProcessWindow(batchSince, batchUntil);
|
||||||
|
this._since = batchSince;
|
||||||
|
|
||||||
|
if (found || isFinal) break;
|
||||||
|
|
||||||
|
windowSize = Math.min(windowSize * 2, MAX_WINDOW);
|
||||||
|
batchUntil = batchSince;
|
||||||
|
batchSince = batchSince - windowSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_processZapEvents(receipts, pubkey) {
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
for (const receipt of receipts) {
|
||||||
|
const entry = parseZapReceipt(receipt, pubkey);
|
||||||
|
if (!entry) continue;
|
||||||
|
|
||||||
|
if (!enrichWithPhoto(entry, this.nostrData.store, pubkey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._resolveSender(entry);
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.osmId) {
|
||||||
|
const bookmarkName = this.placeNameResolver.resolveBookmark(
|
||||||
|
entry.osmId
|
||||||
|
);
|
||||||
|
if (bookmarkName) {
|
||||||
|
entry.placeName = bookmarkName;
|
||||||
|
entry.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateZapItems(receipts, pubkey) {
|
||||||
|
const entries = this._processZapEvents(receipts, pubkey);
|
||||||
|
|
||||||
|
this._zapItems = entries;
|
||||||
|
this._mergeItems();
|
||||||
|
|
||||||
|
void this.placeNameResolver.resolveInBackground(entries).then(() => {
|
||||||
|
this._mergeItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateSocialItems(events) {
|
||||||
|
const filtered = events.filter((e) => this._matchesSourceMode(e));
|
||||||
|
const entries = groupSocialPhotos(filtered);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
this._resolveSender(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.osmId) {
|
||||||
|
const bookmarkName = this.placeNameResolver.resolveBookmark(
|
||||||
|
entry.osmId
|
||||||
|
);
|
||||||
|
if (bookmarkName) {
|
||||||
|
entry.placeName = bookmarkName;
|
||||||
|
entry.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._socialItems = entries;
|
||||||
|
this._mergeItems();
|
||||||
|
|
||||||
|
void this.placeNameResolver.resolveInBackground(entries).then(() => {
|
||||||
|
this._mergeItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_mergeItems() {
|
||||||
|
const all =
|
||||||
|
this._sourceMode === 'explore'
|
||||||
|
? [...this._socialItems]
|
||||||
|
: [...this._zapItems, ...this._socialItems];
|
||||||
|
all.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
this.items = all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if an event should be included in the activity feed given
|
||||||
|
* the current source mode.
|
||||||
|
*
|
||||||
|
* - `'home'`: only events from followees (excluding own photos)
|
||||||
|
* - `'explore'`: events from non-followed authors trusted by relay provenance
|
||||||
|
* (excluding own photos). When no pubkey is connected, all trusted events
|
||||||
|
* pass (no own-photo or followee exclusion).
|
||||||
|
*/
|
||||||
|
_matchesSourceMode(event) {
|
||||||
|
if (this._sourceMode === 'home') {
|
||||||
|
return (
|
||||||
|
this._userPubkey &&
|
||||||
|
event.pubkey !== this._userPubkey &&
|
||||||
|
this.nostrData._contactPubkeys?.has(event.pubkey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this._sourceMode === 'explore') {
|
||||||
|
if (!this._userPubkey) {
|
||||||
|
return this.nostrData.isTrustedEvent(event);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
event.pubkey !== this._userPubkey &&
|
||||||
|
!this.nostrData._contactPubkeys?.has(event.pubkey) &&
|
||||||
|
this.nostrData.isTrustedEvent(event)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveSender(entry) {
|
||||||
|
const pubkey = entry.senderPubkey;
|
||||||
|
if (!pubkey) return;
|
||||||
|
|
||||||
|
if (!this._profileSubs.has(pubkey)) {
|
||||||
|
const sub = this.nostrData.store
|
||||||
|
.model(ProfileModel, pubkey)
|
||||||
|
.subscribe((profileContent) => {
|
||||||
|
this._applyProfileToPubkey(pubkey, profileContent);
|
||||||
|
});
|
||||||
|
this._profileSubs.set(pubkey, sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._applySenderProfile(entry, pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
_applySenderProfile(entry, pubkey) {
|
||||||
|
let profile = this.nostrData.getProfile(pubkey);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
this.items = [...this.items];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanupProfileSubs() {
|
||||||
|
for (const sub of this._profileSubs.values()) {
|
||||||
|
sub.unsubscribe();
|
||||||
|
}
|
||||||
|
this._profileSubs.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@ import Service, { service } from '@ember/service';
|
|||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { groupPhotoContributions } from '../utils/contributions';
|
import { groupPhotoContributions } from '../utils/contributions';
|
||||||
|
|
||||||
const NAME_CACHE_STORE = 'contributions-name-cache';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestrates loading the user's own Nostr contributions, grouping them into
|
* Orchestrates loading the user's own Nostr contributions, grouping them into
|
||||||
* timeline entries, and resolving place names from bookmarks or the OSM API.
|
* 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 {
|
export default class ContributionsService extends Service {
|
||||||
@service nostrData;
|
@service nostrData;
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
@service storage;
|
@service placeNameResolver;
|
||||||
@service osm;
|
|
||||||
@service localForage;
|
|
||||||
|
|
||||||
@tracked items = [];
|
@tracked items = [];
|
||||||
|
|
||||||
_sub = null;
|
_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.
|
* Loads the user's contributions and subscribes to live updates.
|
||||||
@@ -131,9 +63,7 @@ export default class ContributionsService extends Service {
|
|||||||
this._sub = null;
|
this._sub = null;
|
||||||
}
|
}
|
||||||
this.items = [];
|
this.items = [];
|
||||||
this._lastBatchSignature = '';
|
this.placeNameResolver.reset();
|
||||||
this._pendingBatchPromise = null;
|
|
||||||
this._unresolvable.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
willDestroy() {
|
willDestroy() {
|
||||||
@@ -148,9 +78,9 @@ export default class ContributionsService extends Service {
|
|||||||
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
||||||
// first render shows bookmarked place names without a "Loading…" flicker.
|
// first render shows bookmarked place names without a "Loading…" flicker.
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const bookmark = this.storage.findPlaceById(entry.osmId);
|
const bookmarkName = this.placeNameResolver.resolveBookmark(entry.osmId);
|
||||||
if (bookmark?.title) {
|
if (bookmarkName) {
|
||||||
entry.placeName = bookmark.title;
|
entry.placeName = bookmarkName;
|
||||||
entry.placeNameLoading = false;
|
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
|
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
|
||||||
// cache, then fall through to the network batch for the rest.
|
// cache, then fall through to the network batch for the rest.
|
||||||
void this._resolveFromCache(entries);
|
void this.placeNameResolver.resolveInBackground(entries).then(() => {
|
||||||
}
|
this.items = [...this.items];
|
||||||
|
});
|
||||||
_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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+745
-56
@@ -1,17 +1,21 @@
|
|||||||
import Service, { service } from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
import { tracked } from '@glimmer/tracking';
|
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 { EventStore } from 'applesauce-core/event-store';
|
||||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
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 { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||||
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
||||||
|
import { RelayGroup } from 'applesauce-relay';
|
||||||
import { NostrIDB, openDB } from 'nostr-idb';
|
import { NostrIDB, openDB } from 'nostr-idb';
|
||||||
import {
|
import {
|
||||||
excludeRequiredRelays,
|
excludeRequiredRelays,
|
||||||
mergeRequiredRelays,
|
mergeRequiredRelays,
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
|
relayUrlMatches,
|
||||||
uniqNormalizedRelays,
|
uniqNormalizedRelays,
|
||||||
} from '../utils/nostr';
|
} from '../utils/nostr';
|
||||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||||
@@ -24,34 +28,89 @@ const DIRECTORY_RELAYS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
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 {
|
export default class NostrDataService extends Service {
|
||||||
@service nostrRelay;
|
@service nostrRelay;
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
@service settings;
|
@service settings;
|
||||||
|
@service localForage;
|
||||||
|
|
||||||
store = new EventStore();
|
store = new EventStore();
|
||||||
|
|
||||||
@tracked profile = null;
|
@tracked profile = null;
|
||||||
@tracked mailboxes = null;
|
@tracked mailboxes = null;
|
||||||
|
@tracked contacts = null;
|
||||||
@tracked blossomServers = [];
|
@tracked blossomServers = [];
|
||||||
@tracked placePhotos = [];
|
@tracked placePhotos = [];
|
||||||
@tracked myContributionEvents = [];
|
@tracked myContributionEvents = [];
|
||||||
@tracked profiles = {};
|
@tracked profiles = {};
|
||||||
@tracked zapReceipts = {};
|
@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;
|
||||||
|
|
||||||
|
// Deferred that resolves when contacts are first loaded. Created in
|
||||||
|
// loadProfile, resolved in the ContactsModel callback.
|
||||||
|
_contactsResolver = null;
|
||||||
|
_contactsPromise = 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;
|
_profileSub = null;
|
||||||
_mailboxesSub = null;
|
_mailboxesSub = null;
|
||||||
|
_contactsSub = null;
|
||||||
_blossomSub = null;
|
_blossomSub = null;
|
||||||
_photosSub = null;
|
_photosSub = null;
|
||||||
_contributionsSub = null;
|
_contributionsSub = null;
|
||||||
|
_deletionsSub = null;
|
||||||
_profileModelSubs = new Map();
|
_profileModelSubs = new Map();
|
||||||
|
// Pubkeys whose profiles have already been requested at least once. Used to
|
||||||
|
// distinguish a first lookup (handled by the ProfileModel loader) from a
|
||||||
|
// retry on place re-entry, so we don't duplicate the initial request.
|
||||||
|
_attemptedProfilePubkeys = new Set();
|
||||||
|
|
||||||
_zapReceiptsSub = null;
|
_zapReceiptsSub = null;
|
||||||
_zapReceiptsNetworkSub = null;
|
_zapReceiptsNetworkSub = null;
|
||||||
_zapRefreshTimer = null;
|
_zapRefreshTimer = null;
|
||||||
_lastPhotoIds = new Set();
|
_lastPhotoIds = new Set();
|
||||||
|
_incomingZapsNetworkSub = null;
|
||||||
|
|
||||||
|
// Activity photos state: tracks the current time window and source mode
|
||||||
|
// so that the contacts callback can re-trigger when contacts arrive.
|
||||||
|
_activityPhotosSince = null;
|
||||||
|
_activityPhotosMode = 'home';
|
||||||
|
_activityPhotosNetworkSub = null;
|
||||||
|
|
||||||
_requestSub = null;
|
_requestSub = null;
|
||||||
_cachePromise = null;
|
_cachePromise = null;
|
||||||
@@ -87,10 +146,11 @@ export default class NostrDataService extends Service {
|
|||||||
this._stopPersisting = persistEventsToCache(
|
this._stopPersisting = persistEventsToCache(
|
||||||
this.store,
|
this.store,
|
||||||
async (events) => {
|
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(
|
const toCache = events.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.kind === 0 ||
|
e.kind === 0 ||
|
||||||
|
e.kind === 3 ||
|
||||||
e.kind === 5 ||
|
e.kind === 5 ||
|
||||||
e.kind === 10002 ||
|
e.kind === 10002 ||
|
||||||
e.kind === 10063 ||
|
e.kind === 10063 ||
|
||||||
@@ -109,6 +169,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
|
// Feed events from the relay pool into the event store
|
||||||
this.nostrRelay.pool.relays$.subscribe(() => {
|
this.nostrRelay.pool.relays$.subscribe(() => {
|
||||||
// Setup relay subscription tracking if needed, or we just rely on request()
|
// Setup relay subscription tracking if needed, or we just rely on request()
|
||||||
@@ -174,6 +250,160 @@ 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public getter for the normalized relays an event is known to have been
|
||||||
|
// seen on. Used to build shareable `nevent` pointers with relay hints.
|
||||||
|
getEventRelays(eventId) {
|
||||||
|
const relays = this._eventRelays.get(eventId);
|
||||||
|
return relays ? uniqNormalizedRelays([...relays]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the result of publishing an event to relays.
|
||||||
|
*
|
||||||
|
* Successful relays are merged into the provenance map so the event gets
|
||||||
|
* correct relay hints (e.g. in `nevent` pointers) and trust checks. The full
|
||||||
|
* per-relay responses are only debug-logged for now; this is the intended
|
||||||
|
* hook for a future publishing-status store/UI.
|
||||||
|
*
|
||||||
|
* @param {string} eventId The published event id
|
||||||
|
* @param {Array<{ok: boolean, message?: string, from: string}>} responses
|
||||||
|
*/
|
||||||
|
recordPublishResult(eventId, responses = []) {
|
||||||
|
console.debug('[nostr-data] Publish result', eventId, responses);
|
||||||
|
for (const res of responses || []) {
|
||||||
|
if (res?.ok && res.from) {
|
||||||
|
this._recordProvenance(eventId, res.from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
async loadPlacesInBounds(bbox) {
|
||||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||||
|
|
||||||
@@ -212,25 +442,13 @@ export default class NostrDataService extends Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire network request for new prefixes
|
// Fire network request for new prefixes (captures relay provenance for
|
||||||
this.nostrRelay.pool
|
// trust filtering; events are added to the store regardless of trust).
|
||||||
.request(this.activeReadRelays, [
|
this._requestContentWithProvenance(
|
||||||
{
|
this.activeReadRelays,
|
||||||
kinds: [360],
|
[{ kinds: [360], '#g': missingPrefixes }],
|
||||||
'#g': missingPrefixes,
|
'[nostr-data] Error fetching place photos by geohash:'
|
||||||
},
|
);
|
||||||
])
|
|
||||||
.subscribe({
|
|
||||||
next: (event) => {
|
|
||||||
this.store.add(event);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
console.error(
|
|
||||||
'[nostr-data] Error fetching place photos by geohash:',
|
|
||||||
err
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const p of missingPrefixes) {
|
for (const p of missingPrefixes) {
|
||||||
this.loadedGeohashPrefixes.add(p);
|
this.loadedGeohashPrefixes.add(p);
|
||||||
@@ -277,7 +495,8 @@ export default class NostrDataService extends Service {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
.subscribe((events) => {
|
.subscribe((events) => {
|
||||||
this.placePhotos = events;
|
this._allPlacePhotos = events;
|
||||||
|
this._updatePlacePhotos();
|
||||||
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
||||||
this.loadProfiles(pubkeys);
|
this.loadProfiles(pubkeys);
|
||||||
this._scheduleZapReceiptRefresh(events);
|
this._scheduleZapReceiptRefresh(events);
|
||||||
@@ -305,25 +524,28 @@ export default class NostrDataService extends Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire network request specifically for this place
|
// Fire network request specifically for this place (captures provenance).
|
||||||
this.nostrRelay.pool
|
this._requestContentWithProvenance(
|
||||||
.request(this.activeReadRelays, [
|
this.activeReadRelays,
|
||||||
{
|
[{ kinds: [360, 5], '#i': [entityId] }],
|
||||||
kinds: [360, 5],
|
'[nostr-data] Error fetching place photos for place:'
|
||||||
'#i': [entityId],
|
);
|
||||||
},
|
}
|
||||||
])
|
|
||||||
.subscribe({
|
// Recompute `placePhotos` from `_allPlacePhotos` applying the trust filter
|
||||||
next: (event) => {
|
// and the session-only reveal toggle. Called whenever the timeline emits or
|
||||||
this.store.add(event);
|
// the user flips `showUntrustedContent`.
|
||||||
},
|
_updatePlacePhotos() {
|
||||||
error: (err) => {
|
const events = this._allPlacePhotos;
|
||||||
console.error(
|
const { trusted, untrusted } = this.partitionByTrust(events);
|
||||||
'[nostr-data] Error fetching place photos for place:',
|
this.untrustedContentCount = untrusted.length;
|
||||||
err
|
this.placePhotos = this.showUntrustedContent ? events : trusted;
|
||||||
);
|
}
|
||||||
},
|
|
||||||
});
|
@action
|
||||||
|
toggleShowUntrustedContent() {
|
||||||
|
this.showUntrustedContent = !this.showUntrustedContent;
|
||||||
|
this._updatePlacePhotos();
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadMyContributions(pubkey) {
|
async loadMyContributions(pubkey) {
|
||||||
@@ -363,18 +585,367 @@ export default class NostrDataService extends Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Request fresh events from the network in the background
|
// 3. Request fresh events from the network in the background (captures
|
||||||
this.nostrRelay.pool.request(this.activeReadRelays, filters).subscribe({
|
// provenance; own-author events bypass trust filtering anyway).
|
||||||
next: (event) => {
|
this._requestContentWithProvenance(
|
||||||
this.store.add(event);
|
this.activeReadRelays,
|
||||||
},
|
filters,
|
||||||
error: (err) => {
|
'[nostr-data] Error fetching my contribution events:'
|
||||||
console.error(
|
);
|
||||||
'[nostr-data] Error fetching my contribution events:',
|
}
|
||||||
err
|
|
||||||
);
|
/**
|
||||||
},
|
* 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:'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads kind 360 (Place Photo) events for the activity feed, scoped to the
|
||||||
|
* given time window and source mode.
|
||||||
|
*
|
||||||
|
* - `'home'` mode: fetches photos authored by the user's followed contacts
|
||||||
|
* (followees). If contacts haven't loaded yet (`_contactPubkeys` is null or
|
||||||
|
* empty), this returns early and is re-triggered automatically by the
|
||||||
|
* `ContactsModel` subscription callback when contacts arrive.
|
||||||
|
* - `'explore'` mode: fetches all kind 360 photos in the time window from
|
||||||
|
* trusted relays (no authors filter). Provenance is captured so
|
||||||
|
* `isTrustedEvent` can filter at presentation time.
|
||||||
|
*
|
||||||
|
* @param {number} since Unix timestamp (seconds) for the start of the window
|
||||||
|
* @param {string} [mode='home'] Source mode
|
||||||
|
*/
|
||||||
|
async loadActivityPhotos(since, mode = 'home') {
|
||||||
|
this._activityPhotosSince = since;
|
||||||
|
this._activityPhotosMode = mode;
|
||||||
|
|
||||||
|
if (this._activityPhotosNetworkSub) {
|
||||||
|
this._activityPhotosNetworkSub.unsubscribe();
|
||||||
|
this._activityPhotosNetworkSub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'home') {
|
||||||
|
this._loadFolloweePhotos(since);
|
||||||
|
} else if (mode === 'explore') {
|
||||||
|
this._loadTrustedRelaysPhotos(since);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches kind 360 photos from the user's followees (followed contacts),
|
||||||
|
* batched by author pubkey (≤100 per filter to stay under relay REQ limits).
|
||||||
|
* Defers if contacts haven't loaded yet.
|
||||||
|
*/
|
||||||
|
_loadFolloweePhotos(since) {
|
||||||
|
const pubkeys = this._contactPubkeys
|
||||||
|
? Array.from(this._contactPubkeys)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (pubkeys.length === 0) {
|
||||||
|
console.debug(
|
||||||
|
'[nostr-data] No contacts loaded yet, deferring followee photo load'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = this._batchAuthorFilters(pubkeys, [360], since);
|
||||||
|
|
||||||
|
console.debug('[nostr-data] Loading followee photos', {
|
||||||
|
filterCount: filters.length,
|
||||||
|
pubkeyCount: pubkeys.length,
|
||||||
|
since,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Populate the store from the local Nostr IDB cache (instant)
|
||||||
|
this._cachePromise
|
||||||
|
.then(() => this.cache.query(filters))
|
||||||
|
.then((cachedEvents) => {
|
||||||
|
if (cachedEvents && cachedEvents.length > 0) {
|
||||||
|
for (const event of cachedEvents) {
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.warn(
|
||||||
|
'[nostr-data] Failed to read followee photos from local Nostr IDB cache',
|
||||||
|
e
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Request fresh events from the network (captures provenance for trust)
|
||||||
|
this._activityPhotosNetworkSub = this._requestContentWithProvenance(
|
||||||
|
this.activeReadRelays,
|
||||||
|
filters,
|
||||||
|
'[nostr-data] Error fetching followee photos:'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all kind 360 photos in the time window from trusted relays (no
|
||||||
|
* authors filter). Provenance is captured so `isTrustedEvent` can filter
|
||||||
|
* at presentation time. Fires immediately — no contacts dependency.
|
||||||
|
*/
|
||||||
|
_loadTrustedRelaysPhotos(since) {
|
||||||
|
const filters = [{ kinds: [360], since }];
|
||||||
|
|
||||||
|
console.debug('[nostr-data] Loading trusted relay photos', { since });
|
||||||
|
|
||||||
|
// 1. Populate the store from the local Nostr IDB cache (instant)
|
||||||
|
this._cachePromise
|
||||||
|
.then(() => this.cache.query(filters))
|
||||||
|
.then((cachedEvents) => {
|
||||||
|
if (cachedEvents && cachedEvents.length > 0) {
|
||||||
|
for (const event of cachedEvents) {
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.warn(
|
||||||
|
'[nostr-data] Failed to read trusted relay photos from local Nostr IDB cache',
|
||||||
|
e
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Request fresh events from the network (captures provenance for trust)
|
||||||
|
this._activityPhotosNetworkSub = this._requestContentWithProvenance(
|
||||||
|
this.activeReadRelays,
|
||||||
|
filters,
|
||||||
|
'[nostr-data] Error fetching trusted relay photos:'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches events from the network as a Promise that resolves on EOSE.
|
||||||
|
* Records provenance for each event and adds them to the store.
|
||||||
|
*
|
||||||
|
* @param {object[]} filters Nostr filters
|
||||||
|
* @param {string} errorLabel Log label for errors
|
||||||
|
* @returns {Promise<object[]>} Deduplicated events received from relays
|
||||||
|
*/
|
||||||
|
async _fetchEventsWithProvenance(filters, errorLabel) {
|
||||||
|
const complete = RelayGroup.completeOnAny(
|
||||||
|
RelayGroup.completeAfterFirstRelay(5_000),
|
||||||
|
RelayGroup.completeOnAllEose()
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const events = [];
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.nostrRelay.pool
|
||||||
|
.req(this.activeReadRelays, filters)
|
||||||
|
.pipe(
|
||||||
|
completeWhen(complete),
|
||||||
|
timeout({ first: 30_000 }),
|
||||||
|
filter((message) => message.type === 'EVENT')
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (message) => {
|
||||||
|
this._recordProvenance(message.event.id, message.from);
|
||||||
|
this.store.add(message.event);
|
||||||
|
if (!seen.has(message.event.id)) {
|
||||||
|
seen.add(message.event.id);
|
||||||
|
events.push(message.event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error(errorLabel, err);
|
||||||
|
resolve(events);
|
||||||
|
},
|
||||||
|
complete: () => resolve(events),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches kind 360 (Place Photo) events for the activity feed.
|
||||||
|
*
|
||||||
|
* Returns `{ cacheEvents, networkEvents }` so the caller can show cached
|
||||||
|
* items instantly while the network request resolves on EOSE.
|
||||||
|
*
|
||||||
|
* - `'home'` mode: fetches photos authored by the user's followed contacts
|
||||||
|
* (followees), batched by author pubkey.
|
||||||
|
* - `'explore'` mode: fetches all kind 360 photos in the time window from
|
||||||
|
* trusted relays (no authors filter).
|
||||||
|
*
|
||||||
|
* @param {number} since Unix timestamp (seconds) for the start of the window
|
||||||
|
* @param {number} [until] Unix timestamp (seconds) for the end of the window
|
||||||
|
* @param {string} [mode='home'] Source mode
|
||||||
|
* @returns {Promise<{cacheEvents: object[], networkEvents: Promise<object[]>}>}
|
||||||
|
*/
|
||||||
|
async fetchActivityPhotos(since, until, mode = 'home') {
|
||||||
|
let filters;
|
||||||
|
if (mode === 'home') {
|
||||||
|
const pubkeys = this._contactPubkeys
|
||||||
|
? Array.from(this._contactPubkeys)
|
||||||
|
: [];
|
||||||
|
if (pubkeys.length === 0)
|
||||||
|
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||||
|
filters = this._batchAuthorFilters(pubkeys, [360], since, until);
|
||||||
|
} else if (mode === 'explore') {
|
||||||
|
const filter = { kinds: [360], since };
|
||||||
|
if (until !== undefined) filter.until = until;
|
||||||
|
filters = [filter];
|
||||||
|
} else {
|
||||||
|
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Populate the store from the local Nostr IDB cache (instant)
|
||||||
|
const seen = new Set();
|
||||||
|
const cacheResult = [];
|
||||||
|
|
||||||
|
const cacheEvents = await this._cachePromise
|
||||||
|
.then(() => this.cache.query(filters))
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
if (cacheEvents) {
|
||||||
|
for (const event of cacheEvents) {
|
||||||
|
if (!seen.has(event.id)) {
|
||||||
|
seen.add(event.id);
|
||||||
|
cacheResult.push(event);
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Request fresh events from the network (resolves on EOSE)
|
||||||
|
const networkEvents = this._fetchEventsWithProvenance(
|
||||||
|
filters,
|
||||||
|
'[nostr-data] Error fetching activity photos:'
|
||||||
|
).then((events) => {
|
||||||
|
const result = [];
|
||||||
|
for (const event of events) {
|
||||||
|
if (!seen.has(event.id)) {
|
||||||
|
seen.add(event.id);
|
||||||
|
result.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { cacheEvents: cacheResult, networkEvents };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches incoming zap receipts (kind 9735) where the user is the recipient.
|
||||||
|
*
|
||||||
|
* Returns `{ cacheEvents, networkEvents }` so the caller can show cached
|
||||||
|
* items instantly while the network request resolves on EOSE.
|
||||||
|
*
|
||||||
|
* @param {string} pubkey The user's Nostr pubkey
|
||||||
|
* @param {number} [since] Unix timestamp (seconds) for the start of the window
|
||||||
|
* @param {number} [until] Unix timestamp (seconds) for the end of the window
|
||||||
|
* @returns {Promise<{cacheEvents: object[], networkEvents: Promise<object[]>}>}
|
||||||
|
*/
|
||||||
|
async fetchIncomingZaps(pubkey, since, until) {
|
||||||
|
if (!pubkey) return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||||
|
|
||||||
|
const filter = { kinds: [9735], '#p': [pubkey] };
|
||||||
|
if (since !== undefined) filter.since = since;
|
||||||
|
if (until !== undefined) filter.until = until;
|
||||||
|
const filters = [filter];
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const cacheResult = [];
|
||||||
|
|
||||||
|
// 1. IDB cache
|
||||||
|
const cacheEvents = await this._cachePromise
|
||||||
|
.then(() => this.cache.query(filters))
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
if (cacheEvents) {
|
||||||
|
for (const event of cacheEvents) {
|
||||||
|
if (!seen.has(event.id)) {
|
||||||
|
seen.add(event.id);
|
||||||
|
cacheResult.push(event);
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Network (resolves on EOSE)
|
||||||
|
const networkEvents = this._fetchEventsWithProvenance(
|
||||||
|
filters,
|
||||||
|
'[nostr-data] Error fetching incoming zap receipts:'
|
||||||
|
).then((events) => {
|
||||||
|
const result = [];
|
||||||
|
for (const event of events) {
|
||||||
|
if (!seen.has(event.id)) {
|
||||||
|
seen.add(event.id);
|
||||||
|
result.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { cacheEvents: cacheResult, networkEvents };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an array of Nostr filters, each with ≤ `BATCH_SIZE` author pubkeys.
|
||||||
|
*
|
||||||
|
* @param {string[]} pubkeys Author pubkeys to batch
|
||||||
|
* @param {number[]} kinds Event kinds to request
|
||||||
|
* @param {number} since Unix timestamp (seconds) for the start of the window
|
||||||
|
* @returns {object[]} Array of filter objects
|
||||||
|
*/
|
||||||
|
_batchAuthorFilters(pubkeys, kinds, since, until) {
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
const filters = [];
|
||||||
|
for (let i = 0; i < pubkeys.length; i += BATCH_SIZE) {
|
||||||
|
const filter = {
|
||||||
|
kinds,
|
||||||
|
authors: pubkeys.slice(i, i + BATCH_SIZE),
|
||||||
|
since,
|
||||||
|
};
|
||||||
|
if (until !== undefined) filter.until = until;
|
||||||
|
filters.push(filter);
|
||||||
|
}
|
||||||
|
return filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProfiles(pubkeys) {
|
loadProfiles(pubkeys) {
|
||||||
@@ -382,6 +953,8 @@ export default class NostrDataService extends Service {
|
|||||||
(pk) => pk && !this._profileModelSubs.has(pk)
|
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const retryPubkeys = [];
|
||||||
|
|
||||||
for (const pubkey of newPubkeys) {
|
for (const pubkey of newPubkeys) {
|
||||||
const sub = this.store
|
const sub = this.store
|
||||||
.model(ProfileModel, pubkey)
|
.model(ProfileModel, pubkey)
|
||||||
@@ -389,7 +962,60 @@ export default class NostrDataService extends Service {
|
|||||||
this.profiles = { ...this.profiles, [pubkey]: profileContent };
|
this.profiles = { ...this.profiles, [pubkey]: profileContent };
|
||||||
});
|
});
|
||||||
this._profileModelSubs.set(pubkey, sub);
|
this._profileModelSubs.set(pubkey, sub);
|
||||||
|
|
||||||
|
// If we already tried this pubkey before (e.g. selecting the same place
|
||||||
|
// again) and it still isn't in the store, explicitly re-request it. The
|
||||||
|
// ProfileModel loader only runs once per cached model, so without this a
|
||||||
|
// profile published after the first lookup would never be retried.
|
||||||
|
if (
|
||||||
|
this._attemptedProfilePubkeys.has(pubkey) &&
|
||||||
|
!this.store.getReplaceable(0, pubkey)
|
||||||
|
) {
|
||||||
|
retryPubkeys.push(pubkey);
|
||||||
|
}
|
||||||
|
this._attemptedProfilePubkeys.add(pubkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (retryPubkeys.length > 0) {
|
||||||
|
void this.refreshProfiles(retryPubkeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-requests kind 0 metadata for the given pubkeys from the directory and
|
||||||
|
* active read relays, adding any results to the event store.
|
||||||
|
*
|
||||||
|
* Used to retry profiles that were not available on a first lookup (e.g. the
|
||||||
|
* author published their metadata after their content was loaded, or a
|
||||||
|
* cached applesauce model prevented the fallback loader from running again).
|
||||||
|
* Inserting the events into the store notifies any active `ProfileModel`
|
||||||
|
* subscriptions, so callers don't need to manage subscriptions themselves.
|
||||||
|
*
|
||||||
|
* @param {string[]} pubkeys Pubkeys to refresh
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async refreshProfiles(pubkeys) {
|
||||||
|
const unique = [...new Set(pubkeys)].filter(Boolean);
|
||||||
|
if (unique.length === 0) return;
|
||||||
|
|
||||||
|
const relays = uniqNormalizedRelays([
|
||||||
|
...DIRECTORY_RELAYS,
|
||||||
|
...this.activeReadRelays,
|
||||||
|
]);
|
||||||
|
const filters = this._batchAuthorFilters(unique, [0]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
this.nostrRelay.pool
|
||||||
|
.request(relays, filters)
|
||||||
|
.pipe(timeout({ first: 15_000 }))
|
||||||
|
.subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
this.store.add(event);
|
||||||
|
},
|
||||||
|
error: () => resolve(),
|
||||||
|
complete: () => resolve(),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getProfile(pubkey) {
|
getProfile(pubkey) {
|
||||||
@@ -410,8 +1036,15 @@ export default class NostrDataService extends Service {
|
|||||||
// Reset state
|
// Reset state
|
||||||
this.profile = null;
|
this.profile = null;
|
||||||
this.mailboxes = null;
|
this.mailboxes = null;
|
||||||
|
this.contacts = null;
|
||||||
|
this._contactPubkeys = null;
|
||||||
this.blossomServers = [];
|
this.blossomServers = [];
|
||||||
|
|
||||||
|
// Create a deferred that resolves when contacts are first loaded
|
||||||
|
this._contactsPromise = new Promise((resolve) => {
|
||||||
|
this._contactsResolver = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
this._cleanupSubscriptions();
|
this._cleanupSubscriptions();
|
||||||
|
|
||||||
// Setup models to track state reactively FIRST
|
// Setup models to track state reactively FIRST
|
||||||
@@ -428,6 +1061,26 @@ export default class NostrDataService extends Service {
|
|||||||
this.mailboxes = mailboxesData;
|
this.mailboxes = mailboxesData;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._contactsSub = this.store
|
||||||
|
.model(ContactsModel, pubkey)
|
||||||
|
.subscribe((contacts) => {
|
||||||
|
this.contacts = contacts;
|
||||||
|
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
|
||||||
|
// Resolve the deferred so callers awaiting whenContactsLoaded proceed.
|
||||||
|
this._contactsResolver?.();
|
||||||
|
this._contactsResolver = null;
|
||||||
|
this._updatePlacePhotos();
|
||||||
|
// Re-trigger activity photo load now that contacts are available.
|
||||||
|
// If no activity load has been requested yet, _activityPhotosSince
|
||||||
|
// is null and the call is a no-op.
|
||||||
|
if (this._activityPhotosSince !== null) {
|
||||||
|
this.loadActivityPhotos(
|
||||||
|
this._activityPhotosSince,
|
||||||
|
this._activityPhotosMode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this._blossomSub = this.store
|
this._blossomSub = this.store
|
||||||
.replaceable(10063, pubkey)
|
.replaceable(10063, pubkey)
|
||||||
.subscribe((event) => {
|
.subscribe((event) => {
|
||||||
@@ -457,7 +1110,7 @@ export default class NostrDataService extends Service {
|
|||||||
const cachedEvents = await this.cache.query([
|
const cachedEvents = await this.cache.query([
|
||||||
{
|
{
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
kinds: [0, 10002, 10063],
|
kinds: [0, 3, 10002, 10063],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -478,7 +1131,7 @@ export default class NostrDataService extends Service {
|
|||||||
.request(profileRelays, [
|
.request(profileRelays, [
|
||||||
{
|
{
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
kinds: [0, 10002, 10063],
|
kinds: [0, 3, 10002, 10063],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.subscribe({
|
.subscribe({
|
||||||
@@ -491,6 +1144,23 @@ export default class NostrDataService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a promise that resolves when contacts are first loaded, or
|
||||||
|
* immediately if already loaded. Times out after 5s so callers don't hang
|
||||||
|
* forever if contacts never arrive.
|
||||||
|
*
|
||||||
|
* @param {number} [timeout=5000] Timeout in milliseconds
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
whenContactsLoaded(timeout = 5000) {
|
||||||
|
if (this._contactPubkeys) return Promise.resolve();
|
||||||
|
if (!this._contactsPromise) return Promise.resolve();
|
||||||
|
return Promise.race([
|
||||||
|
this._contactsPromise,
|
||||||
|
new Promise((resolve) => setTimeout(resolve, timeout)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
get userDisplayName() {
|
get userDisplayName() {
|
||||||
if (this.profile) {
|
if (this.profile) {
|
||||||
if (this.profile.nip05) {
|
if (this.profile.nip05) {
|
||||||
@@ -640,6 +1310,11 @@ export default class NostrDataService extends Service {
|
|||||||
this._mailboxesSub.unsubscribe();
|
this._mailboxesSub.unsubscribe();
|
||||||
this._mailboxesSub = null;
|
this._mailboxesSub = null;
|
||||||
}
|
}
|
||||||
|
if (this._contactsSub) {
|
||||||
|
this._contactsSub.unsubscribe();
|
||||||
|
this._contactsSub = null;
|
||||||
|
this._contactPubkeys = null;
|
||||||
|
}
|
||||||
if (this._blossomSub) {
|
if (this._blossomSub) {
|
||||||
this._blossomSub.unsubscribe();
|
this._blossomSub.unsubscribe();
|
||||||
this._blossomSub = null;
|
this._blossomSub = null;
|
||||||
@@ -654,12 +1329,26 @@ export default class NostrDataService extends Service {
|
|||||||
}
|
}
|
||||||
this._cleanupZapReceiptSubs();
|
this._cleanupZapReceiptSubs();
|
||||||
this._clearZapRefreshTimer();
|
this._clearZapRefreshTimer();
|
||||||
|
if (this._incomingZapsNetworkSub) {
|
||||||
|
this._incomingZapsNetworkSub.unsubscribe();
|
||||||
|
this._incomingZapsNetworkSub = null;
|
||||||
|
}
|
||||||
|
if (this._activityPhotosNetworkSub) {
|
||||||
|
this._activityPhotosNetworkSub.unsubscribe();
|
||||||
|
this._activityPhotosNetworkSub = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
willDestroy() {
|
willDestroy() {
|
||||||
super.willDestroy(...arguments);
|
super.willDestroy(...arguments);
|
||||||
this._cleanupSubscriptions();
|
this._cleanupSubscriptions();
|
||||||
this._clearProfileSubs();
|
this._clearProfileSubs();
|
||||||
|
this._attemptedProfilePubkeys.clear();
|
||||||
|
|
||||||
|
if (this._deletionsSub) {
|
||||||
|
this._deletionsSub.unsubscribe();
|
||||||
|
this._deletionsSub = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (this._stopPersisting) {
|
if (this._stopPersisting) {
|
||||||
this._stopPersisting();
|
this._stopPersisting();
|
||||||
|
|||||||
@@ -8,18 +8,11 @@ export default class NostrRelayService extends Service {
|
|||||||
if (!relays || relays.length === 0) {
|
if (!relays || relays.length === 0) {
|
||||||
throw new Error('No relays provided to publish the event.');
|
throw new Error('No relays provided to publish the event.');
|
||||||
}
|
}
|
||||||
// The publish method is a wrapper around the event method that returns a Promise<PublishResponse[]>
|
// The publish method is a wrapper around the event method that returns a
|
||||||
// and automatically handles reconnecting and retrying.
|
// Promise<PublishResponse[]> and automatically handles reconnecting and
|
||||||
const responses = await this.pool.publish(relays, event);
|
// retrying. It resolves with the per-relay responses even when no relay
|
||||||
|
// accepted the event; callers are responsible for checking `ok` so that
|
||||||
// Check if at least one relay accepted the event
|
// failed attempts can still be recorded.
|
||||||
const success = responses.some((res) => res.ok);
|
return await this.pool.publish(relays, event);
|
||||||
if (!success) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return responses;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+21
-6
@@ -196,7 +196,7 @@ out center;
|
|||||||
|
|
||||||
async getCategoryPois(bounds, categoryId, lat, lon) {
|
async getCategoryPois(bounds, categoryId, lat, lon) {
|
||||||
const category = getCategoryById(categoryId);
|
const category = getCategoryById(categoryId);
|
||||||
if (!category || !bounds) return [];
|
if (!category || !Array.isArray(category.filter) || !bounds) return [];
|
||||||
|
|
||||||
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
|
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
|
||||||
|
|
||||||
@@ -457,11 +457,11 @@ out center;
|
|||||||
// request using comma-separated IDs (max ~50 per request).
|
// request using comma-separated IDs (max ~50 per request).
|
||||||
//
|
//
|
||||||
// Note: the multi-fetch endpoint returns ways/relations WITHOUT their child
|
// Note: the multi-fetch endpoint returns ways/relations WITHOUT their child
|
||||||
// nodes, so normalized data may lack lat/lon and geometry. We intentionally
|
// nodes, so normalized data may lack lat/lon and geometry.
|
||||||
// do NOT write these results to the general OSM cache — only the returned
|
// We only write complete results (those with lat/lon) to the general OSM
|
||||||
// Map is used by the caller (the contributions service manages its own
|
// cache. Nodes from the batch endpoint always include coordinates, so they
|
||||||
// name cache). This prevents incomplete data from breaking place detail
|
// are cached. Ways/relations without child node coordinates are not cached,
|
||||||
// navigation, which needs the full single-object endpoint.
|
// preserving the full single-object endpoint for place detail navigation.
|
||||||
const MAX_IDS_PER_REQUEST = 50;
|
const MAX_IDS_PER_REQUEST = 50;
|
||||||
|
|
||||||
for (let i = 0; i < group.length; i += MAX_IDS_PER_REQUEST) {
|
for (let i = 0; i < group.length; i += MAX_IDS_PER_REQUEST) {
|
||||||
@@ -488,6 +488,21 @@ out center;
|
|||||||
);
|
);
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
result.set(item.cacheKey, 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) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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) return;
|
||||||
|
|
||||||
|
// Phase 1: Cache lookup (localForage → OSM IDB cache)
|
||||||
|
await Promise.all(
|
||||||
|
pending.map(async (entry) => {
|
||||||
|
const name = await this._resolveCachedName(entry);
|
||||||
|
if (name) {
|
||||||
|
entry.placeName = name;
|
||||||
|
entry.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unresolved = entries.filter(
|
||||||
|
(e) => e.placeNameLoading && !this._isUnresolvable(e)
|
||||||
|
);
|
||||||
|
if (unresolved.length === 0) return;
|
||||||
|
|
||||||
|
// Phase 2: Batch OSM fetch (awaited, deduplicated by signature)
|
||||||
|
try {
|
||||||
|
const nameMap = await this._getOrCreateBatch(unresolved);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.placeNameLoading) continue;
|
||||||
|
if (nameMap.has(entry.placeIdentifier)) {
|
||||||
|
entry.placeName = nameMap.get(entry.placeIdentifier);
|
||||||
|
entry.placeNameLoading = false;
|
||||||
|
} else {
|
||||||
|
this._unresolvable.add(entry.placeIdentifier);
|
||||||
|
entry.placeName = this._fallbackName(entry);
|
||||||
|
entry.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[place-name-resolver] Batch resolution failed', e);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.placeNameLoading) {
|
||||||
|
this._unresolvable.add(entry.placeIdentifier);
|
||||||
|
entry.placeName = this._fallbackName(entry);
|
||||||
|
entry.placeNameLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isUnresolvable(entry) {
|
||||||
|
return this._unresolvable.has(entry.placeIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a promise for the batch OSM fetch, deduplicating calls that have
|
||||||
|
* the same set of place identifiers. If a batch with the same signature is
|
||||||
|
* already in flight, returns that promise instead of firing a new one.
|
||||||
|
*
|
||||||
|
* @param {Array} unresolved Entries that still need a network fetch
|
||||||
|
* @returns {Promise<Map<string, string>>} Map of placeIdentifier → name
|
||||||
|
*/
|
||||||
|
_getOrCreateBatch(unresolved) {
|
||||||
|
const signature = unresolved
|
||||||
|
.map((e) => e.placeIdentifier)
|
||||||
|
.sort()
|
||||||
|
.join('|');
|
||||||
|
|
||||||
|
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
|
||||||
|
return this._pendingBatchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastBatchSignature = signature;
|
||||||
|
this._pendingBatchPromise = this._batchResolveNames(unresolved).finally(
|
||||||
|
() => {
|
||||||
|
this._pendingBatchPromise = null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return this._pendingBatchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ const DEFAULT_SETTINGS = {
|
|||||||
nostrWriteRelays: null,
|
nostrWriteRelays: null,
|
||||||
nostrReadRelayExclusions: null,
|
nostrReadRelayExclusions: null,
|
||||||
nostrWriteRelayExclusions: 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,
|
experimentalEnablePhotoDeletion: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ export default class SettingsService extends Service {
|
|||||||
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
|
@tracked nostrReadRelayExclusions = DEFAULT_SETTINGS.nostrReadRelayExclusions;
|
||||||
@tracked nostrWriteRelayExclusions =
|
@tracked nostrWriteRelayExclusions =
|
||||||
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
|
DEFAULT_SETTINGS.nostrWriteRelayExclusions;
|
||||||
|
@tracked nostrTrustedRelays = DEFAULT_SETTINGS.nostrTrustedRelays;
|
||||||
@tracked experimentalEnablePhotoDeletion =
|
@tracked experimentalEnablePhotoDeletion =
|
||||||
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
|
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
|
||||||
|
|
||||||
@@ -123,6 +128,7 @@ export default class SettingsService extends Service {
|
|||||||
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
|
||||||
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
|
this.nostrReadRelayExclusions = finalSettings.nostrReadRelayExclusions;
|
||||||
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
|
this.nostrWriteRelayExclusions = finalSettings.nostrWriteRelayExclusions;
|
||||||
|
this.nostrTrustedRelays = finalSettings.nostrTrustedRelays;
|
||||||
this.experimentalEnablePhotoDeletion =
|
this.experimentalEnablePhotoDeletion =
|
||||||
finalSettings.experimentalEnablePhotoDeletion;
|
finalSettings.experimentalEnablePhotoDeletion;
|
||||||
|
|
||||||
@@ -142,6 +148,7 @@ export default class SettingsService extends Service {
|
|||||||
nostrWriteRelays: this.nostrWriteRelays,
|
nostrWriteRelays: this.nostrWriteRelays,
|
||||||
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
|
nostrReadRelayExclusions: this.nostrReadRelayExclusions,
|
||||||
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
|
nostrWriteRelayExclusions: this.nostrWriteRelayExclusions,
|
||||||
|
nostrTrustedRelays: this.nostrTrustedRelays,
|
||||||
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
|
||||||
};
|
};
|
||||||
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
localStorage.setItem('marco:settings', JSON.stringify(settings));
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export default class StorageService extends Service {
|
|||||||
this.rs.on('connected', () => {
|
this.rs.on('connected', () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.userAddress = this.rs.remote.userAddress;
|
this.userAddress = this.rs.remote.userAddress;
|
||||||
|
this.initialSyncDone = false;
|
||||||
|
|
||||||
if (this.isNewConnection) {
|
if (this.isNewConnection) {
|
||||||
this.toast.show('Remote storage connected', 3000);
|
this.toast.show('Remote storage connected', 3000);
|
||||||
@@ -71,6 +72,7 @@ export default class StorageService extends Service {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.rs.on('not-connected', () => {
|
this.rs.on('not-connected', () => {
|
||||||
|
this.initialSyncDone = true;
|
||||||
this.loadLists();
|
this.loadLists();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+380
-3
@@ -13,6 +13,10 @@
|
|||||||
--danger-color: var(--marker-color-primary);
|
--danger-color: var(--marker-color-primary);
|
||||||
--danger-color-dark: var(--marker-color-dark);
|
--danger-color-dark: var(--marker-color-dark);
|
||||||
--default-list-color: #fc3;
|
--default-list-color: #fc3;
|
||||||
|
--secondary-text-color: #898989;
|
||||||
|
--border-color: #d8d8d8;
|
||||||
|
--success-color: #2e7d32;
|
||||||
|
--secondary-background-color: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -611,14 +615,28 @@ body {
|
|||||||
|
|
||||||
.relay-list li {
|
.relay-list li {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
word-break: break-all;
|
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 {
|
.btn-remove-relay {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -639,12 +657,19 @@ body {
|
|||||||
stroke: currentcolor;
|
stroke: currentcolor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-remove-relay:hover,
|
.btn-remove-relay:hover:not(:disabled),
|
||||||
.btn-remove-relay:active {
|
.btn-remove-relay:active:not(:disabled) {
|
||||||
background-color: var(--danger-color);
|
background-color: var(--danger-color);
|
||||||
color: var(--primary-background-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 {
|
.add-relay-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -655,6 +680,64 @@ body {
|
|||||||
font-size: 0.85rem;
|
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 {
|
@keyframes details-slide-down {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -1092,6 +1175,71 @@ abbr[title] {
|
|||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.place-payment-methods {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
padding-top: 1.2rem;
|
||||||
|
border-top: 1px solid var(--divider-color);
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
outline-offset: 2px;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method.is-denied {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method.is-denied::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 28px;
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
transform: translate(-50%, -50%) rotate(-45deg);
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
right: -6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--default-list-color);
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 0;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method-badge .icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method-badge .icon svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
/* TODO: remove this scoped rule in favor of a global box-sizing reset
|
/* TODO: remove this scoped rule in favor of a global box-sizing reset
|
||||||
(e.g. `*, *::before, *::after { box-sizing: border-box; }`) at the top
|
(e.g. `*, *::before, *::after { box-sizing: border-box; }`) at the top
|
||||||
@@ -2334,6 +2482,38 @@ button.create-place {
|
|||||||
padding: 4rem 1rem;
|
padding: 4rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab Navigation */
|
||||||
|
.tab-nav {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-nav-button {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
font-family: inherit;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-nav-button:hover {
|
||||||
|
color: var(--body-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-nav-button.is-active {
|
||||||
|
border-bottom-color: var(--link-color);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--body-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* Contributions Timeline */
|
/* Contributions Timeline */
|
||||||
.contributions-list {
|
.contributions-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -2577,3 +2757,200 @@ button.create-place {
|
|||||||
color: var(--body-text-color);
|
color: var(--body-text-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Activity Timeline — activity list rendered in the sidebar */
|
||||||
|
.activity-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1rem -1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-load-more {
|
||||||
|
list-style: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem 1rem;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-load-more-spinner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .sender-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .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;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .sender-line {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
|
||||||
|
& .sender-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .action {
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .amount {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--body-text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
align-self: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .context {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
|
||||||
|
& .context-images {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .context-thumb {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
& img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .context-thumb-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
background: rgb(0 0 0 / 65%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .context-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
& .activity-place-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
& .place-name-text {
|
||||||
|
animation: place-name-fade-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .activity-date {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes place-name-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import ActivityTimeline from '#components/activity-timeline';
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if @controller.mapUi.isSidebarVisible}}
|
||||||
|
<ActivityTimeline
|
||||||
|
@items={{@controller.items}}
|
||||||
|
@isLoading={{@controller.isLoading}}
|
||||||
|
@isConnected={{@controller.isConnected}}
|
||||||
|
@sourceMode={{@controller.sourceMode}}
|
||||||
|
@onSetSourceMode={{@controller.setSourceMode}}
|
||||||
|
@isLoadingMore={{@controller.isLoadingMore}}
|
||||||
|
@onLoadMore={{@controller.loadMore}}
|
||||||
|
@scrollTop={{@controller.scrollTop}}
|
||||||
|
@onSelect={{@controller.selectItem}}
|
||||||
|
@onBack={{@controller.backToMenu}}
|
||||||
|
@onClose={{@controller.close}}
|
||||||
|
@onNostrConnected={{@controller.onNostrConnected}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
@@ -23,6 +23,7 @@ export default class ApplicationComponent extends Component {
|
|||||||
name === 'search' ||
|
name === 'search' ||
|
||||||
name === 'menu' ||
|
name === 'menu' ||
|
||||||
name === 'contributions' ||
|
name === 'contributions' ||
|
||||||
|
name === 'activity' ||
|
||||||
name.startsWith('lists'))
|
name.startsWith('lists'))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,7 @@ export default class ApplicationComponent extends Component {
|
|||||||
name === 'place' ||
|
name === 'place' ||
|
||||||
name === 'menu' ||
|
name === 'menu' ||
|
||||||
name === 'contributions' ||
|
name === 'contributions' ||
|
||||||
|
name === 'activity' ||
|
||||||
name.startsWith('lists')
|
name.startsWith('lists')
|
||||||
) {
|
) {
|
||||||
this.mapUi.clearSelection();
|
this.mapUi.clearSelection();
|
||||||
@@ -58,6 +60,7 @@ export default class ApplicationComponent extends Component {
|
|||||||
if (
|
if (
|
||||||
name === 'menu' ||
|
name === 'menu' ||
|
||||||
name === 'contributions' ||
|
name === 'contributions' ||
|
||||||
|
name === 'activity' ||
|
||||||
name.startsWith('lists')
|
name.startsWith('lists')
|
||||||
) {
|
) {
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
@@ -69,6 +72,9 @@ export default class ApplicationComponent extends Component {
|
|||||||
} else {
|
} else {
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
}
|
}
|
||||||
|
} else if (name === 'search' && this.mapUi.searchResults.length === 0) {
|
||||||
|
// An empty search has no markers worth keeping, so clear the route
|
||||||
|
this.router.replaceWith('index');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ import { fn } from '@ember/helper';
|
|||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import { htmlSafe } from '@ember/template';
|
import { htmlSafe } from '@ember/template';
|
||||||
|
import restoreScroll from '../../modifiers/restore-scroll';
|
||||||
|
|
||||||
export default class ListsIndexTemplate extends Component {
|
export default class ListsIndexTemplate extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
|
|
||||||
|
savedList = {
|
||||||
|
id: 'saved',
|
||||||
|
title: 'Saved',
|
||||||
|
color: 'var(--default-list-color)',
|
||||||
|
};
|
||||||
|
|
||||||
styleFor(color) {
|
styleFor(color) {
|
||||||
const finalColor =
|
const finalColor =
|
||||||
color ||
|
color ||
|
||||||
@@ -20,8 +27,16 @@ export default class ListsIndexTemplate extends Component {
|
|||||||
return htmlSafe(`background-color: ${finalColor}`);
|
return htmlSafe(`background-color: ${finalColor}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get scrollTop() {
|
||||||
|
return this.mapUi.getScrollPosition('lists-index');
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
selectList(listId) {
|
selectList(listId) {
|
||||||
|
const sidebarContent = document.querySelector('.sidebar-content');
|
||||||
|
if (sidebarContent) {
|
||||||
|
this.mapUi.saveScrollPosition('lists-index', sidebarContent.scrollTop);
|
||||||
|
}
|
||||||
this.router.transitionTo('lists.list', listId);
|
this.router.transitionTo('lists.list', listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +61,39 @@ export default class ListsIndexTemplate extends Component {
|
|||||||
<span class="sidebar-header-icon-wrapper">
|
<span class="sidebar-header-icon-wrapper">
|
||||||
<Icon @name="bookmark" @size={{20}} @color="#898989" />
|
<Icon @name="bookmark" @size={{20}} @color="#898989" />
|
||||||
</span>
|
</span>
|
||||||
Collections
|
Saved Places
|
||||||
</h2>
|
</h2>
|
||||||
<button type="button" class="close-btn" {{on "click" this.close}}>
|
<button type="button" class="close-btn" {{on "click" this.close}}>
|
||||||
<Icon @name="x" @size={{20}} @color="#333" />
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content" {{restoreScroll this.scrollTop}}>
|
||||||
<ul class="places-list">
|
<ul class="places-list">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="lists-index-item"
|
||||||
|
{{on "click" (fn this.selectList this.savedList.id)}}
|
||||||
|
>
|
||||||
|
<div class="lists-index-item-left">
|
||||||
|
{{! template-lint-disable no-inline-styles }}
|
||||||
|
<span
|
||||||
|
class="list-color-dot"
|
||||||
|
style={{this.styleFor this.savedList.color}}
|
||||||
|
></span>
|
||||||
|
<div class="lists-index-name">{{this.savedList.title}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="lists-index-count">
|
||||||
|
{{#if this.storage.savedPlaces.length}}
|
||||||
|
{{this.storage.savedPlaces.length}}
|
||||||
|
places
|
||||||
|
{{else}}
|
||||||
|
empty
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
{{#each this.storage.lists as |list|}}
|
{{#each this.storage.lists as |list|}}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import PlacesSidebar from '#components/places-sidebar';
|
|||||||
@title={{@controller.listTitle}}
|
@title={{@controller.listTitle}}
|
||||||
@color={{@controller.listColor}}
|
@color={{@controller.listColor}}
|
||||||
@scrollTop={{@controller.scrollTop}}
|
@scrollTop={{@controller.scrollTop}}
|
||||||
@isLoading={{@controller.loadPlacesTask.isRunning}}
|
@isLoading={{@controller.isLoading}}
|
||||||
@onSelect={{@controller.selectPlace}}
|
@onSelect={{@controller.selectPlace}}
|
||||||
@onClose={{@controller.close}}
|
@onClose={{@controller.close}}
|
||||||
@onBack={{@controller.backToLists}}
|
@onBack={{@controller.backToLists}}
|
||||||
|
|||||||
@@ -18,14 +18,20 @@ export default class SearchTemplate extends Component {
|
|||||||
this.mapUi.showSidebar();
|
this.mapUi.showSidebar();
|
||||||
this.mapUi.preventNextZoom = true;
|
this.mapUi.preventNextZoom = true;
|
||||||
// We don't need to manually set currentSearch here because
|
// We don't need to manually set currentSearch here because
|
||||||
// it was already set in the route's setupController
|
// it was already set in the search controller
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
close() {
|
close() {
|
||||||
this.mapUi.hideSidebar();
|
// With no results there is nothing to keep around (no map markers), so
|
||||||
|
// dismissing the drawer clears the search route entirely.
|
||||||
|
if (this.mapUi.searchResults.length === 0) {
|
||||||
|
this.router.replaceWith('index');
|
||||||
|
} else {
|
||||||
|
this.mapUi.hideSidebar();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* 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, applyDeletions } 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;
|
||||||
|
photos = [];
|
||||||
|
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,
|
||||||
|
photos,
|
||||||
|
placeIdentifier,
|
||||||
|
osmType,
|
||||||
|
osmId,
|
||||||
|
senderPubkey,
|
||||||
|
amountSats,
|
||||||
|
message,
|
||||||
|
createdAt,
|
||||||
|
}) {
|
||||||
|
this.photoEventId = photoEventId;
|
||||||
|
this.photo = photo;
|
||||||
|
this.photos = photos ?? [];
|
||||||
|
this.placeIdentifier = placeIdentifier;
|
||||||
|
this.osmType = osmType;
|
||||||
|
this.osmId = osmId;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUR_IN_SECONDS = 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a single `ActivityEntry` (type 'photo') from a group of kind 360
|
||||||
|
* events by the same author for the same OSM entity.
|
||||||
|
*
|
||||||
|
* @param {Array} events Kind 360 events in this sub-group (same author + place)
|
||||||
|
* @returns {ActivityEntry}
|
||||||
|
*/
|
||||||
|
function buildSocialEntry(events) {
|
||||||
|
const photos = events
|
||||||
|
.map(parsePhotoFromEvent)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
|
||||||
|
const createdAt = events.reduce(
|
||||||
|
(max, e) => (e.created_at > max ? e.created_at : max),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const placeIdentifier =
|
||||||
|
events[0].tags?.find((t) => t[0] === 'i')?.[1] || null;
|
||||||
|
|
||||||
|
let osmType;
|
||||||
|
let osmId;
|
||||||
|
if (placeIdentifier) {
|
||||||
|
const parts = placeIdentifier.split(':');
|
||||||
|
osmType = parts[1];
|
||||||
|
osmId = parts[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = new ActivityEntry({
|
||||||
|
photo: photos[0] || null,
|
||||||
|
photos,
|
||||||
|
placeIdentifier,
|
||||||
|
osmType,
|
||||||
|
osmId,
|
||||||
|
senderPubkey: events[0].pubkey,
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
entry.type = 'photo';
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups kind 360 (Place Photo) events from followed contacts into activity
|
||||||
|
* entries, keyed by (author + OSM entity + time proximity).
|
||||||
|
*
|
||||||
|
* This mirrors the time-proximity grouping logic from `groupPhotoContributions`
|
||||||
|
* in `utils/contributions.js` but adds author as a grouping dimension so that
|
||||||
|
* "Alice shared 3 photos to Central Park Café" is one entry.
|
||||||
|
*
|
||||||
|
* @param {Array} events Mixed kind 360 / kind 5 events from followed contacts
|
||||||
|
* @param {number} [thresholdHours=3] Max gap in hours within a sub-group
|
||||||
|
* @returns {Array} Sorted `ActivityEntry` entries (newest first)
|
||||||
|
*/
|
||||||
|
export function groupSocialPhotos(events, thresholdHours = 3) {
|
||||||
|
if (!events || events.length === 0) return [];
|
||||||
|
|
||||||
|
const photoEvents = applyDeletions(events);
|
||||||
|
if (photoEvents.length === 0) return [];
|
||||||
|
|
||||||
|
// Group by (author pubkey + OSM entity identifier)
|
||||||
|
const byAuthorEntity = new Map();
|
||||||
|
for (const event of photoEvents) {
|
||||||
|
const entityTag = (event.tags || []).find((t) => t[0] === 'i');
|
||||||
|
const entityId = entityTag?.[1];
|
||||||
|
if (!entityId) continue;
|
||||||
|
|
||||||
|
const key = `${event.pubkey}:${entityId}`;
|
||||||
|
if (!byAuthorEntity.has(key)) byAuthorEntity.set(key, []);
|
||||||
|
byAuthorEntity.get(key).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const thresholdSeconds = thresholdHours * HOUR_IN_SECONDS;
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
for (const [, groupEvents] of byAuthorEntity) {
|
||||||
|
// Sort newest-first within the group
|
||||||
|
const sorted = [...groupEvents].sort((a, b) => b.created_at - a.created_at);
|
||||||
|
|
||||||
|
// Walk newest -> oldest, starting a new sub-group when the gap to the
|
||||||
|
// previous event exceeds the threshold.
|
||||||
|
let currentGroup = [];
|
||||||
|
let prevTime = null;
|
||||||
|
|
||||||
|
for (const event of sorted) {
|
||||||
|
if (prevTime !== null && prevTime - event.created_at > thresholdSeconds) {
|
||||||
|
entries.push(buildSocialEntry(currentGroup));
|
||||||
|
currentGroup = [];
|
||||||
|
}
|
||||||
|
currentGroup.push(event);
|
||||||
|
prevTime = event.created_at;
|
||||||
|
}
|
||||||
|
if (currentGroup.length > 0) {
|
||||||
|
entries.push(buildSocialEntry(currentGroup));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort entries by their newest event's created_at, descending. Entries with
|
||||||
|
// no usable photos (e.g. malformed imeta) are filtered out.
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.photos.length > 0)
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ export class ContributionEntry {
|
|||||||
* @param {object} event A NIP-360 (kind 360) Nostr event
|
* @param {object} event A NIP-360 (kind 360) Nostr event
|
||||||
* @returns {object|null} A photo object, or null if no usable `imeta` was found
|
* @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 tags = event.tags || [];
|
||||||
|
|
||||||
const eventTags = tags
|
const eventTags = tags
|
||||||
@@ -120,7 +120,7 @@ function parsePhotoFromEvent(event) {
|
|||||||
* @param {Array} events Mixed kind 360 and kind 5 events
|
* @param {Array} events Mixed kind 360 and kind 5 events
|
||||||
* @returns {Array} Surviving kind 360 events
|
* @returns {Array} Surviving kind 360 events
|
||||||
*/
|
*/
|
||||||
function applyDeletions(events) {
|
export function applyDeletions(events) {
|
||||||
const deletedIds = new Set();
|
const deletedIds = new Set();
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.kind === 5) {
|
if (event.kind === 5) {
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ export function formatRelativeDate(timestamp) {
|
|||||||
if (diffMin < 60) return `${diffMin} min ago`;
|
if (diffMin < 60) return `${diffMin} min ago`;
|
||||||
if (diffHr < 24) return `${diffHr} hr ago`;
|
if (diffHr < 24) return `${diffHr} hr ago`;
|
||||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||||
|
const isCurrentYear = date.getFullYear() === now.getFullYear();
|
||||||
return date.toLocaleDateString(undefined, {
|
return date.toLocaleDateString(undefined, {
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
...(isCurrentYear ? {} : { year: 'numeric' }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||||
|
import briefcase from 'feather-icons/dist/icons/briefcase.svg?raw';
|
||||||
import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
|
import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
|
||||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||||
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||||
@@ -34,10 +35,12 @@ import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
|||||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||||
import settings from 'feather-icons/dist/icons/settings.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 target from 'feather-icons/dist/icons/target.svg?raw';
|
||||||
import trash2 from 'feather-icons/dist/icons/trash-2.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 uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
|
||||||
import user from 'feather-icons/dist/icons/user.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 x from 'feather-icons/dist/icons/x.svg?raw';
|
||||||
import check from 'feather-icons/dist/icons/check.svg?raw';
|
import check from 'feather-icons/dist/icons/check.svg?raw';
|
||||||
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
||||||
@@ -47,9 +50,15 @@ import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
|||||||
* Pinhead icons
|
* Pinhead icons
|
||||||
*/
|
*/
|
||||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
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 barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||||
|
import barrelWithBunghole from '@waysidemapping/pinhead/dist/icons/barrel_with_bunghole.svg?raw';
|
||||||
|
import bicycle from '@waysidemapping/pinhead/dist/icons/bicycle.svg?raw';
|
||||||
|
import bulldozer from '@waysidemapping/pinhead/dist/icons/bulldozer.svg?raw';
|
||||||
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
import climbingWall from '@waysidemapping/pinhead/dist/icons/climbing_wall.svg?raw';
|
||||||
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.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 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 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';
|
import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
|
||||||
@@ -60,6 +69,7 @@ import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
|||||||
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.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 car from '@waysidemapping/pinhead/dist/icons/car.svg?raw';
|
||||||
import carAndWrench from '@waysidemapping/pinhead/dist/icons/car_and_wrench.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 castleKeep from '@waysidemapping/pinhead/dist/icons/castle_keep.svg?raw';
|
||||||
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.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';
|
import cityGate from '@waysidemapping/pinhead/dist/icons/city_gate.svg?raw';
|
||||||
@@ -72,11 +82,13 @@ import clothesHanger from '@waysidemapping/pinhead/dist/icons/clothes_hanger.svg
|
|||||||
import cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw';
|
import cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw';
|
||||||
import cloth from '@waysidemapping/pinhead/dist/icons/cloth.svg?raw';
|
import cloth from '@waysidemapping/pinhead/dist/icons/cloth.svg?raw';
|
||||||
import cocktail from '@waysidemapping/pinhead/dist/icons/cocktail.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 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 comedyMaskAndTragedyMask from '@waysidemapping/pinhead/dist/icons/comedy_mask_and_tragedy_mask.svg?raw';
|
||||||
import croissant from '@waysidemapping/pinhead/dist/icons/croissant.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 cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
|
||||||
import donut from '@waysidemapping/pinhead/dist/icons/donut.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 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 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';
|
import familyRestroomSymbol from '@waysidemapping/pinhead/dist/icons/family_restroom_symbol.svg?raw';
|
||||||
@@ -86,10 +98,14 @@ import fish from '@waysidemapping/pinhead/dist/icons/fish.svg?raw';
|
|||||||
import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
|
import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
|
||||||
import flowerBouquet from '@waysidemapping/pinhead/dist/icons/flower_bouquet.svg?raw';
|
import flowerBouquet from '@waysidemapping/pinhead/dist/icons/flower_bouquet.svg?raw';
|
||||||
import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
|
import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
|
||||||
|
import fortress from '@waysidemapping/pinhead/dist/icons/fortress.svg?raw';
|
||||||
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
|
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
|
||||||
|
import gableRoofedBuilding from '@waysidemapping/pinhead/dist/icons/gable_roofed_building.svg?raw';
|
||||||
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
||||||
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.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 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 iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
|
||||||
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
|
import industrialBuilding from '@waysidemapping/pinhead/dist/icons/industrial_building.svg?raw';
|
||||||
import infoI from '@waysidemapping/pinhead/dist/icons/info_i.svg?raw';
|
import infoI from '@waysidemapping/pinhead/dist/icons/info_i.svg?raw';
|
||||||
@@ -102,6 +118,7 @@ import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
|
|||||||
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||||
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
||||||
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
||||||
|
import paymentCard from '@waysidemapping/pinhead/dist/icons/payment_card.svg?raw';
|
||||||
import parkingP from '@waysidemapping/pinhead/dist/icons/p_wide.svg?raw';
|
import parkingP from '@waysidemapping/pinhead/dist/icons/p_wide.svg?raw';
|
||||||
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
|
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
|
||||||
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
|
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
|
||||||
@@ -127,15 +144,19 @@ import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?r
|
|||||||
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
|
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
|
||||||
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw';
|
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw';
|
||||||
import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
|
import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
|
||||||
|
import townBuildings from '@waysidemapping/pinhead/dist/icons/town_buildings.svg?raw';
|
||||||
|
import transitVehicleOnRailwayTrack from '@waysidemapping/pinhead/dist/icons/transit_vehicle_on_railway_track.svg?raw';
|
||||||
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
||||||
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
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 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 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';
|
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Custom/local icons
|
* Custom/local icons
|
||||||
*/
|
*/
|
||||||
|
import bitcoin from '../icons/bitcoin.svg?raw';
|
||||||
import loadingRing from '../icons/270-ring.svg?raw';
|
import loadingRing from '../icons/270-ring.svg?raw';
|
||||||
import nostrich from '../icons/nostrich-2.svg?raw';
|
import nostrich from '../icons/nostrich-2.svg?raw';
|
||||||
import remotestorage from '../icons/remotestorage.svg?raw';
|
import remotestorage from '../icons/remotestorage.svg?raw';
|
||||||
@@ -145,16 +166,24 @@ import wikipedia from '../icons/wikipedia.svg?raw';
|
|||||||
const ICONS = {
|
const ICONS = {
|
||||||
activity,
|
activity,
|
||||||
angelfish,
|
angelfish,
|
||||||
|
anchor,
|
||||||
|
'airport-terminal': airportTerminal,
|
||||||
'arrow-left': arrowLeft,
|
'arrow-left': arrowLeft,
|
||||||
barbell,
|
barbell,
|
||||||
|
'barrel-with-bunghole': barrelWithBunghole,
|
||||||
|
bicycle,
|
||||||
|
bulldozer,
|
||||||
banknote,
|
banknote,
|
||||||
|
'banknote-in-slot': banknoteInSlot,
|
||||||
'badge-shield-with-fire': badgeShieldWithFire,
|
'badge-shield-with-fire': badgeShieldWithFire,
|
||||||
'beach-umbrella-in-ground': beachUmbrellaInGround,
|
'beach-umbrella-in-ground': beachUmbrellaInGround,
|
||||||
'beer-mug-with-foam': beerMugWithFoam,
|
'beer-mug-with-foam': beerMugWithFoam,
|
||||||
|
bitcoin,
|
||||||
bookmark,
|
bookmark,
|
||||||
'boxing-glove-up': boxingGloveUp,
|
'boxing-glove-up': boxingGloveUp,
|
||||||
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
||||||
bridge,
|
bridge,
|
||||||
|
briefcase,
|
||||||
bus,
|
bus,
|
||||||
camera,
|
camera,
|
||||||
'feather-camera': featherCamera,
|
'feather-camera': featherCamera,
|
||||||
@@ -176,6 +205,7 @@ const ICONS = {
|
|||||||
cleaver,
|
cleaver,
|
||||||
cloth,
|
cloth,
|
||||||
cocktail,
|
cocktail,
|
||||||
|
couch,
|
||||||
clock,
|
clock,
|
||||||
'coffee-bean': coffeeBean,
|
'coffee-bean': coffeeBean,
|
||||||
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
||||||
@@ -183,6 +213,7 @@ const ICONS = {
|
|||||||
'cup-and-saucer': cupAndSaucer,
|
'cup-and-saucer': cupAndSaucer,
|
||||||
database,
|
database,
|
||||||
donut,
|
donut,
|
||||||
|
'dollar-swap-to-euro': dollarSwapToEuro,
|
||||||
edit,
|
edit,
|
||||||
eyeglasses,
|
eyeglasses,
|
||||||
facebook,
|
facebook,
|
||||||
@@ -195,8 +226,12 @@ const ICONS = {
|
|||||||
'flower-bouquet': flowerBouquet,
|
'flower-bouquet': flowerBouquet,
|
||||||
'fork-and-knife': forkAndKnife,
|
'fork-and-knife': forkAndKnife,
|
||||||
fort,
|
fort,
|
||||||
|
fortress,
|
||||||
|
'gable-roofed-building': gableRoofedBuilding,
|
||||||
|
'gasoline-pump': gasolinePump,
|
||||||
gift,
|
gift,
|
||||||
globe,
|
globe,
|
||||||
|
grapes,
|
||||||
gravestone,
|
gravestone,
|
||||||
'grecian-vase': grecianVase,
|
'grecian-vase': grecianVase,
|
||||||
'greek-cross': greekCross,
|
'greek-cross': greekCross,
|
||||||
@@ -226,6 +261,7 @@ const ICONS = {
|
|||||||
nostrich,
|
nostrich,
|
||||||
'open-book': openBook,
|
'open-book': openBook,
|
||||||
palace,
|
palace,
|
||||||
|
'payment-card': paymentCard,
|
||||||
'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall,
|
'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall,
|
||||||
'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track':
|
'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track':
|
||||||
personBoardingTramWithDestinationDisplayAndPantographOnTramTrack,
|
personBoardingTramWithDestinationDisplayAndPantographOnTramTrack,
|
||||||
@@ -260,25 +296,35 @@ const ICONS = {
|
|||||||
target,
|
target,
|
||||||
'trash-2': trash2,
|
'trash-2': trash2,
|
||||||
'upload-cloud': uploadCloud,
|
'upload-cloud': uploadCloud,
|
||||||
|
'town-buildings': townBuildings,
|
||||||
|
'transit-vehicle-on-railway-track': transitVehicleOnRailwayTrack,
|
||||||
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||||
user,
|
user,
|
||||||
|
'user-check': userCheck,
|
||||||
'village-buildings': villageBuildings,
|
'village-buildings': villageBuildings,
|
||||||
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||||
whatsapp,
|
whatsapp,
|
||||||
wikipedia,
|
wikipedia,
|
||||||
winding_way_wide: windingWayWide,
|
winding_way_wide: windingWayWide,
|
||||||
|
'water-standpipe-with-handle-and-drinking-glass':
|
||||||
|
waterStandpipeWithHandleAndDrinkingGlass,
|
||||||
parking_p: parkingP,
|
parking_p: parkingP,
|
||||||
car,
|
car,
|
||||||
'car-and-wrench': carAndWrench,
|
'car-and-wrench': carAndWrench,
|
||||||
|
'car-between-roller-brushes': carBetweenRollerBrushes,
|
||||||
'castle-keep': castleKeep,
|
'castle-keep': castleKeep,
|
||||||
x,
|
x,
|
||||||
zap,
|
zap,
|
||||||
|
shield,
|
||||||
'loading-ring': loadingRing,
|
'loading-ring': loadingRing,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILLED_ICONS = [
|
const FILLED_ICONS = [
|
||||||
|
'banknote',
|
||||||
|
'bitcoin',
|
||||||
'fork-and-knife',
|
'fork-and-knife',
|
||||||
|
'payment-card',
|
||||||
'wikipedia',
|
'wikipedia',
|
||||||
'whatsapp',
|
'whatsapp',
|
||||||
'cup-and-saucer',
|
'cup-and-saucer',
|
||||||
|
|||||||
@@ -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.
|
* 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.
|
* Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0.
|
||||||
|
|||||||
+43
-12
@@ -9,6 +9,8 @@ export const POI_ICON_RULES = [
|
|||||||
{ tags: { cuisine: 'coffee_shop' }, icon: 'coffee-bean' },
|
{ tags: { cuisine: 'coffee_shop' }, icon: 'coffee-bean' },
|
||||||
{ tags: { cuisine: 'coffee' }, icon: 'coffee-bean' },
|
{ tags: { cuisine: 'coffee' }, icon: 'coffee-bean' },
|
||||||
|
|
||||||
|
{ tags: { craft: 'brewery' }, icon: 'barrel-with-bunghole' },
|
||||||
|
|
||||||
// General Amenity/Shop Types
|
// General Amenity/Shop Types
|
||||||
{ tags: { amenity: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
{ tags: { amenity: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||||
{ tags: { cuisine: '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: 'social_facility' }, icon: 'family-restroom-symbol' },
|
||||||
|
|
||||||
{ tags: { amenity: 'bank' }, icon: 'banknote' },
|
{ 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: 'place_of_worship' }, icon: 'place-of-worship-building' },
|
||||||
{ tags: { amenity: 'townhall' }, icon: 'classical-building-with-clock' },
|
{ tags: { amenity: 'townhall' }, icon: 'classical-building-with-clock' },
|
||||||
{ tags: { building: '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: 'fire_station' }, icon: 'badge-shield-with-fire' },
|
||||||
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
||||||
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
|
{ 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: 'school' }, icon: 'open-book' },
|
||||||
{ tags: { amenity: 'driving_school' }, icon: 'car' },
|
{ 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: 'coffee' }, icon: 'coffee-bean' },
|
||||||
{ tags: { shop: 'tea' }, icon: 'coffee-bean' },
|
{ tags: { shop: 'tea' }, icon: 'coffee-bean' },
|
||||||
@@ -85,17 +97,11 @@ export const POI_ICON_RULES = [
|
|||||||
{ tags: { natural: 'beach' }, icon: 'beach-umbrella-in-ground' },
|
{ tags: { natural: 'beach' }, icon: 'beach-umbrella-in-ground' },
|
||||||
{ tags: { leisure: 'park' }, icon: 'tree-and-bench-with-backrest' },
|
{ tags: { leisure: 'park' }, icon: 'tree-and-bench-with-backrest' },
|
||||||
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
|
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
|
||||||
|
{ tags: { leisure: 'marina' }, icon: 'anchor' },
|
||||||
// Transport
|
{ tags: { landuse: 'vineyard' }, icon: 'grapes' },
|
||||||
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
{ tags: { landuse: 'cemetery' }, icon: 'memorial-stone-with-inscription' },
|
||||||
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
|
{ tags: { landuse: 'construction' }, icon: 'bulldozer' },
|
||||||
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
|
{ tags: { landuse: 'residential' }, icon: 'town-buildings' },
|
||||||
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
|
|
||||||
{ tags: { bus: true }, icon: 'bus' },
|
|
||||||
{
|
|
||||||
tags: { railway: 'tram_stop' },
|
|
||||||
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Tourism
|
// Tourism
|
||||||
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
|
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
|
||||||
@@ -116,9 +122,11 @@ export const POI_ICON_RULES = [
|
|||||||
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
|
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
|
||||||
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
||||||
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
||||||
|
{ tags: { historic: 'aqueduct' }, icon: 'bridge' },
|
||||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||||
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
|
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
|
||||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||||
|
{ tags: { historic: 'monastery' }, icon: 'fortress' },
|
||||||
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
|
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
|
||||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||||
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||||
@@ -138,13 +146,30 @@ export const POI_ICON_RULES = [
|
|||||||
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
||||||
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
||||||
{ tags: { historic: 'ruin' }, icon: 'camera' },
|
{ tags: { historic: 'ruin' }, icon: 'camera' },
|
||||||
{ tags: { historic: 'yes' }, icon: 'camera' },
|
|
||||||
|
// 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' },
|
||||||
|
{ tags: { highway: 'cycleway' }, icon: 'bicycle' },
|
||||||
|
{ tags: { bus: true }, icon: 'bus' },
|
||||||
|
{
|
||||||
|
tags: { railway: 'tram_stop' },
|
||||||
|
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
|
||||||
|
},
|
||||||
|
{ tags: { light_rail: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||||
|
{ tags: { train: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||||
|
{ tags: { tram: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||||
|
{ tags: { subway: true }, icon: 'transit-vehicle-on-railway-track' },
|
||||||
|
|
||||||
// Accommodation
|
// Accommodation
|
||||||
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
|
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
|
||||||
{ tags: { tourism: 'hostel' }, icon: 'person-sleeping-in-bed' },
|
{ tags: { tourism: 'hostel' }, icon: 'person-sleeping-in-bed' },
|
||||||
{ tags: { tourism: 'motel' }, icon: 'person-sleeping-in-bed' },
|
{ tags: { tourism: 'motel' }, icon: 'person-sleeping-in-bed' },
|
||||||
{ tags: { tourism: 'guest_house' }, icon: 'person-sleeping-in-bed' },
|
{ tags: { tourism: 'guest_house' }, icon: 'person-sleeping-in-bed' },
|
||||||
|
{ tags: { leisure: 'resort' }, icon: 'person-sleeping-in-bed' },
|
||||||
|
|
||||||
// Sports / Motorsports
|
// Sports / Motorsports
|
||||||
{ tags: { sport: 'motor' }, icon: 'flag-checkered' },
|
{ tags: { sport: 'motor' }, icon: 'flag-checkered' },
|
||||||
@@ -174,6 +199,7 @@ export const POI_ICON_RULES = [
|
|||||||
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
|
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||||
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
|
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
|
||||||
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
|
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
|
||||||
|
{ tags: { landuse: 'recreation_ground' }, icon: 'person-running' },
|
||||||
{ tags: { sport: true }, icon: 'person-running' },
|
{ tags: { sport: true }, icon: 'person-running' },
|
||||||
|
|
||||||
// Healthcare
|
// Healthcare
|
||||||
@@ -187,6 +213,11 @@ export const POI_ICON_RULES = [
|
|||||||
// Buildings
|
// Buildings
|
||||||
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
||||||
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
||||||
|
{ tags: { building: 'office' }, icon: 'lowrise-building' },
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
{ tags: { historic: true }, icon: 'camera' },
|
||||||
|
{ tags: { building: true }, icon: 'gable-roofed-building' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-2
@@ -34,18 +34,18 @@ export function getLocalizedName(tags, defaultName = 'Untitled Place') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PLACE_TYPE_KEYS = [
|
const PLACE_TYPE_KEYS = [
|
||||||
|
'craft',
|
||||||
'amenity',
|
'amenity',
|
||||||
'shop',
|
'shop',
|
||||||
'tourism',
|
'tourism',
|
||||||
'historic',
|
'historic',
|
||||||
'leisure',
|
'leisure',
|
||||||
'office',
|
'office',
|
||||||
'craft',
|
'aeroway',
|
||||||
'building',
|
'building',
|
||||||
'landuse',
|
'landuse',
|
||||||
'public_transport',
|
'public_transport',
|
||||||
'highway',
|
'highway',
|
||||||
'aeroway',
|
|
||||||
'waterway',
|
'waterway',
|
||||||
'natural',
|
'natural',
|
||||||
'place',
|
'place',
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
const CARD_KEYS = [
|
||||||
|
'payment:cards',
|
||||||
|
'payment:credit_cards',
|
||||||
|
'payment:debit_cards',
|
||||||
|
'payment:contactless',
|
||||||
|
'payment:visa',
|
||||||
|
'payment:mastercard',
|
||||||
|
'payment:american_express',
|
||||||
|
'payment:amex',
|
||||||
|
'payment:maestro',
|
||||||
|
'payment:girocard',
|
||||||
|
'payment:discover_card',
|
||||||
|
'payment:diners_club',
|
||||||
|
'payment:jcb',
|
||||||
|
'payment:unionpay',
|
||||||
|
'payment:bancontact',
|
||||||
|
'payment:postfinance_card',
|
||||||
|
'payment:mir',
|
||||||
|
'payment:dankort',
|
||||||
|
'payment:interac',
|
||||||
|
'payment:visa_debit',
|
||||||
|
'payment:mastercard_debit',
|
||||||
|
'payment:apple_pay',
|
||||||
|
'payment:google_pay',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isYes(val) {
|
||||||
|
return val === 'yes' || val === 'only';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNo(val) {
|
||||||
|
return val === 'no';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePaymentMethods(tags = {}) {
|
||||||
|
const safeTags = tags || {};
|
||||||
|
|
||||||
|
// 1. Cash
|
||||||
|
let cash = null;
|
||||||
|
const cashVal = safeTags['payment:cash'];
|
||||||
|
const coinsVal = safeTags['payment:coins'];
|
||||||
|
const notesVal = safeTags['payment:notes'];
|
||||||
|
|
||||||
|
if (
|
||||||
|
isYes(cashVal) ||
|
||||||
|
(!isNo(cashVal) && (isYes(coinsVal) || isYes(notesVal)))
|
||||||
|
) {
|
||||||
|
cash = 'accepted';
|
||||||
|
} else if (
|
||||||
|
isNo(cashVal) ||
|
||||||
|
(!isYes(cashVal) && isNo(coinsVal) && isNo(notesVal))
|
||||||
|
) {
|
||||||
|
cash = 'denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Cards
|
||||||
|
let cards = null;
|
||||||
|
const anyCardAccepted = CARD_KEYS.some((key) => isYes(safeTags[key]));
|
||||||
|
|
||||||
|
if (anyCardAccepted) {
|
||||||
|
cards = 'accepted';
|
||||||
|
} else {
|
||||||
|
const cardsNo = isNo(safeTags['payment:cards']);
|
||||||
|
const creditAndDebitNo =
|
||||||
|
isNo(safeTags['payment:credit_cards']) &&
|
||||||
|
isNo(safeTags['payment:debit_cards']);
|
||||||
|
const cashOnly = safeTags['payment:cash'] === 'only';
|
||||||
|
|
||||||
|
if (cardsNo || creditAndDebitNo || cashOnly) {
|
||||||
|
cards = 'denied';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Bitcoin
|
||||||
|
const xbtVal = safeTags['currency:XBT'];
|
||||||
|
const lightningVal = safeTags['payment:lightning'];
|
||||||
|
const onchainVal = safeTags['payment:onchain'];
|
||||||
|
const btcVal = safeTags['payment:bitcoin'];
|
||||||
|
const btcCurrencyVal = safeTags['currency:BTC'];
|
||||||
|
|
||||||
|
const isBtcAccepted =
|
||||||
|
isYes(xbtVal) ||
|
||||||
|
isYes(lightningVal) ||
|
||||||
|
isYes(onchainVal) ||
|
||||||
|
isYes(btcVal) ||
|
||||||
|
isYes(btcCurrencyVal);
|
||||||
|
|
||||||
|
const isBtcDenied = !isBtcAccepted && (isNo(xbtVal) || isNo(btcVal));
|
||||||
|
|
||||||
|
const lightning = isYes(lightningVal);
|
||||||
|
const onchain =
|
||||||
|
isYes(onchainVal) ||
|
||||||
|
((isYes(xbtVal) || isYes(btcVal) || isYes(btcCurrencyVal)) &&
|
||||||
|
!isNo(onchainVal));
|
||||||
|
|
||||||
|
const bitcoin = {
|
||||||
|
status: isBtcAccepted ? 'accepted' : isBtcDenied ? 'denied' : null,
|
||||||
|
lightning,
|
||||||
|
onchain: isBtcAccepted ? onchain : false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPaymentInfo =
|
||||||
|
cash !== null || cards !== null || bitcoin.status !== null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasPaymentInfo,
|
||||||
|
cash,
|
||||||
|
cards,
|
||||||
|
bitcoin,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@ import { POI_CATEGORIES } from './poi-categories';
|
|||||||
import { getMatchingPoiCategoryIds } from './poi-category-matcher';
|
import { getMatchingPoiCategoryIds } from './poi-category-matcher';
|
||||||
|
|
||||||
export const CATEGORY_TAGS = {
|
export const CATEGORY_TAGS = {
|
||||||
restaurants: ['food', 'menu', 'vibe', 'front'],
|
restaurants: ['front', 'food', 'drinks', 'menu', 'vibe'],
|
||||||
coffee: ['food', 'menu', 'vibe', 'front'],
|
coffee: ['front', 'food', 'drinks', 'menu', 'vibe'],
|
||||||
groceries: ['front', 'food'],
|
groceries: ['front', 'products'],
|
||||||
'things-to-do': ['architecture', 'amenities', 'vibe', 'front'],
|
'things-to-do': ['front', 'architecture', 'amenities', 'vibe'],
|
||||||
accommodation: ['rooms', 'amenities', 'food', 'vibe', 'front'],
|
accommodation: ['front', 'rooms', 'amenities', 'food', 'drinks', 'vibe'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSuggestedPhotoTags(place) {
|
export function getSuggestedPhotoTags(place) {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const POI_CATEGORIES = [
|
|||||||
filter: [
|
filter: [
|
||||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]',
|
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork|information)$"]',
|
||||||
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||||
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
'["leisure"~"^(sports_centre|stadium|water_park|golf_course|marina)$"]',
|
||||||
'["historic"]',
|
'["historic"]',
|
||||||
'["shop"="flea_market"]',
|
'["shop"="flea_market"]',
|
||||||
],
|
],
|
||||||
@@ -55,11 +55,26 @@ export const POI_CATEGORIES = [
|
|||||||
id: 'accommodation',
|
id: 'accommodation',
|
||||||
label: 'Hotels',
|
label: 'Hotels',
|
||||||
icon: 'person-sleeping-in-bed',
|
icon: 'person-sleeping-in-bed',
|
||||||
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'],
|
filter: [
|
||||||
|
'["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]',
|
||||||
|
'["leisure"="resort"]',
|
||||||
|
],
|
||||||
types: ['node', 'way', 'relation'],
|
types: ['node', 'way', 'relation'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Special-cased category for spatial "Nearby" searches (triggered by clicking
|
||||||
|
// the map). It intentionally lives outside POI_CATEGORIES so it does not show
|
||||||
|
// up as a quick-search chip or in the search autocomplete. Like other
|
||||||
|
// categories it is represented in the URL as ?category=nearby, which lets the
|
||||||
|
// UI display its label and offer a clear button.
|
||||||
|
export const NEARBY_CATEGORY = {
|
||||||
|
id: 'nearby',
|
||||||
|
label: 'Nearby',
|
||||||
|
icon: 'target',
|
||||||
|
};
|
||||||
|
|
||||||
export function getCategoryById(id) {
|
export function getCategoryById(id) {
|
||||||
|
if (id === NEARBY_CATEGORY.id) return NEARBY_CATEGORY;
|
||||||
return POI_CATEGORIES.find((c) => c.id === id);
|
return POI_CATEGORIES.find((c) => c.id === id);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Place Icons
|
||||||
|
|
||||||
|
How place/POI icons on the map are imported, selected from OSM tags, and rendered.
|
||||||
|
|
||||||
|
> Scope: this covers the **POI icon system** used for map markers and UI.
|
||||||
|
> It does **not** cover the PWA/launcher icons in `public/icons/` and `release/icons/`
|
||||||
|
> (those are app icons referenced from `index.html` / `web-app-manifest.json`).
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `app/utils/icons.js` | Registry. Imports every SVG as a raw string (`?raw`), exposes `getIcon(name)` and `isIconFilled(name)`. |
|
||||||
|
| `app/utils/osm-icons.js` | OSM tag → icon mapping. `POI_ICON_RULES`, `getIconNameForTags(tags)`, `getIconSvgForTags(tags)`. |
|
||||||
|
| `app/components/icon.gjs` | Generic `<Icon @name="..." />` renderer used across the UI. |
|
||||||
|
| `app/components/map.gjs` | Map marker call site; embeds the icon SVG into an OpenLayers `Icon` data-URI. |
|
||||||
|
| `app/icons/` | Custom/local SVG files. |
|
||||||
|
| `node_modules/@waysidemapping/pinhead/dist/icons/*.svg` | Pinhead source icons (2,320 files). |
|
||||||
|
| `node_modules/feather-icons/dist/icons/*.svg` | Feather source icons. |
|
||||||
|
| `tests/unit/utils/osm-icons-test.js` | Tests for tag matching + integrity check that every rule icon exists. |
|
||||||
|
| `tests/integration/components/icon-test.gjs` | Tests for the `<Icon>` component. |
|
||||||
|
|
||||||
|
## How icons are imported (`app/utils/icons.js`)
|
||||||
|
|
||||||
|
Every icon is a **static named import** with Vite's `?raw` suffix, so the file contents
|
||||||
|
arrive as a string. There is **no glob/context import**; each icon is listed explicitly.
|
||||||
|
|
||||||
|
Imports are grouped and sorted alphabetically (per the header comment at
|
||||||
|
`app/utils/icons.js:1`):
|
||||||
|
|
||||||
|
1. **Feather icons** — `feather-icons/dist/icons/*.svg`
|
||||||
|
2. **Pinhead icons** — `@waysidemapping/pinhead/dist/icons/*.svg`
|
||||||
|
3. **Custom/local icons** — `../icons/*.svg`
|
||||||
|
|
||||||
|
```js
|
||||||
|
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||||
|
import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
|
||||||
|
import bitcoin from '../icons/bitcoin.svg?raw';
|
||||||
|
```
|
||||||
|
|
||||||
|
The imported strings are collected in the `ICONS` object, keyed by the kebab-case name
|
||||||
|
used everywhere else in the app. Quoted keys are needed when the name contains dashes:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ICONS = {
|
||||||
|
activity, // shorthand works for single-word names
|
||||||
|
'map-pin': mapPin, // quote dashed keys
|
||||||
|
donut,
|
||||||
|
'loading-ring': loadingRing,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Some registry keys are intentionally **not** normalized to kebab-case
|
||||||
|
(e.g. `climbing_wall`, `parking_p`, `winding_way_wide`) — match whatever string the
|
||||||
|
rule uses.
|
||||||
|
|
||||||
|
`FILLED_ICONS` lists names that render with `fill` instead of `stroke`
|
||||||
|
(`app/utils/icons.js:309`). `getIcon(name)` returns the SVG string (or `undefined`);
|
||||||
|
`isIconFilled(name)` drives the `.icon-filled` CSS class.
|
||||||
|
|
||||||
|
## How icons are selected from OSM tags (`app/utils/osm-icons.js`)
|
||||||
|
|
||||||
|
`POI_ICON_RULES` is an **ordered** array of `{ tags, icon }`. Each rule requires **all**
|
||||||
|
of its listed tags to match. The **first matching rule wins**, so more specific rules
|
||||||
|
must come before catch-alls.
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const POI_ICON_RULES = [
|
||||||
|
{ tags: { cuisine: 'donut' }, icon: 'donut' },
|
||||||
|
...{ tags: { shop: true }, icon: 'shopping-bag' }, // catch-all, must be late
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
`getIconNameForTags(tags)` (`app/utils/osm-icons.js:214`) matching semantics:
|
||||||
|
|
||||||
|
- Returns `null` if `tags` is falsy or nothing matches.
|
||||||
|
- A rule tag only matches if its value is truthy.
|
||||||
|
- A tag value is split on `;` and trimmed, so `cuisine=donut;coffee_shop` matches both
|
||||||
|
`donut` and `coffee_shop` rules.
|
||||||
|
- `expectedValue: true` matches any non-empty value.
|
||||||
|
|
||||||
|
`getIconSvgForTags(tags)` wraps the name lookup and returns the raw SVG. It is exported
|
||||||
|
but currently unused.
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
|
||||||
|
- **UI:** `<Icon @name="donut" @size={{16}} />` — `app/components/icon.gjs`. Renders
|
||||||
|
only if `getIcon(@name)` returns an SVG. `@filled` overrides `isIconFilled`.
|
||||||
|
- **Map markers:** `searchResultStyle` in `app/components/map.gjs:123` calls
|
||||||
|
`getIconNameForTags(tags)` (line 151), strips the `<svg>` wrapper, and embeds the
|
||||||
|
paths in white inside the red pin at a `0.8` scale (lines 194-199). Results are cached
|
||||||
|
in `cachedIconUrls` keyed by icon name / `'default'`.
|
||||||
|
|
||||||
|
## Adding a new icon
|
||||||
|
|
||||||
|
1. **Find an SVG.**
|
||||||
|
- Pinhead: browse `node_modules/@waysidemapping/pinhead/dist/icons/` or search the
|
||||||
|
gallery at https://pinhead.ink. Filenames use snake_case (`coffee_bean.svg`).
|
||||||
|
- Feather: `node_modules/feather-icons/dist/icons/`.
|
||||||
|
- Otherwise drop a custom file in `app/icons/`.
|
||||||
|
2. **Import it** in `app/utils/icons.js` in the correct group, keeping the group
|
||||||
|
alphabetical, using `?raw`.
|
||||||
|
3. **Register it** in the `ICONS` map with a kebab-case key (quote it if it has dashes).
|
||||||
|
4. **Mark it filled** in `FILLED_ICONS` if it is a fill-style (not stroke-style) icon.
|
||||||
|
5. **Map OSM tags** in `POI_ICON_RULES` (`app/utils/osm-icons.js`), inserting the rule
|
||||||
|
before any catch-all that would otherwise shadow it.
|
||||||
|
|
||||||
|
No test is needed just to register a new icon or tag rule. The generic integrity test
|
||||||
|
(`all icons used in POI_ICON_RULES exist in the icons utility`) already checks every
|
||||||
|
rule's icon. Only add a focused test in `tests/unit/utils/osm-icons-test.js` for
|
||||||
|
non-trivial matching behavior (e.g. ordering/shadowing, semicolon-separated values, or
|
||||||
|
a new catch-all).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Order matters.** First match wins; put specific rules before catch-alls
|
||||||
|
(`shop: true`, `office: true`, `craft: true`, `sport: true`, `historic: yes`).
|
||||||
|
- **The integrity test is your safety net.** `osm-icons-test.js` asserts every icon named
|
||||||
|
in `POI_ICON_RULES` exists in `getIcon`. Forgetting the import/registry entry fails CI.
|
||||||
|
This covers _all_ icons automatically, so you normally don't need to add a test when
|
||||||
|
adding one.
|
||||||
|
- **Naming mismatch.** Pinhead filenames are snake_case; registry keys are usually
|
||||||
|
kebab-case, with a few unnormalized exceptions. A typo yields `null` (no marker icon)
|
||||||
|
rather than an error.
|
||||||
|
- **Two icon systems.** Don't touch `public/icons/` (PWA icons) when working on POI icons;
|
||||||
|
`pnpm build:icons` regenerates those PNGs and is release-only.
|
||||||
|
- **Don't confuse with categories.** Tag→category matching for the sidebar/chips lives in
|
||||||
|
`app/utils/poi-category-matcher.js` and `app/utils/poi-categories.js`, separate from
|
||||||
|
tag→icon.
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm test # builds and runs the full QUnit suite via Testem
|
||||||
|
pnpm lint # ESLint + Stylelint + Prettier + ember-template-lint
|
||||||
|
pnpm lint:fix # auto-fix
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<!-- Fediverse account -->
|
||||||
|
<link rel="me" href="https://kosmos.social/@marco">
|
||||||
|
|
||||||
<!-- Open Graph -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:title" content="Marco">
|
<meta property="og:title" content="Marco">
|
||||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.30.0",
|
"version": "1.34.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
var t=Object.create,e=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,s=Object.prototype.hasOwnProperty,o=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports),a=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:"Module"}),r},l=(t,i,o,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(var l,c=r(i),u=0,h=c.length;u<h;u++)l=c[u],s.call(t,l)||l===o||e(t,l,{get:(t=>i[t]).bind(null,l),enumerable:!(a=n(i,l))||a.enumerable});return t},c=(n,r,o)=>(o=null!=n?t(i(n)):{},l(!r&&n&&n.__esModule&&s.call(n,"default")?o:e(o,"default",{value:n,enumerable:!0}),n)),u=(t=>"undefined"!=typeof require?require:"undefined"!=typeof Proxy?new Proxy(t,{get:(t,e)=>("undefined"!=typeof require?require:t)[e]}):t)(function(t){if("undefined"!=typeof require)return require.apply(this,arguments);throw Error('Calling `require` for "'+t+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();var h,d=Object.prototype,p=Symbol("undefined"),f=function(t){return t[t.ADD=0]="ADD",t[t.ONCE=1]="ONCE",t[t.REMOVE=2]="REMOVE",t}(f||{}),g=1,m=class{_descriptors;_mixins;_isInit;_lazyChains;_values;_revisions;source;proto;_parent;_listeners;_listenersVersion=1;_inheritedEnd=-1;_flattenedVersion=0;constructor(t){this._parent=void 0,this._descriptors=void 0,this._mixins=void 0,this._lazyChains=void 0,this._values=void 0,this._revisions=void 0,this._isInit=!1,this.source=t,this.proto=void 0===t.constructor?void 0:t.constructor.prototype,this._listeners=void 0}get parent(){let t=this._parent;if(void 0===t){let e=y(this.source);this._parent=t=null===e||e===d?null:_(e)}return t}setInitializing(){this._isInit=!0}unsetInitializing(){this._isInit=!1}isInitializing(){return this._isInit}isPrototypeMeta(t){return this.proto===this.source&&this.source===t}_getOrCreateOwnMap(t){return this[t]||(this[t]=Object.create(null))}_getOrCreateOwnSet(t){return this[t]||(this[t]=new Set)}_findInheritedMap(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r){let t=r.get(e);if(void 0!==t)return t}n=n.parent}}_hasInInheritedSet(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r&&r.has(e))return!0;n=n.parent}return!1}valueFor(t){let e=this._values;return void 0!==e?e[t]:void 0}setValueFor(t,e){this._getOrCreateOwnMap("_values")[t]=e}revisionFor(t){let e=this._revisions;return void 0!==e?e[t]:void 0}setRevisionFor(t,e){this._getOrCreateOwnMap("_revisions")[t]=e}writableLazyChainsFor(t){let e=this._getOrCreateOwnMap("_lazyChains"),n=e[t];return void 0===n&&(n=e[t]=[]),n}readableLazyChainsFor(t){let e=this._lazyChains;if(void 0!==e)return e[t]}addMixin(t){this._getOrCreateOwnSet("_mixins").add(t)}hasMixin(t){return this._hasInInheritedSet("_mixins",t)}forEachMixins(t){let e,n=this;for(;null!==n;){let r=n._mixins;void 0!==r&&(e=void 0===e?new Set:e,r.forEach(n=>{e.has(n)||(e.add(n),t(n))})),n=n.parent}}writeDescriptors(t,e){(this._descriptors||(this._descriptors=new Map)).set(t,e)}peekDescriptors(t){let e=this._findInheritedMap("_descriptors",t);return e===p?void 0:e}removeDescriptors(t){this.writeDescriptors(t,p)}forEachDescriptors(t){let e,n=this;for(;null!==n;){let r=n._descriptors;void 0!==r&&(e=void 0===e?new Set:e,r.forEach((n,r)=>{e.has(r)||(e.add(r),n!==p&&t(r,n))})),n=n.parent}}addToListeners(t,e,n,r,i){this.pushListener(t,e,n,r?f.ONCE:f.ADD,i)}removeFromListeners(t,e,n){this.pushListener(t,e,n,f.REMOVE)}pushListener(t,e,n,r,i=!1){let s=this.writableListeners(),o=x(s,t,e,n);if(-1!==o&&o<this._inheritedEnd&&(s.splice(o,1),this._inheritedEnd--,o=-1),-1===o)s.push({event:t,target:e,method:n,kind:r,sync:i});else{let t=s[o];r===f.REMOVE&&t.kind!==f.REMOVE?s.splice(o,1):(t.kind=r,t.sync=i)}}writableListeners(){return this._flattenedVersion!==g||this.source!==this.proto&&-1!==this._inheritedEnd||g++,-1===this._inheritedEnd&&(this._inheritedEnd=0,this._listeners=[]),this._listeners}flattenedListeners(){if(this._flattenedVersion<g){let t=this.parent;if(null!==t){let e=t.flattenedListeners();if(void 0!==e)if(void 0===this._listeners)this._listeners=e;else{let t=this._listeners;this._inheritedEnd>0&&(t.splice(0,this._inheritedEnd),this._inheritedEnd=0);for(Line truncated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
:root{--body-text-color:#333;--primary-background-color:#fff;--hover-bg:#f8f9fa;--divider-color:#eee;--sidebar-width:350px;--link-color:#2a7fff;--link-color-visited:#6a4fbf;--marker-color-primary:#ea4335;--marker-color-dark:#b31412;--danger-color:var(--marker-color-primary);--danger-color-dark:var(--marker-color-dark);--default-list-color:#fc3;--secondary-text-color:#898989;--border-color:#d8d8d8;--success-color:#2e7d32;--secondary-background-color:#f0f0f0}html,body{overscroll-behavior:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent;height:100%}button{-webkit-tap-highlight-color:transparent}body{color:var(--body-text-color);margin:0;font-family:Noto Sans,sans-serif;font-size:16px;font-weight:400}#root,#ember-testing{height:100%}.map-container{-webkit-tap-highlight-color:transparent;background:#f8f9fa;outline:none;position:absolute;inset:0}#rs-widget-container{z-index:4000;display:none;position:absolute;top:60px;right:10px}#rs-widget-container.visible{display:block}.rs-backdrop{z-index:3999;position:fixed;inset:0}.app-header{z-index:3000;pointer-events:none;grid-template-columns:1fr auto 1fr;grid-template-areas:"search chips user";align-items:center;gap:1rem;height:60px;padding:.5rem 1rem;display:grid;position:absolute;top:0;left:0;right:0}@media (width<=768px){.app-header{grid-template-columns:1fr auto;grid-template-areas:"search user""chips chips";row-gap:8px;height:auto;padding:.5rem .5rem 0}}.header-left,.header-right,.header-center{pointer-events:auto}.header-left{grid-area:search;justify-self:start;align-items:center;width:100%;display:flex}@media (width>768px){.header-left{min-width:300px;max-width:350px}}.header-right{grid-area:user;justify-self:end}.header-center{grid-area:chips;justify-content:center;min-width:0;display:flex}@media (width>768px){.header-center .category-chips-scroll{width:auto;max-width:100%}}@media (width<=768px){.header-center{justify-content:start;width:100%;overflow:hidden}.header-center.searching{display:none}}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:0;display:flex}.user-avatar-placeholder{background:#2a3743;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;width:40px;height:40px;display:flex}.user-avatar{object-fit:cover;border-radius:50%;flex-shrink:0;width:40px;height:40px;display:block}.photo-preview-img{max-width:100%;height:auto}.dropzone{text-align:center;cursor:pointer;aspect-ratio:4/3;background-color:#ffffff05;border:2px dashed #ccc;border-radius:8px;margin:1.5rem 0 1rem;transition:all .2s}.dropzone.is-dragging{background-color:#61afef0d;border-color:#61afef}.dropzone-label{cursor:pointer;color:#898989;flex-direction:column;justify-content:center;align-items:center;gap:1rem;width:100%;height:100%;display:flex}.dropzone-label p{margin:0}.file-input-hidden{display:none}.photo-upload-item{aspect-ratio:4/3;background:#1e262e;border-radius:6px;width:100%;margin-bottom:1.25rem;position:relative;overflow:hidden}.photo-upload-item img{object-fit:contain;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.photo-upload-item .overlay,.photo-upload-item .btn-remove-photo{z-index:2}.photo-upload-item .overlay{background:#0009;flex-direction:column;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.upload-status-text{color:#fff;text-shadow:0 1px 3px #000c;text-align:center;margin-top:1rem;padding:0 1rem;font-size:.9rem}.photo-upload-item .error-overlay{cursor:pointer;background:#e06c75cc;border:none;width:100%;margin:0;padding:0}.photo-upload-item .btn-remove-photo{cursor:pointer;color:#fff;background:#000000b3;border:none;border-radius:50%;justify-content:center;align-items:center;width:24px;height:24px;padding:0;display:flex;position:absolute;top:4px;right:4px}.photo-upload-item .btn-remove-photo:hover{background:var(--danger-color)}.spin-animation{animation:1s linear infinite spin}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.btn-publish{width:100%}.user-menu-container{position:relative}.user-menu-popover{z-index:3001;background:#fff;border-radius:8px;width:280px;margin-top:10px;padding:1rem;position:absolute;top:100%;right:0;box-shadow:0 4px 12px #00000026}.menu-backdrop{z-index:3000;position:fixed;inset:0}.account-list{margin:0;padding:0;list-style:none}.account-item{border-bottom:1px solid var(--divider-color);flex-direction:column;padding:1rem 0;display:flex}.account-item:last-child{border-bottom:none;padding-bottom:0}.account-item:first-child{padding-top:0}.account-header{justify-content:space-between;align-items:center;display:flex}.account-status{color:#898989;white-space:nowrap;text-overflow:ellipsis;margin-top:.35rem;margin-left:calc(18px + .75rem);font-size:.85rem;overflow:hidden}.account-status strong{font-weight:600}.account-item.disaLine truncated
|
||||||
@@ -1 +0,0 @@
|
|||||||
:root{--body-text-color:#333;--primary-background-color:#fff;--hover-bg:#f8f9fa;--divider-color:#eee;--sidebar-width:350px;--link-color:#2a7fff;--link-color-visited:#6a4fbf;--marker-color-primary:#ea4335;--marker-color-dark:#b31412;--danger-color:var(--marker-color-primary);--danger-color-dark:var(--marker-color-dark);--default-list-color:#fc3}html,body{overscroll-behavior:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent;height:100%}button{-webkit-tap-highlight-color:transparent}body{color:var(--body-text-color);margin:0;font-family:Noto Sans,sans-serif;font-size:16px;font-weight:400}#root,#ember-testing{height:100%}.map-container{-webkit-tap-highlight-color:transparent;background:#f8f9fa;outline:none;position:absolute;inset:0}#rs-widget-container{z-index:4000;display:none;position:absolute;top:60px;right:10px}#rs-widget-container.visible{display:block}.rs-backdrop{z-index:3999;position:fixed;inset:0}.app-header{z-index:3000;pointer-events:none;grid-template-columns:1fr auto 1fr;grid-template-areas:"search chips user";align-items:center;gap:1rem;height:60px;padding:.5rem 1rem;display:grid;position:absolute;top:0;left:0;right:0}@media (width<=768px){.app-header{grid-template-columns:1fr auto;grid-template-areas:"search user""chips chips";row-gap:8px;height:auto;padding:.5rem .5rem 0}}.header-left,.header-right,.header-center{pointer-events:auto}.header-left{grid-area:search;justify-self:start;align-items:center;width:100%;display:flex}@media (width>768px){.header-left{min-width:300px;max-width:350px}}.header-right{grid-area:user;justify-self:end}.header-center{grid-area:chips;justify-content:center;min-width:0;display:flex}@media (width>768px){.header-center .category-chips-scroll{width:auto;max-width:100%}}@media (width<=768px){.header-center{justify-content:start;width:100%;overflow:hidden}.header-center.searching{display:none}}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:0;display:flex}.user-avatar-placeholder{background:#2a3743;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;width:40px;height:40px;display:flex}.user-avatar{object-fit:cover;border-radius:50%;flex-shrink:0;width:40px;height:40px;display:block}.photo-preview-img{max-width:100%;height:auto}.dropzone{text-align:center;cursor:pointer;aspect-ratio:4/3;background-color:#ffffff05;border:2px dashed #ccc;border-radius:8px;margin:1.5rem 0 1rem;transition:all .2s}.dropzone.is-dragging{background-color:#61afef0d;border-color:#61afef}.dropzone-label{cursor:pointer;color:#898989;flex-direction:column;justify-content:center;align-items:center;gap:1rem;width:100%;height:100%;display:flex}.dropzone-label p{margin:0}.file-input-hidden{display:none}.photo-upload-item{aspect-ratio:4/3;background:#1e262e;border-radius:6px;width:100%;margin-bottom:1.25rem;position:relative;overflow:hidden}.photo-upload-item img{object-fit:contain;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.photo-upload-item .overlay,.photo-upload-item .btn-remove-photo{z-index:2}.photo-upload-item .overlay{background:#0009;flex-direction:column;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.upload-status-text{color:#fff;text-shadow:0 1px 3px #000c;text-align:center;margin-top:1rem;padding:0 1rem;font-size:.9rem}.photo-upload-item .error-overlay{cursor:pointer;background:#e06c75cc;border:none;width:100%;margin:0;padding:0}.photo-upload-item .btn-remove-photo{cursor:pointer;color:#fff;background:#000000b3;border:none;border-radius:50%;justify-content:center;align-items:center;width:24px;height:24px;padding:0;display:flex;position:absolute;top:4px;right:4px}.photo-upload-item .btn-remove-photo:hover{background:var(--danger-color)}.spin-animation{animation:1s linear infinite spin}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.btn-publish{width:100%}.user-menu-container{position:relative}.user-menu-popover{z-index:3001;background:#fff;border-radius:8px;width:280px;margin-top:10px;padding:1rem;position:absolute;top:100%;right:0;box-shadow:0 4px 12px #00000026}.menu-backdrop{z-index:3000;position:fixed;inset:0}.account-list{margin:0;padding:0;list-style:none}.account-item{border-bottom:1px solid var(--divider-color);flex-direction:column;padding:1rem 0;display:flex}.account-item:last-child{border-bottom:none;padding-bottom:0}.account-item:first-child{padding-top:0}.account-header{justify-content:space-between;align-items:center;display:flex}.account-status{color:#898989;white-space:nowrap;text-overflow:ellipsis;margin-top:.35rem;margin-left:calc(18px + .75rem);font-size:.85rem;overflow:hidden}.account-status strong{font-weight:600}.account-item.disabled{opacity:.5;pointer-events:none}.account-info{align-items:center;gap:.75rem;display:flex}.btn-text{cursor:pointLine truncated
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
var t=Object.create,e=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,s=Object.prototype.hasOwnProperty,o=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports),a=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:"Module"}),r},l=(t,i,o,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(var l,c=r(i),u=0,h=c.length;u<h;u++)l=c[u],s.call(t,l)||l===o||e(t,l,{get:(t=>i[t]).bind(null,l),enumerable:!(a=n(i,l))||a.enumerable});return t},c=(n,r,o)=>(o=null!=n?t(i(n)):{},l(!r&&n&&n.__esModule&&s.call(n,"default")?o:e(o,"default",{value:n,enumerable:!0}),n)),u=(t=>"undefined"!=typeof require?require:"undefined"!=typeof Proxy?new Proxy(t,{get:(t,e)=>("undefined"!=typeof require?require:t)[e]}):t)(function(t){if("undefined"!=typeof require)return require.apply(this,arguments);throw Error('Calling `require` for "'+t+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();var h,d=Object.prototype,p=Symbol("undefined"),f=function(t){return t[t.ADD=0]="ADD",t[t.ONCE=1]="ONCE",t[t.REMOVE=2]="REMOVE",t}(f||{}),g=1,m=class{_descriptors;_mixins;_isInit;_lazyChains;_values;_revisions;source;proto;_parent;_listeners;_listenersVersion=1;_inheritedEnd=-1;_flattenedVersion=0;constructor(t){this._parent=void 0,this._descriptors=void 0,this._mixins=void 0,this._lazyChains=void 0,this._values=void 0,this._revisions=void 0,this._isInit=!1,this.source=t,this.proto=void 0===t.constructor?void 0:t.constructor.prototype,this._listeners=void 0}get parent(){let t=this._parent;if(void 0===t){let e=y(this.source);this._parent=t=null===e||e===d?null:_(e)}return t}setInitializing(){this._isInit=!0}unsetInitializing(){this._isInit=!1}isInitializing(){return this._isInit}isPrototypeMeta(t){return this.proto===this.source&&this.source===t}_getOrCreateOwnMap(t){return this[t]||(this[t]=Object.create(null))}_getOrCreateOwnSet(t){return this[t]||(this[t]=new Set)}_findInheritedMap(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r){let t=r.get(e);if(void 0!==t)return t}n=n.parent}}_hasInInheritedSet(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r&&r.has(e))return!0;n=n.parent}return!1}valueFor(t){let e=this._values;return void 0!==e?e[t]:void 0}setValueFor(t,e){this._getOrCreateOwnMap("_values")[t]=e}revisionFor(t){let e=this._revisions;return void 0!==e?e[t]:void 0}setRevisionFor(t,e){this._getOrCreateOwnMap("_revisions")[t]=e}writableLazyChainsFor(t){let e=this._getOrCreateOwnMap("_lazyChains"),n=e[t];return void 0===n&&(n=e[t]=[]),n}readableLazyChainsFor(t){let e=this._lazyChains;if(void 0!==e)return e[t]}addMixin(t){this._getOrCreateOwnSet("_mixins").add(t)}hasMixin(t){return this._hasInInheritedSet("_mixins",t)}forEachMixins(t){let e,n=this;for(;null!==n;){let r=n._mixins;void 0!==r&&(e=void 0===e?new Set:e,r.forEach(n=>{e.has(n)||(e.add(n),t(n))})),n=n.parent}}writeDescriptors(t,e){(this._descriptors||(this._descriptors=new Map)).set(t,e)}peekDescriptors(t){let e=this._findInheritedMap("_descriptors",t);return e===p?void 0:e}removeDescriptors(t){this.writeDescriptors(t,p)}forEachDescriptors(t){let e,n=this;for(;null!==n;){let r=n._descriptors;void 0!==r&&(e=void 0===e?new Set:e,r.forEach((n,r)=>{e.has(r)||(e.add(r),n!==p&&t(r,n))})),n=n.parent}}addToListeners(t,e,n,r,i){this.pushListener(t,e,n,r?f.ONCE:f.ADD,i)}removeFromListeners(t,e,n){this.pushListener(t,e,n,f.REMOVE)}pushListener(t,e,n,r,i=!1){let s=this.writableListeners(),o=x(s,t,e,n);if(-1!==o&&o<this._inheritedEnd&&(s.splice(o,1),this._inheritedEnd--,o=-1),-1===o)s.push({event:t,target:e,method:n,kind:r,sync:i});else{let t=s[o];r===f.REMOVE&&t.kind!==f.REMOVE?s.splice(o,1):(t.kind=r,t.sync=i)}}writableListeners(){return this._flattenedVersion!==g||this.source!==this.proto&&-1!==this._inheritedEnd||g++,-1===this._inheritedEnd&&(this._inheritedEnd=0,this._listeners=[]),this._listeners}flattenedListeners(){if(this._flattenedVersion<g){let t=this.parent;if(null!==t){let e=t.flattenedListeners();if(void 0!==e)if(void 0===this._listeners)this._listeners=e;else{let t=this._listeners;this._inheritedEnd>0&&(t.splice(0,this._inheritedEnd),this._inheritedEnd=0);for(Line truncated
|
|
||||||
@@ -1 +1 @@
|
|||||||
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-Dw0KrEfS.js";var Ka=Aa({default:()=>Ga}),Qa=Aa({default:()=>tn}),Xa={willChange:"_arrangedContentArrayWillChange",didChange:"_arrangedContentArrayDidChange"};function en(e,t){return"[]"===t?(e._revalidate(),e._arrTag):"length"===t?(e._revalidate(),e._lengthTag):B(e,t)}var tn=class extends Y{_objectsDirtyIndex=0;_objects=null;_lengthDirty=!0;_length=0;_arrangedContent=null;_arrangedContentIsUpdating=!1;_arrangedContentTag=null;_arrangedContentRevision=null;_lengthTag=null;_arrTag=null;init(e){super.init(e),W(this,en)}[b](){this._revalidate()}willDestroy(){this._removeArrangedContentArrayObserver()}objectAtContent(e){let t=be(this,"arrangedContent");return le(t,e)}replace(e,t,a){this.replaceContent(e,t,a)}replaceContent(e,t,a){let n=be(this,"content");$a(n,e,t,a)}objectAt(e){if(this._revalidate(),null===this._objects&&(this._objects=[]),-1!==this._objectsDirtyIndex&&e>=this._objectsDirtyIndex){let e=be(this,"arrangedContent");if(e){let t=this._objects.length=be(e,"length");for(let e=this._objectsDirtyIndex;e<t;e++)this._objects[e]=this.objectAtContent(e)}else this._objects.length=0;this._objectsDirtyIndex=-1}return this._objects[e]}get length(){if(this._revalidate(),this._lengthDirty){let e=be(this,"arrangedContent");this._length=e?be(e,"length"):0,this._lengthDirty=!1}return L(this._lengthTag),this._length}set length(e){let t,a=this.length-e;if(0===a)return;a<0&&(t=new Array(-a),a=0);let n=be(this,"content");n&&($a(n,e,a,t),this._invalidate())}_updateArrangedContentArray(e){let t=null===this._objects?0:this._objects.length,a=e?be(e,"length"):0;this._removeArrangedContentArrayObserver(),Je(this,0,t,a),this._invalidate(),Dt(this,0,t,a,!1),this._addArrangedContentArrayObserver(e)}_addArrangedContentArrayObserver(e){e&&!e.isDestroyed&&(Et(e,this,Xa),this._arrangedContent=e)}_removeArrangedContentArrayObserver(){this._arrangedContent&&Va(this._arrangedContent,this,Xa)}_arrangedContentArrayWillChange(){}_arrangedContentArrayDidChange(e,t,a,n){Je(this,t,a,n);let s=t;s<0&&(s+=be(this._arrangedContent,"length")+a-n),(-1===this._objectsDirtyIndex||this._objectsDirtyIndex>s)&&(this._objectsDirtyIndex=s),this._lengthDirty=!0,Dt(this,t,a,n,!1)}_invalidate(){this._objectsDirtyIndex=0,this._lengthDirty=!0}_revalidate(){if(!0!==this._arrangedContentIsUpdating&&(null===this._arrangedContentTag||!Ye(this._arrangedContentTag,this._arrangedContentRevision))){let e=this.get("arrangedContent");null===this._arrangedContentTag?this._addArrangedContentArrayObserver(e):(this._arrangedContentIsUpdating=!0,this._updateArrangedContentArray(e),this._arrangedContentIsUpdating=!1);let t=this._arrangedContentTag=B(this,"arrangedContent");this._arrangedContentRevision=Ge(this._arrangedContentTag),O(e)?(this._lengthTag=ua([t,I(e,"length")]),this._arrTag=ua([t,I(e,"[]")])):this._lengthTag=this._arrTag=t}}};tn.reopen(Ga,{arrangedContent:st("content")});var an=Aa({Meta:()=>va,UNDEFINED:()=>Qt,counters:()=>ma,meta:()=>jt,peekMeta:()=>Xe,setMeta:()=>na}),nn=Aa({ActionHandler:()=>Fe,Comparable:()=>C,ContainerProxyMixin:()=>Ua,MutableEnumerable:()=>_,RSVP:()=>wt,RegistryProxyMixin:()=>nt,TargetActionSupport:()=>Pa,_ProxyMixin:()=>Sa,_contentFor:()=>taLine truncated
|
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-BlRXtNAu.js";var Ka=Aa({default:()=>Ga}),Qa=Aa({default:()=>tn}),Xa={willChange:"_arrangedContentArrayWillChange",didChange:"_arrangedContentArrayDidChange"};function en(e,t){return"[]"===t?(e._revalidate(),e._arrTag):"length"===t?(e._revalidate(),e._lengthTag):B(e,t)}var tn=class extends Y{_objectsDirtyIndex=0;_objects=null;_lengthDirty=!0;_length=0;_arrangedContent=null;_arrangedContentIsUpdating=!1;_arrangedContentTag=null;_arrangedContentRevision=null;_lengthTag=null;_arrTag=null;init(e){super.init(e),W(this,en)}[b](){this._revalidate()}willDestroy(){this._removeArrangedContentArrayObserver()}objectAtContent(e){let t=be(this,"arrangedContent");return le(t,e)}replace(e,t,a){this.replaceContent(e,t,a)}replaceContent(e,t,a){let n=be(this,"content");$a(n,e,t,a)}objectAt(e){if(this._revalidate(),null===this._objects&&(this._objects=[]),-1!==this._objectsDirtyIndex&&e>=this._objectsDirtyIndex){let e=be(this,"arrangedContent");if(e){let t=this._objects.length=be(e,"length");for(let e=this._objectsDirtyIndex;e<t;e++)this._objects[e]=this.objectAtContent(e)}else this._objects.length=0;this._objectsDirtyIndex=-1}return this._objects[e]}get length(){if(this._revalidate(),this._lengthDirty){let e=be(this,"arrangedContent");this._length=e?be(e,"length"):0,this._lengthDirty=!1}return L(this._lengthTag),this._length}set length(e){let t,a=this.length-e;if(0===a)return;a<0&&(t=new Array(-a),a=0);let n=be(this,"content");n&&($a(n,e,a,t),this._invalidate())}_updateArrangedContentArray(e){let t=null===this._objects?0:this._objects.length,a=e?be(e,"length"):0;this._removeArrangedContentArrayObserver(),Je(this,0,t,a),this._invalidate(),Dt(this,0,t,a,!1),this._addArrangedContentArrayObserver(e)}_addArrangedContentArrayObserver(e){e&&!e.isDestroyed&&(Et(e,this,Xa),this._arrangedContent=e)}_removeArrangedContentArrayObserver(){this._arrangedContent&&Va(this._arrangedContent,this,Xa)}_arrangedContentArrayWillChange(){}_arrangedContentArrayDidChange(e,t,a,n){Je(this,t,a,n);let s=t;s<0&&(s+=be(this._arrangedContent,"length")+a-n),(-1===this._objectsDirtyIndex||this._objectsDirtyIndex>s)&&(this._objectsDirtyIndex=s),this._lengthDirty=!0,Dt(this,t,a,n,!1)}_invalidate(){this._objectsDirtyIndex=0,this._lengthDirty=!0}_revalidate(){if(!0!==this._arrangedContentIsUpdating&&(null===this._arrangedContentTag||!Ye(this._arrangedContentTag,this._arrangedContentRevision))){let e=this.get("arrangedContent");null===this._arrangedContentTag?this._addArrangedContentArrayObserver(e):(this._arrangedContentIsUpdating=!0,this._updateArrangedContentArray(e),this._arrangedContentIsUpdating=!1);let t=this._arrangedContentTag=B(this,"arrangedContent");this._arrangedContentRevision=Ge(this._arrangedContentTag),O(e)?(this._lengthTag=ua([t,I(e,"length")]),this._arrTag=ua([t,I(e,"[]")])):this._lengthTag=this._arrTag=t}}};tn.reopen(Ga,{arrangedContent:st("content")});var an=Aa({Meta:()=>va,UNDEFINED:()=>Qt,counters:()=>ma,meta:()=>jt,peekMeta:()=>Xe,setMeta:()=>na}),nn=Aa({ActionHandler:()=>Fe,Comparable:()=>C,ContainerProxyMixin:()=>Ua,MutableEnumerable:()=>_,RSVP:()=>wt,RegistryProxyMixin:()=>nt,TargetActionSupport:()=>Pa,_ProxyMixin:()=>Sa,_contentFor:()=>taLine truncated
|
||||||
@@ -1 +1 @@
|
|||||||
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-Dw0KrEfS.js";function l(){for(var t=[],r=0;r<arguments.length;r++)t[r]=arguments[r];return 1===(t=n(t)).length?s(t[0]):new e(function(t){return function(e){for(var r=[],n=function(n){r.push(s(t[n]).subscribe(i(e,function(t){if(r){for(var i=0;i<r.length;i++)i!==n&&r[i].unsubscribe();r=null}e.next(t)})))},a=0;r&&!e.closed&&a<t.length;a++)n(a)}}(t))}var d=32,c=class t{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(e){if(e instanceof t&&(e=e.unwrap()),"number"!=typeof e.length)throw Error("bad length");const i=e.length+this.length;if(this.capacity<i){const t=this._raw,e=Math.max(2*this.capacity,i);this._raw=new Uint8Array(e),this._raw.set(t)}this._raw.set(e,this.length),this.length+=e.length}shift(){const t=this._raw[0];return this._raw=this._raw.subarray(1),this.length--,t}shiftN(t=1){const e=this._raw.subarray(0,t);return this._raw=this._raw.subarray(t),this.length-=t,e}};function f(t){let e=0;for(;;){if(0===t.length)throw Error("parse ends prematurely");let i=t.shift();if(e=e<<7|127&i,!(128&i))break}return e}function m(t){if(0===t)return new c([0]);let e=[];for(;0!==t;)e.push(127&t),t>>>=7;e.reverse();for(let i=0;i<e.length-1;i++)e[i]|=128;return new c(e)}function g(t,e){if(t.length<e)throw Error("parse ends prematurely");return t.shiftN(e)}var p=class{buf;sha256;constructor(){this.buf=new Uint8Array(d),this.sha256=async t=>u.create().update(t).digest()}setToZero(){this.buf=new Uint8Array(d)}add(t){let e=0,i=0,r=new DataView(this.buf.buffer),n=new DataView(t.buffer);for(let s=0;s<8;s++){let t=4*s,a=r.getUint32(t,!0);a+=e,a+=n.getUint32(t,!0),a>4294967295&&(i=1),r.setUint32(t,4294967295&a,!0),e=i,i=0}}negate(){let t=new DataView(this.buf.buffer);for(let i=0;i<8;i++){let e=4*i;t.setUint32(e,~t.getUint32(e,!0))}let e=new Uint8Array(d);e[0]=1,this.add(e)}async getFingerprint(t){let e=new c;return e.extend(this.buf),e.extend(m(t)),(await this.sha256(e.unwrap())).subarray(0,16)}},w=class{items;sealed;constructor(){this.items=[],this.sealed=!1}insert(t,e){if(this.sealed)throw Error("already sealed");if((e=y(e)).byteLength!==d)throw Error("bad id size for added item");this.items.push({timestamp:t,id:e})}seal(){if(this.sealed)throw Error("already sealed");this.sealed=!0,this.items.sort(A);for(let t=1;t<this.items.length;t++)if(0===A(this.items[t-1],this.items[t]))throw Error("duplicate item inserted")}unseal(){this.sealed=!1}size(){return this._checkSealed(),this.items.length}getItem(t){if(this._checkSealed(),t>=this.items.length)throw Error("out of range");return this.items[t]}iterate(t,e,i){this._checkSealed(),this._checkBounds(t,e);for(let r=t;r<e&&i(this.items[r],r);++r);}findLowerBound(t,e,i){return this._checkSealed(),this._checkBounds(t,e),this._binarySearch(this.items,t,e,t=>A(t,i)<0)}async fingerprint(t,e){let i=new p;return i.setToZero(),this.iterate(t,e,t=>(i.add(t.id),!0)),await i.getFingerprint(e-t)}_checkSealed(){if(!this.sealed)throw Error("not sealed")}_checkBounds(t,e){if(t>e||e>this.items.length)throw Error("bad range")}_binarySearch(t,e,i,r){let n=i-e;for(;n>0;){let i=e,s=Math.floor(n/2);i+=s,r(t[i])?(e=++i,n-=s+1):n=s}return e}},b=class{storage;frameSizeLimit;lastTimestampIn;lastTimestampOut;isInitiator;wantUint8ArrayOutput;constructor(t,e=0){if(0!==e&&e<4096)throw Error("frameSizeLimit too small");this.storage=t,this.frameSizeLimit=e,this.lastTimestampIn=0,this.lastTimestampOut=0}_bound(t,e){return{timestamp:t,id:e||new Uint8Array(0)}}async initiate(){if(this.isInitiator)throw Error("already initiated");this.isInitiator=!0;let t=new c;return t.extend([97]),await this.splitRange(0,this.storage.size(),this._bound(Number.MAX_VALUE),t),this._renderOutput(t)}setInitiator(){this.isInitiator=!0}async reconcile(t){let e=[],i=[],r=new c(y(t));this.lastTimestampIn=this.lastTimestampOut=0;let n=new c;n.extend([97]);let s=g(r,1)[0];if(s<96||s>111)throw Error("invalid negentropy protocol version byte");if(97!==s){if(this.isInitiator)throw Error("unsupported negentropy protocol version requested: "+(s-96));return[this._renderOutput(n),e,i]}let a=this.storage.size(),o=this._bound(0),h=0,u=!1;for(;0!==r.length;){let t=new c,s=()=>{u&&(u=!1,t.extend(this.encodeBound(o)),t.extend(m(0)))},l=this.decodeBound(r),p=f(r),w=h,b=this.storage.findLowerBound(h,a,l);if(0===p)u=!0;else if(1===p)0!==E(g(r,16),await this.storage.fingerprint(w,b))?(s(),await this.splitRange(w,b,l,t)):u=!0;else{if(2!==p)throw Error("unexpected mode");{let a=f(r),o={};for(let t=0;t<a;t++){let t=g(r,d);this.isInitiator&&(o[t]=t)}if(this.isInitiator){u=!0,this.storage.iterate(w,b,t=>{let i=t.id;return o[i]?delete o[i]:this.isInitiator&&e.push(this.wantUint8ArrayOutput?i:x(i)),!0});for(let t of Object.values(o))i.push(this.wantUint8ArrayOutput?t:x(t))}else{s();let e=new c,i=0,r=l;this.storage.iterate(w,b,(t,s)=>this.exceededFLine truncated
|
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-BlRXtNAu.js";function l(){for(var t=[],n=0;n<arguments.length;n++)t[n]=arguments[n];return 1===(t=o(t)).length?e(t[0]):new r(function(t){return function(r){for(var n=[],s=function(s){n.push(e(t[s]).subscribe(i(r,function(t){if(n){for(var e=0;e<n.length;e++)e!==s&&n[e].unsubscribe();n=null}r.next(t)})))},a=0;n&&!r.closed&&a<t.length;a++)s(a)}}(t))}var d=32,c=class t{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(e){if(e instanceof t&&(e=e.unwrap()),"number"!=typeof e.length)throw Error("bad length");const i=e.length+this.length;if(this.capacity<i){const t=this._raw,e=Math.max(2*this.capacity,i);this._raw=new Uint8Array(e),this._raw.set(t)}this._raw.set(e,this.length),this.length+=e.length}shift(){const t=this._raw[0];return this._raw=this._raw.subarray(1),this.length--,t}shiftN(t=1){const e=this._raw.subarray(0,t);return this._raw=this._raw.subarray(t),this.length-=t,e}};function f(t){let e=0;for(;;){if(0===t.length)throw Error("parse ends prematurely");let i=t.shift();if(e=e<<7|127&i,!(128&i))break}return e}function m(t){if(0===t)return new c([0]);let e=[];for(;0!==t;)e.push(127&t),t>>>=7;e.reverse();for(let i=0;i<e.length-1;i++)e[i]|=128;return new c(e)}function g(t,e){if(t.length<e)throw Error("parse ends prematurely");return t.shiftN(e)}var p=class{buf;sha256;constructor(){this.buf=new Uint8Array(d),this.sha256=async t=>u.create().update(t).digest()}setToZero(){this.buf=new Uint8Array(d)}add(t){let e=0,i=0,r=new DataView(this.buf.buffer),n=new DataView(t.buffer);for(let s=0;s<8;s++){let t=4*s,a=r.getUint32(t,!0);a+=e,a+=n.getUint32(t,!0),a>4294967295&&(i=1),r.setUint32(t,4294967295&a,!0),e=i,i=0}}negate(){let t=new DataView(this.buf.buffer);for(let i=0;i<8;i++){let e=4*i;t.setUint32(e,~t.getUint32(e,!0))}let e=new Uint8Array(d);e[0]=1,this.add(e)}async getFingerprint(t){let e=new c;return e.extend(this.buf),e.extend(m(t)),(await this.sha256(e.unwrap())).subarray(0,16)}},w=class{items;sealed;constructor(){this.items=[],this.sealed=!1}insert(t,e){if(this.sealed)throw Error("already sealed");if((e=y(e)).byteLength!==d)throw Error("bad id size for added item");this.items.push({timestamp:t,id:e})}seal(){if(this.sealed)throw Error("already sealed");this.sealed=!0,this.items.sort(A);for(let t=1;t<this.items.length;t++)if(0===A(this.items[t-1],this.items[t]))throw Error("duplicate item inserted")}unseal(){this.sealed=!1}size(){return this._checkSealed(),this.items.length}getItem(t){if(this._checkSealed(),t>=this.items.length)throw Error("out of range");return this.items[t]}iterate(t,e,i){this._checkSealed(),this._checkBounds(t,e);for(let r=t;r<e&&i(this.items[r],r);++r);}findLowerBound(t,e,i){return this._checkSealed(),this._checkBounds(t,e),this._binarySearch(this.items,t,e,t=>A(t,i)<0)}async fingerprint(t,e){let i=new p;return i.setToZero(),this.iterate(t,e,t=>(i.add(t.id),!0)),await i.getFingerprint(e-t)}_checkSealed(){if(!this.sealed)throw Error("not sealed")}_checkBounds(t,e){if(t>e||e>this.items.length)throw Error("bad range")}_binarySearch(t,e,i,r){let n=i-e;for(;n>0;){let i=e,s=Math.floor(n/2);i+=s,r(t[i])?(e=++i,n-=s+1):n=s}return e}},b=class{storage;frameSizeLimit;lastTimestampIn;lastTimestampOut;isInitiator;wantUint8ArrayOutput;constructor(t,e=0){if(0!==e&&e<4096)throw Error("frameSizeLimit too small");this.storage=t,this.frameSizeLimit=e,this.lastTimestampIn=0,this.lastTimestampOut=0}_bound(t,e){return{timestamp:t,id:e||new Uint8Array(0)}}async initiate(){if(this.isInitiator)throw Error("already initiated");this.isInitiator=!0;let t=new c;return t.extend([97]),await this.splitRange(0,this.storage.size(),this._bound(Number.MAX_VALUE),t),this._renderOutput(t)}setInitiator(){this.isInitiator=!0}async reconcile(t){let e=[],i=[],r=new c(y(t));this.lastTimestampIn=this.lastTimestampOut=0;let n=new c;n.extend([97]);let s=g(r,1)[0];if(s<96||s>111)throw Error("invalid negentropy protocol version byte");if(97!==s){if(this.isInitiator)throw Error("unsupported negentropy protocol version requested: "+(s-96));return[this._renderOutput(n),e,i]}let a=this.storage.size(),o=this._bound(0),h=0,u=!1;for(;0!==r.length;){let t=new c,s=()=>{u&&(u=!1,t.extend(this.encodeBound(o)),t.extend(m(0)))},l=this.decodeBound(r),p=f(r),w=h,b=this.storage.findLowerBound(h,a,l);if(0===p)u=!0;else if(1===p)0!==E(g(r,16),await this.storage.fingerprint(w,b))?(s(),await this.splitRange(w,b,l,t)):u=!0;else{if(2!==p)throw Error("unexpected mode");{let a=f(r),o={};for(let t=0;t<a;t++){let t=g(r,d);this.isInitiator&&(o[t]=t)}if(this.isInitiator){u=!0,this.storage.iterate(w,b,t=>{let i=t.id;return o[i]?delete o[i]:this.isInitiator&&e.push(this.wantUint8ArrayOutput?i:x(i)),!0});for(let t of Object.values(o))i.push(this.wantUint8ArrayOutput?t:x(t))}else{s();let e=new c,i=0,r=l;this.storage.iterate(w,b,(t,s)=>this.exceededFLine truncated
|
||||||
+5
-2
@@ -6,6 +6,9 @@
|
|||||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<!-- Fediverse account -->
|
||||||
|
<link rel="me" href="https://kosmos.social/@marco">
|
||||||
|
|
||||||
<!-- Open Graph -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:title" content="Marco">
|
<meta property="og:title" content="Marco">
|
||||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
<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-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-Dw0KrEfS.js"></script>
|
<script type="module" crossorigin src="/assets/main-BlRXtNAu.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-DWxZH-9E.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="modal-portal"></div>
|
<div id="modal-portal"></div>
|
||||||
|
|||||||
@@ -7,12 +7,6 @@
|
|||||||
"skillPath": "skills/ember-best-practices/SKILL.md",
|
"skillPath": "skills/ember-best-practices/SKILL.md",
|
||||||
"computedHash": "34e82668e2fa1e6b025ac8cf1015634484bf5d8fbc80e8aa642489d2df79498c"
|
"computedHash": "34e82668e2fa1e6b025ac8cf1015634484bf5d8fbc80e8aa642489d2df79498c"
|
||||||
},
|
},
|
||||||
"nak": {
|
|
||||||
"source": "soapbox-pub/nostr-skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "skills/nak/SKILL.md",
|
|
||||||
"computedHash": "710d3f3945ff421ed2b7f40ecd32c5e263bc029d43fe8f4fd1491a8013c7389a"
|
|
||||||
},
|
|
||||||
"nostr": {
|
"nostr": {
|
||||||
"source": "soapbox-pub/nostr-skills",
|
"source": "soapbox-pub/nostr-skills",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
@tracked sourceMode = 'home';
|
||||||
|
@tracked isLoadingMore = false;
|
||||||
|
@tracked isLoading = false;
|
||||||
|
|
||||||
|
async load() {}
|
||||||
|
|
||||||
|
async setSourceMode() {}
|
||||||
|
|
||||||
|
async retryUnresolvedProfiles() {}
|
||||||
|
|
||||||
|
loadMore() {}
|
||||||
|
|
||||||
|
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-item');
|
||||||
|
assert.dom('.activity-item').exists({ count: 1 });
|
||||||
|
assert.dom('.sender-name').hasText('Alice');
|
||||||
|
assert.dom('.action').includesText('zapped your photo');
|
||||||
|
assert.dom('.amount').includesText('21 ⚡');
|
||||||
|
assert.dom('.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-item');
|
||||||
|
|
||||||
|
await click('.activity-item');
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
currentURL().includes('/place/osm:node:123'),
|
||||||
|
'Transitions to place details'
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.returnToRoute,
|
||||||
|
{ name: 'activity' },
|
||||||
|
'returnToRoute is set to activity'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,8 +16,16 @@ class MockStorageService extends Service {
|
|||||||
id: 'place-123',
|
id: 'place-123',
|
||||||
title: 'Mountain Trail',
|
title: 'Mountain Trail',
|
||||||
geohash: 'u33dc0',
|
geohash: 'u33dc0',
|
||||||
|
createdAt: '2023-01-02T12:00:00.000Z',
|
||||||
osmTags: { name: 'Mountain Trail' },
|
osmTags: { name: 'Mountain Trail' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'place-456',
|
||||||
|
title: 'Beach View',
|
||||||
|
geohash: 'u33dc1',
|
||||||
|
createdAt: '2023-01-03T12:00:00.000Z',
|
||||||
|
osmTags: { name: 'Beach View' },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
lists = [
|
lists = [
|
||||||
{
|
{
|
||||||
@@ -74,27 +82,56 @@ module('Acceptance | collections navigation', function (hooks) {
|
|||||||
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
|
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
|
||||||
assert
|
assert
|
||||||
.dom('.app-menu')
|
.dom('.app-menu')
|
||||||
.includesText('Collections', 'Menu contains Collections link');
|
.includesText('Saved Places', 'Menu contains Saved Places link');
|
||||||
|
|
||||||
// 3. Transition to Collections Index (List of lists)
|
// 3. Transition to Saved Places index (list of lists)
|
||||||
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Collections"
|
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Saved Places"
|
||||||
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
|
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
|
||||||
assert
|
assert
|
||||||
.dom('.sidebar-header-text-centered')
|
.dom('.sidebar-header-text-centered')
|
||||||
.includesText('Collections', 'Header is centered and titled Collections');
|
.includesText(
|
||||||
|
'Saved Places',
|
||||||
|
'Header is centered and titled Saved Places'
|
||||||
|
);
|
||||||
assert
|
assert
|
||||||
.dom('.lists-index-item')
|
.dom('.lists-index-item')
|
||||||
.exists({ count: 2 }, 'Renders our 2 mocked list items');
|
.exists({ count: 3 }, 'Renders Saved plus our 2 mocked list items');
|
||||||
|
assert
|
||||||
|
.dom(document.querySelectorAll('.lists-index-item')[0])
|
||||||
|
.includesText('Saved', 'Saved appears as the first list item');
|
||||||
|
assert
|
||||||
|
.dom(document.querySelectorAll('.lists-index-item')[0])
|
||||||
|
.includesText('2 places', 'Saved shows the total saved places count');
|
||||||
|
|
||||||
// 4. Transition to a specific list (Want to go)
|
// 4. Transition to the synthetic Saved list
|
||||||
await click(document.querySelectorAll('.lists-index-item')[0]); // Click "Want to go"
|
await click(document.querySelectorAll('.lists-index-item')[0]);
|
||||||
|
assert.strictEqual(
|
||||||
|
currentURL(),
|
||||||
|
'/lists/saved',
|
||||||
|
'Transitions to /lists/saved'
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor('.places-list');
|
||||||
|
assert
|
||||||
|
.dom(document.querySelectorAll('.places-list .place-name')[0])
|
||||||
|
.hasText('Beach View', 'Saved Places shows newest saved place first');
|
||||||
|
assert
|
||||||
|
.dom(document.querySelectorAll('.places-list .place-name')[1])
|
||||||
|
.hasText('Mountain Trail', 'Saved Places includes all saved places');
|
||||||
|
|
||||||
|
// 5. Go back to the Saved Places index
|
||||||
|
await click('.sidebar-header .back-btn');
|
||||||
|
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
|
||||||
|
|
||||||
|
// 6. Transition to a specific real list (Want to go)
|
||||||
|
await click(document.querySelectorAll('.lists-index-item')[1]); // Click "Want to go"
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
currentURL(),
|
currentURL(),
|
||||||
'/lists/to-go',
|
'/lists/to-go',
|
||||||
'Transitions instantly to /lists/to-go'
|
'Transitions instantly to /lists/to-go'
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Verify background loading spinner shows up, then results populate
|
// 7. Verify background loading spinner shows up, then results populate
|
||||||
await waitFor('.places-list');
|
await waitFor('.places-list');
|
||||||
assert
|
assert
|
||||||
.dom('.places-list .place-name')
|
.dom('.places-list .place-name')
|
||||||
@@ -103,19 +140,37 @@ module('Acceptance | collections navigation', function (hooks) {
|
|||||||
.dom('.places-list .place-type')
|
.dom('.places-list .place-type')
|
||||||
.hasText('Saved place', 'Place type displays Saved place correctly');
|
.hasText('Saved place', 'Place type displays Saved place correctly');
|
||||||
|
|
||||||
// 6. Click back button in collection list header
|
// 8. Click back button in collection list header
|
||||||
await click('.sidebar-header .back-btn');
|
await click('.sidebar-header .back-btn');
|
||||||
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
|
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
|
||||||
|
|
||||||
// 7. Click back button in collections index header
|
// 9. Click back button in collections index header
|
||||||
await click('.sidebar-header .back-btn');
|
await click('.sidebar-header .back-btn');
|
||||||
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
|
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
|
||||||
|
|
||||||
// 8. Close sidebar
|
// 10. Close sidebar
|
||||||
await click('.sidebar-header .close-btn');
|
await click('.sidebar-header .close-btn');
|
||||||
assert.strictEqual(currentURL(), '/', 'Sidebar closed and returned home');
|
assert.strictEqual(currentURL(), '/', 'Sidebar closed and returned home');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clicking a place inside Saved Places sets returnToRoute and returns gracefully on back click', async function (assert) {
|
||||||
|
await visit('/lists/saved');
|
||||||
|
await waitFor('.places-list');
|
||||||
|
|
||||||
|
await click('.place-item');
|
||||||
|
assert.ok(
|
||||||
|
currentURL().includes('/place/place-456'),
|
||||||
|
'Transitions to the newest saved place details route'
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.back-btn');
|
||||||
|
assert.strictEqual(
|
||||||
|
currentURL(),
|
||||||
|
'/lists/saved',
|
||||||
|
'Returns gracefully back to the Saved Places view'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('clicking a place inside a collection sets returnToRoute and returns gracefully on back click', async function (assert) {
|
test('clicking a place inside a collection sets returnToRoute and returns gracefully on back click', async function (assert) {
|
||||||
await visit('/lists/to-go');
|
await visit('/lists/to-go');
|
||||||
await waitFor('.places-list');
|
await waitFor('.places-list');
|
||||||
@@ -136,7 +191,7 @@ module('Acceptance | collections navigation', function (hooks) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('places inside a collection are sorted by createdAt descending', async function (assert) {
|
test('places inside Saved Places are sorted by createdAt descending', async function (assert) {
|
||||||
class SortedMockStorageService extends Service {
|
class SortedMockStorageService extends Service {
|
||||||
initialSyncDone = true;
|
initialSyncDone = true;
|
||||||
savedPlaces = [
|
savedPlaces = [
|
||||||
@@ -203,7 +258,7 @@ module('Acceptance | collections navigation', function (hooks) {
|
|||||||
this.owner.unregister('service:storage');
|
this.owner.unregister('service:storage');
|
||||||
this.owner.register('service:storage', SortedMockStorageService);
|
this.owner.register('service:storage', SortedMockStorageService);
|
||||||
|
|
||||||
await visit('/lists/to-go');
|
await visit('/lists/saved');
|
||||||
await waitFor('.places-list');
|
await waitFor('.places-list');
|
||||||
|
|
||||||
const placeNames = Array.from(
|
const placeNames = Array.from(
|
||||||
@@ -213,7 +268,88 @@ module('Acceptance | collections navigation', function (hooks) {
|
|||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
placeNames,
|
placeNames,
|
||||||
['Newest Place', 'Middle Place', 'Oldest Place'],
|
['Newest Place', 'Middle Place', 'Oldest Place'],
|
||||||
'Places are ordered by createdAt in descending order'
|
'Saved Places are ordered by createdAt in descending order'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returning from place details preserves scroll position without a loading flash', async function (assert) {
|
||||||
|
const places = Array.from({ length: 40 }, (_, i) => ({
|
||||||
|
id: `place-${i}`,
|
||||||
|
title: `Place ${i}`,
|
||||||
|
geohash: 'u33dc0',
|
||||||
|
lat: 48.1,
|
||||||
|
lon: 11.5,
|
||||||
|
createdAt: new Date(2023, 0, 1, 12, 0, i).toISOString(),
|
||||||
|
osmTags: { name: `Place ${i}` },
|
||||||
|
}));
|
||||||
|
|
||||||
|
class ManyPlacesStorageService extends Service {
|
||||||
|
initialSyncDone = true;
|
||||||
|
savedPlaces = places;
|
||||||
|
lists = [
|
||||||
|
{
|
||||||
|
id: 'to-go',
|
||||||
|
title: 'Want to go',
|
||||||
|
color: '#2e9e4f',
|
||||||
|
placeRefs: places.map((p) => ({ id: p.id, geohash: p.geohash })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
findPlaceById(id) {
|
||||||
|
return this.savedPlaces.find((p) => p.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaceSaved() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlacesInList(listId) {
|
||||||
|
if (listId === 'to-go') {
|
||||||
|
return Promise.resolve(this.savedPlaces);
|
||||||
|
}
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
rs = {
|
||||||
|
on: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.unregister('service:storage');
|
||||||
|
this.owner.register('service:storage', ManyPlacesStorageService);
|
||||||
|
|
||||||
|
await visit('/lists/to-go');
|
||||||
|
await waitFor('.places-list');
|
||||||
|
|
||||||
|
const sidebarContent = document.querySelector('.sidebar-content');
|
||||||
|
sidebarContent.scrollTop = 300;
|
||||||
|
assert.strictEqual(sidebarContent.scrollTop, 300, 'List is scrolled');
|
||||||
|
|
||||||
|
// Open place details from an item that is currently visible so the browser
|
||||||
|
// does not scroll it back into view (which would reset our saved position),
|
||||||
|
// then return via the back button.
|
||||||
|
const visibleItem = document.querySelectorAll('.place-item')[5];
|
||||||
|
await click(visibleItem);
|
||||||
|
await waitFor('.sidebar-content');
|
||||||
|
await click('.back-btn');
|
||||||
|
await waitFor('.places-list');
|
||||||
|
|
||||||
|
// Allow the restore-scroll requestAnimationFrame to run
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
|
||||||
|
const restored = document.querySelector('.sidebar-content');
|
||||||
|
assert.strictEqual(
|
||||||
|
restored.scrollTop,
|
||||||
|
300,
|
||||||
|
'Scroll position is restored on return'
|
||||||
|
);
|
||||||
|
assert
|
||||||
|
.dom('.sidebar-loading')
|
||||||
|
.doesNotExist('No loading spinner flashes when returning');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -129,4 +129,47 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
backStub.restore();
|
backStub.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an empty search is not restored when navigating back from a place', async function (assert) {
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// Nearby search that yields no results, but with a fetchable place
|
||||||
|
this.owner.register(
|
||||||
|
'service:osm',
|
||||||
|
class extends Service {
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async fetchOsmObject(id, type) {
|
||||||
|
return {
|
||||||
|
osmId: id,
|
||||||
|
osmType: type,
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||||
|
title: 'Test Place',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await visit('/search?lat=1&lon=1');
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.currentSearch,
|
||||||
|
null,
|
||||||
|
'Empty search is not restorable'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate opening a place while the (empty) search route is active
|
||||||
|
mapUi.returnToSearch = true;
|
||||||
|
|
||||||
|
await visit('/place/osm:node:123');
|
||||||
|
await click('.back-btn');
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
currentURL(),
|
||||||
|
'/',
|
||||||
|
'Back navigation does not reopen the empty search'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -54,7 +54,19 @@ class MockOsmService extends Service {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
async getNearbyPois() {
|
async getNearbyPois() {
|
||||||
return [];
|
return new Promise((resolve) => {
|
||||||
|
osmResolve = () => {
|
||||||
|
resolve([
|
||||||
|
{
|
||||||
|
title: 'Nearby Place',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +80,7 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
this.owner.register('service:osm', MockOsmService);
|
this.owner.register('service:osm', MockOsmService);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search shows loading indicator but nearby search does not', async function (assert) {
|
test('search shows loading indicator for text, category and nearby searches', async function (assert) {
|
||||||
const mapUi = this.owner.lookup('service:map-ui');
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
// 1. Text Search
|
// 1. Text Search
|
||||||
@@ -121,11 +133,25 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 3. Nearby Search
|
// 3. Nearby Search
|
||||||
await visit('/search?lat=1&lon=1');
|
const nearbyPromise = visit('/search?category=nearby&lat=1&lon=1');
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
{ type: 'nearby', value: 'nearby' },
|
||||||
|
'Loading state is set for nearby search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve the manual promise
|
||||||
|
osmResolve();
|
||||||
|
|
||||||
|
await nearbyPromise;
|
||||||
|
await settled();
|
||||||
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
mapUi.loadingState,
|
mapUi.loadingState,
|
||||||
null,
|
null,
|
||||||
'Loading state is NOT set for nearby search'
|
'Loading state is cleared after nearby search'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { visit, currentURL } from '@ember/test-helpers';
|
import { visit, currentURL, click } from '@ember/test-helpers';
|
||||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
|
|
||||||
@@ -277,6 +277,9 @@ module('Acceptance | search', function (hooks) {
|
|||||||
async getCategoryPois() {
|
async getCategoryPois() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.owner.register('service:osm', MockOsmService);
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
|
||||||
@@ -336,10 +339,117 @@ module('Acceptance | search', function (hooks) {
|
|||||||
'Search input is populated with mapped category label'
|
'Search input is populated with mapped category label'
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Go back to index
|
// 3. Visit a nearby search URL
|
||||||
|
await visit('/search?category=nearby&lat=52.52&lon=13.405');
|
||||||
|
assert
|
||||||
|
.dom('.search-input')
|
||||||
|
.hasValue('Nearby', 'Search input is populated with the Nearby label');
|
||||||
|
|
||||||
|
// 4. Go back to index
|
||||||
await visit('/');
|
await visit('/');
|
||||||
assert
|
assert
|
||||||
.dom('.search-input')
|
.dom('.search-input')
|
||||||
.hasValue('', 'Search input is cleared on transitioning to index');
|
.hasValue('', 'Search input is cleared on transitioning to index');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('closing the drawer clears the search route when there are no results', async function (assert) {
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [];
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rs = { on: () => {} };
|
||||||
|
placesInView = [];
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
await visit('/search?q=nowhere');
|
||||||
|
|
||||||
|
assert.dom('.empty-state').hasText('No results found.');
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.currentSearch,
|
||||||
|
null,
|
||||||
|
'An empty search is not kept as a restorable search context'
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.close-btn');
|
||||||
|
|
||||||
|
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||||
|
assert.strictEqual(currentURL(), '/', 'Search route is cleared');
|
||||||
|
assert.strictEqual(mapUi.searchResults.length, 0, 'Results are cleared');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nearby search state is shown in the search box and can be dismissed', async function (assert) {
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
cancelAll() {}
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Nearby Cafe',
|
||||||
|
lat: 52.521,
|
||||||
|
lon: 13.406,
|
||||||
|
osmId: '789',
|
||||||
|
osmType: 'N',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [];
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rs = { on: () => {} };
|
||||||
|
placesInView = [];
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
await visit('/search?category=nearby&lat=52.52&lon=13.405');
|
||||||
|
|
||||||
|
assert.dom('.sidebar-header h2').includesText('Nearby');
|
||||||
|
assert.dom('.search-input').hasValue('Nearby', 'Input indicates Nearby');
|
||||||
|
|
||||||
|
// Closing the sidebar keeps the search (and its markers) active, so the
|
||||||
|
// search box must still indicate the Nearby search and offer dismissal.
|
||||||
|
await click('.close-btn');
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
currentURL().includes('category=nearby'),
|
||||||
|
'Still on the nearby search route with markers visible'
|
||||||
|
);
|
||||||
|
assert
|
||||||
|
.dom('.search-input')
|
||||||
|
.hasValue('Nearby', 'Input still indicates Nearby after closing sidebar');
|
||||||
|
assert.dom('.search-clear-btn').exists('Clear button remains available');
|
||||||
|
|
||||||
|
await click('.search-clear-btn');
|
||||||
|
|
||||||
|
assert.strictEqual(currentURL(), '/', 'Dismissing nearby clears the route');
|
||||||
|
assert.strictEqual(mapUi.searchResults.length, 0, 'Results are cleared');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -62,6 +62,16 @@ export class MockNostrDataService extends Service {
|
|||||||
return this.profiles[pubkey];
|
return this.profiles[pubkey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEventRelays() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
recordPublishResult() {}
|
||||||
|
|
||||||
|
refreshProfiles() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
get activeReadRelays() {
|
get activeReadRelays() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -105,14 +115,14 @@ export class MockNostrDataService extends Service {
|
|||||||
|
|
||||||
export class MockNostrRelayService extends Service {
|
export class MockNostrRelayService extends Service {
|
||||||
pool = {
|
pool = {
|
||||||
publish: () => Promise.resolve([{ ok: true }]),
|
publish: () => Promise.resolve([{ ok: true, from: 'wss://relay.test' }]),
|
||||||
subscribe: () => {},
|
subscribe: () => {},
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
close: () => {},
|
close: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
async publish() {
|
async publish() {
|
||||||
return [{ ok: true }];
|
return [{ ok: true, from: 'wss://relay.test' }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
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 = [];
|
||||||
|
this.sourceMode = 'home';
|
||||||
|
this.onSetSourceMode = () => {};
|
||||||
|
this.isLoadingMore = false;
|
||||||
|
this.onLoadMore = () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders a loading state', async function (assert) {
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<ActivityTimeline
|
||||||
|
@items={{this.emptyItems}}
|
||||||
|
@isLoading={{true}}
|
||||||
|
@isConnected={{true}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</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}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</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}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</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}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</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}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.activity-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}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.handleBack}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.sidebar-header .back-btn');
|
||||||
|
assert.true(backClicked);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders tab nav with Home and Explore tabs', async function (assert) {
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<ActivityTimeline
|
||||||
|
@items={{this.emptyItems}}
|
||||||
|
@isLoading={{false}}
|
||||||
|
@isConnected={{true}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.tab-nav').exists();
|
||||||
|
assert.dom('.tab-nav-button').exists({ count: 2 });
|
||||||
|
const buttons = this.element.querySelectorAll('.tab-nav-button');
|
||||||
|
assert.strictEqual(buttons[0].textContent.trim(), 'Home');
|
||||||
|
assert.strictEqual(buttons[1].textContent.trim(), 'Explore');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a tab fires @onSetSourceMode with the tab value', async function (assert) {
|
||||||
|
let selectedMode = null;
|
||||||
|
this.handleSetSourceMode = (mode) => {
|
||||||
|
selectedMode = mode;
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<ActivityTimeline
|
||||||
|
@items={{this.emptyItems}}
|
||||||
|
@isLoading={{false}}
|
||||||
|
@isConnected={{true}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.handleSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = this.element.querySelectorAll('.tab-nav-button');
|
||||||
|
await click(buttons[1]);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
selectedMode,
|
||||||
|
'explore',
|
||||||
|
'onSetSourceMode called with explore'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explore mode shows items when not connected', async function (assert) {
|
||||||
|
this.items = [
|
||||||
|
{
|
||||||
|
type: 'photo',
|
||||||
|
photoEventId: 'photo-1',
|
||||||
|
photo: {
|
||||||
|
url: 'https://x.com/photo.jpg',
|
||||||
|
thumbUrl: 'https://x.com/thumb.jpg',
|
||||||
|
},
|
||||||
|
placeIdentifier: 'osm:node:111',
|
||||||
|
authorPubkey: 'b'.repeat(64),
|
||||||
|
createdAt: 2000,
|
||||||
|
authorName: 'Alice',
|
||||||
|
authorAvatar: 'https://x.com/avatar.jpg',
|
||||||
|
authorProfileLoading: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
this.sourceMode = 'explore';
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<ActivityTimeline
|
||||||
|
@items={{this.items}}
|
||||||
|
@isLoading={{false}}
|
||||||
|
@isConnected={{false}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.activity-list').exists('items render in explore mode');
|
||||||
|
assert.dom('.activity-list .activity-item').exists({ count: 1 });
|
||||||
|
assert.dom('.empty-state').doesNotExist('no connect prompt in explore');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explore mode shows empty state when not connected and no items', async function (assert) {
|
||||||
|
this.sourceMode = 'explore';
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<ActivityTimeline
|
||||||
|
@items={{this.emptyItems}}
|
||||||
|
@isLoading={{false}}
|
||||||
|
@isConnected={{false}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.empty-state').includesText('No activity yet.');
|
||||||
|
assert.dom('.empty-state').doesNotIncludeText('Connect');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('load-more sentinel renders when items exist', 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<ActivityTimeline
|
||||||
|
@items={{this.items}}
|
||||||
|
@isLoading={{false}}
|
||||||
|
@isConnected={{true}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.activity-load-more').exists('sentinel renders');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('load-more spinner shows when isLoadingMore is true', 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
this.isLoadingMore = true;
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<ActivityTimeline
|
||||||
|
@items={{this.items}}
|
||||||
|
@isLoading={{false}}
|
||||||
|
@isConnected={{true}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
|
@onSelect={{this.noop}}
|
||||||
|
@onBack={{this.noop}}
|
||||||
|
@onClose={{this.noop}}
|
||||||
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.activity-load-more-spinner').exists('spinner shows');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
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('.sender-name').hasText('Alice');
|
||||||
|
assert.dom('.action').includesText('zapped your photo');
|
||||||
|
assert.dom('.amount').hasText('21 ⚡');
|
||||||
|
assert.dom('.message').includesText('Great shot!');
|
||||||
|
assert
|
||||||
|
.dom('.sender-avatar')
|
||||||
|
.hasAttribute('src', 'https://x.com/avatar.jpg');
|
||||||
|
assert
|
||||||
|
.dom('.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('.sender-avatar-placeholder').exists();
|
||||||
|
assert.dom('.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('.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('.context-thumb').doesNotExist();
|
||||||
|
assert.dom('.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-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('.context-text')
|
||||||
|
.includesText('Café Central', 'Resolved place name is displayed');
|
||||||
|
assert
|
||||||
|
.dom('.contribution-name-loading')
|
||||||
|
.doesNotExist('No loading/fallback text shown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it shows no text 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('.context-text .activity-place-name')
|
||||||
|
.hasText('', 'No text shown while loading');
|
||||||
|
assert
|
||||||
|
.dom('.context-text .place-name-text')
|
||||||
|
.doesNotExist('No place-name-text shown while loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('.context-text .place-name-text')
|
||||||
|
.hasText('Unnamed place', 'Fallback name is displayed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it displays place name in activity-place-name and date in activity-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('.activity-place-name');
|
||||||
|
const dateEl = this.element.querySelector('.activity-date');
|
||||||
|
|
||||||
|
assert.ok(placeNameEl, 'activity-place-name element exists');
|
||||||
|
assert.ok(dateEl, 'activity-date element exists');
|
||||||
|
assert.ok(
|
||||||
|
placeNameEl.textContent.includes('Café Central'),
|
||||||
|
'Place name is in activity-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('.activity-place-name')
|
||||||
|
.exists('activity-place-name element exists');
|
||||||
|
assert.dom('.activity-date').hasText('1 hr ago', 'Date is still visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
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 Service, { service } from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
||||||
import {
|
import {
|
||||||
excludeRequiredRelays,
|
excludeRequiredRelays,
|
||||||
@@ -59,9 +60,18 @@ class MockNostrDataService extends Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get trustedRelays() {
|
||||||
|
const configured = this.settings.nostrTrustedRelays || [];
|
||||||
|
return uniqNormalizedRelays([...this.requiredReadRelays, ...configured]);
|
||||||
|
}
|
||||||
|
|
||||||
async clearCache() {}
|
async clearCache() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockNostrAuthService extends Service {
|
||||||
|
@tracked isConnected = true;
|
||||||
|
}
|
||||||
|
|
||||||
function readRows(element) {
|
function readRows(element) {
|
||||||
const list = element.querySelectorAll('.relay-list')[0];
|
const list = element.querySelectorAll('.relay-list')[0];
|
||||||
return [...list.querySelectorAll('li')];
|
return [...list.querySelectorAll('li')];
|
||||||
@@ -83,6 +93,7 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
|||||||
localStorage.removeItem('marco:settings');
|
localStorage.removeItem('marco:settings');
|
||||||
|
|
||||||
this.owner.register('service:nostrData', MockNostrDataService);
|
this.owner.register('service:nostrData', MockNostrDataService);
|
||||||
|
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||||
this.settings = this.owner.lookup('service:settings');
|
this.settings = this.owner.lookup('service:settings');
|
||||||
this.onChange = () => {};
|
this.onChange = () => {};
|
||||||
});
|
});
|
||||||
@@ -108,8 +119,17 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
|||||||
const requiredRow = rowByText(rows, 'nostr.kosmos.org');
|
const requiredRow = rowByText(rows, 'nostr.kosmos.org');
|
||||||
const mailboxRow = rowByText(rows, 'mailbox.example.com');
|
const mailboxRow = rowByText(rows, 'mailbox.example.com');
|
||||||
|
|
||||||
assert.dom(requiredRow.querySelector('.btn-remove-relay')).doesNotExist();
|
const requiredRemoveBtn = requiredRow.querySelector('.btn-remove-relay');
|
||||||
assert.dom(mailboxRow.querySelector('.btn-remove-relay')).exists();
|
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) {
|
test('removing mailbox read relay stores exclusion override', async function (assert) {
|
||||||
@@ -217,4 +237,107 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
|||||||
.dom('#nostr-photo-fallback-uploads')
|
.dom('#nostr-photo-fallback-uploads')
|
||||||
.exists('fallback toggle visible with multiple media servers');
|
.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('publish-only settings are hidden when no nostr key is connected', async function (assert) {
|
||||||
|
this.owner.lookup('service:nostrAuth').isConnected = false;
|
||||||
|
|
||||||
|
const element = await renderAndOpenDetails(this);
|
||||||
|
|
||||||
|
assert.dom('#new-write-relay').doesNotExist('write relay input hidden');
|
||||||
|
assert
|
||||||
|
.dom('#nostr-media-server')
|
||||||
|
.doesNotExist('media server select hidden');
|
||||||
|
assert
|
||||||
|
.dom('#nostr-photo-fallback-uploads')
|
||||||
|
.doesNotExist('fallback uploads select hidden');
|
||||||
|
assert.strictEqual(
|
||||||
|
element.querySelectorAll('.relay-list').length,
|
||||||
|
1,
|
||||||
|
'only the read relay list is rendered'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read relays and cached data stay visible when no nostr key is connected', async function (assert) {
|
||||||
|
this.owner.lookup('service:nostrAuth').isConnected = false;
|
||||||
|
|
||||||
|
const element = await renderAndOpenDetails(this);
|
||||||
|
|
||||||
|
assert.dom('#new-read-relay').exists('read relay input visible');
|
||||||
|
assert.dom(readRows(element)[0]).exists('read relay list visible');
|
||||||
|
assert
|
||||||
|
.dom(element.querySelector('.btn-outline'))
|
||||||
|
.includesText('Clear profiles, photos, and reviews');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -22,6 +22,9 @@ module('Integration | Component | category-chips', function (hooks) {
|
|||||||
// Check for some expected labels
|
// Check for some expected labels
|
||||||
assert.dom(this.element).includesText('Restaurants');
|
assert.dom(this.element).includesText('Restaurants');
|
||||||
assert.dom(this.element).includesText('Coffee');
|
assert.dom(this.element).includesText('Coffee');
|
||||||
|
|
||||||
|
// Nearby is an internal-only category and must not appear as a chip
|
||||||
|
assert.dom(this.element).doesNotIncludeText('Nearby');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking a chip triggers the @onSelect action', async function (assert) {
|
test('clicking a chip triggers the @onSelect action', async function (assert) {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render } from '@ember/test-helpers';
|
||||||
|
import Icon from 'marco/components/icon';
|
||||||
|
|
||||||
|
module('Integration | Component | icon', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it renders default 16px square dimensions', async function (assert) {
|
||||||
|
await render(<template><Icon @name="zap" /></template>);
|
||||||
|
|
||||||
|
assert.dom('.icon').exists();
|
||||||
|
assert.dom('.icon').hasAttribute('style', /width:16px;height:16px;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders custom square size with @size', async function (assert) {
|
||||||
|
await render(<template><Icon @name="zap" @size={{24}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('.icon').hasAttribute('style', /width:24px;height:24px;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it supports custom @width and @height', async function (assert) {
|
||||||
|
await render(
|
||||||
|
<template><Icon @name="bitcoin" @width={{17}} @height={{22}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.icon').hasAttribute('style', /width:17px;height:22px;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it allows @width to override while @height falls back to @size', async function (assert) {
|
||||||
|
await render(
|
||||||
|
<template><Icon @name="bitcoin" @width={{17}} @size={{22}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.icon').hasAttribute('style', /width:17px;height:22px;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it allows @height to override while @width falls back to @size', async function (assert) {
|
||||||
|
await render(
|
||||||
|
<template><Icon @name="bitcoin" @height={{22}} @size={{18}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.icon').hasAttribute('style', /width:18px;height:22px;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it supports explicit string units for width and height', async function (assert) {
|
||||||
|
await render(
|
||||||
|
<template><Icon @name="zap" @width="100%" @height="2rem" /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.icon').hasAttribute('style', /width:100%;height:2rem;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it splats HTML attributes to the icon element', async function (assert) {
|
||||||
|
await render(
|
||||||
|
<template><Icon @name="zap" data-test-custom-icon="true" /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('[data-test-custom-icon="true"]').exists();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
|
|||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
|
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
|
import { decodePointer } from 'applesauce-core/helpers/pointers';
|
||||||
import PhotoGallery from 'marco/components/photo-gallery';
|
import PhotoGallery from 'marco/components/photo-gallery';
|
||||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
@@ -133,6 +134,9 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
|
|
||||||
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
|
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
|
||||||
assert.ok(blossomSpy.notCalled, 'blossom.delete was NOT called');
|
assert.ok(blossomSpy.notCalled, 'blossom.delete was NOT called');
|
||||||
|
assert
|
||||||
|
.dom('.dropdown-popover')
|
||||||
|
.doesNotExist('dropdown menu was closed on cancel');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it performs full deletion flow when confirmed', async function (assert) {
|
test('it performs full deletion flow when confirmed', async function (assert) {
|
||||||
@@ -160,8 +164,11 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
|
|
||||||
const confirmStub = sinon.stub(window, 'confirm').returns(true);
|
const confirmStub = sinon.stub(window, 'confirm').returns(true);
|
||||||
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
|
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
|
||||||
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
|
const publishStub = sinon
|
||||||
|
.stub(this.nostrRelay, 'publish')
|
||||||
|
.resolves([{ ok: true, from: 'wss://relay.test' }]);
|
||||||
const storeStub = sinon.stub(this.nostrData.store, 'add');
|
const storeStub = sinon.stub(this.nostrData.store, 'add');
|
||||||
|
const recordPublishSpy = sinon.spy(this.nostrData, 'recordPublishResult');
|
||||||
const toastSpy = sinon.spy(this.toast, 'show');
|
const toastSpy = sinon.spy(this.toast, 'show');
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
@@ -221,18 +228,100 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
'added kind 5 event to local store'
|
'added kind 5 event to local store'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check publish result recording
|
||||||
|
assert.ok(
|
||||||
|
recordPublishSpy.calledOnce,
|
||||||
|
'nostrData.recordPublishResult was called'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
recordPublishSpy.firstCall.args[0],
|
||||||
|
publishedEvent.id,
|
||||||
|
'publish result recorded for the deletion event'
|
||||||
|
);
|
||||||
|
|
||||||
// Check UX
|
// Check UX
|
||||||
assert.ok(
|
assert.ok(
|
||||||
toastSpy.calledWith('Photo deleted successfully'),
|
toastSpy.calledWith('Photo deleted successfully'),
|
||||||
'success toast was shown'
|
'success toast was shown'
|
||||||
);
|
);
|
||||||
assert.ok(closed, 'gallery was closed after deletion');
|
assert
|
||||||
|
.dom('.dropdown-popover')
|
||||||
|
.doesNotExist('dropdown menu was closed after deletion');
|
||||||
|
assert.notOk(closed, 'gallery was kept open after deletion');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it closes the gallery when deleting the last remaining photo', async function (assert) {
|
||||||
|
this.nostrAuth.pubkey = USER_A;
|
||||||
|
this.settings.update('experimentalEnablePhotoDeletion', true);
|
||||||
|
Object.defineProperty(this.nostrAuth, 'signer', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => ({
|
||||||
|
signEvent: async (e) => ({
|
||||||
|
...e,
|
||||||
|
id: 'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||||
|
sig: 'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
|
||||||
|
pubkey: USER_A,
|
||||||
|
}),
|
||||||
|
getPublicKey: async () => USER_A,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.set('photos', [this.photos[0]]);
|
||||||
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
this.handleClose = () => {
|
||||||
|
closed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
sinon.stub(window, 'confirm').returns(true);
|
||||||
|
sinon.stub(this.blossom, 'delete').resolves();
|
||||||
|
sinon
|
||||||
|
.stub(this.nostrRelay, 'publish')
|
||||||
|
.resolves([{ ok: true, from: 'wss://relay.test' }]);
|
||||||
|
sinon.stub(this.nostrData.store, 'add');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<div id="test-container">
|
||||||
|
<div id="modal-portal"></div>
|
||||||
|
<PhotoGallery
|
||||||
|
@photos={{this.photos}}
|
||||||
|
@selectedPhoto={{this.selectedPhoto}}
|
||||||
|
@onClose={{this.handleClose}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
await click('.dropdown-trigger-btn');
|
||||||
|
await click('.dropdown-item.text-danger');
|
||||||
|
|
||||||
|
assert.ok(closed, 'gallery was closed when last photo was deleted');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it copies event id to clipboard', async function (assert) {
|
test('it copies event id to clipboard', async function (assert) {
|
||||||
|
const eventId = '1'.repeat(64);
|
||||||
this.nostrAuth.pubkey = USER_A;
|
this.nostrAuth.pubkey = USER_A;
|
||||||
|
this.photos = [
|
||||||
|
{
|
||||||
|
eventId,
|
||||||
|
pubkey: USER_A,
|
||||||
|
placeIdentifier: 'osm:node:12345',
|
||||||
|
url: 'photo.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
this.selectedPhoto = this.photos[0];
|
this.selectedPhoto = this.photos[0];
|
||||||
|
|
||||||
|
sinon
|
||||||
|
.stub(this.nostrData, 'getEventRelays')
|
||||||
|
.returns([
|
||||||
|
'wss://a.test',
|
||||||
|
'wss://b.test',
|
||||||
|
'wss://c.test',
|
||||||
|
'wss://d.test',
|
||||||
|
'wss://e.test',
|
||||||
|
]);
|
||||||
const clipboardStub = sinon
|
const clipboardStub = sinon
|
||||||
.stub(navigator.clipboard, 'writeText')
|
.stub(navigator.clipboard, 'writeText')
|
||||||
.resolves();
|
.resolves();
|
||||||
@@ -263,7 +352,23 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
|
|
||||||
await click(copyBtn);
|
await click(copyBtn);
|
||||||
|
|
||||||
assert.ok(clipboardStub.calledWith('event1'), 'copied correct event id');
|
const copied = clipboardStub.firstCall.args[0];
|
||||||
|
assert.ok(
|
||||||
|
copied.startsWith('nevent1'),
|
||||||
|
'copied value is an nevent identifier'
|
||||||
|
);
|
||||||
|
const decoded = decodePointer(copied);
|
||||||
|
assert.strictEqual(decoded.type, 'nevent', 'decoded value is an nevent');
|
||||||
|
assert.strictEqual(
|
||||||
|
decoded.data.id,
|
||||||
|
eventId,
|
||||||
|
'decoded nevent references the correct event id'
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
decoded.data.relays,
|
||||||
|
['wss://a.test', 'wss://b.test', 'wss://c.test'],
|
||||||
|
'decoded nevent includes at most 3 relay hints'
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
toastSpy.calledWith('Event ID copied to clipboard'),
|
toastSpy.calledWith('Event ID copied to clipboard'),
|
||||||
'success toast was shown'
|
'success toast was shown'
|
||||||
|
|||||||
@@ -411,4 +411,158 @@ module('Integration | Component | place-details', function (hooks) {
|
|||||||
|
|
||||||
assert.dom('button.btn-link').doesNotExist();
|
assert.dom('button.btn-link').doesNotExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it renders payment methods when payment tags are present on place', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Coffee Place',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.4,
|
||||||
|
osmTags: {
|
||||||
|
'payment:cash': 'yes',
|
||||||
|
'payment:cards': 'no',
|
||||||
|
'currency:XBT': 'yes',
|
||||||
|
'payment:lightning': 'yes',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('.place-payment-methods').exists();
|
||||||
|
assert.dom('[data-test-payment-method="cash"]').exists();
|
||||||
|
assert.dom('[data-test-payment-method="cards"]').hasClass('is-denied');
|
||||||
|
assert.dom('[data-test-payment-method="bitcoin"]').exists();
|
||||||
|
assert.dom('[data-test-payment-badge="lightning"]').exists();
|
||||||
|
|
||||||
|
const methodNames = Array.from(
|
||||||
|
this.element.querySelectorAll('[data-test-payment-method]')
|
||||||
|
).map((el) => el.getAttribute('data-test-payment-method'));
|
||||||
|
assert.deepEqual(
|
||||||
|
methodNames,
|
||||||
|
['cash', 'cards', 'bitcoin'],
|
||||||
|
'renders in stable order: cash, cards, bitcoin'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it does not render payment methods when no payment tags are present on place', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Simple Place',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.4,
|
||||||
|
osmTags: {
|
||||||
|
amenity: 'bench',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('.place-payment-methods').doesNotExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders the operator name with a briefcase icon', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Operated Cafe',
|
||||||
|
osmTags: {
|
||||||
|
operator: 'Le Méridien',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
const operatorBlock = this.element.querySelector(
|
||||||
|
'.meta-info .content-with-icon span.icon[title="Operator"]'
|
||||||
|
);
|
||||||
|
assert.ok(operatorBlock, 'Operator block is rendered');
|
||||||
|
|
||||||
|
const row = operatorBlock.closest('.content-with-icon');
|
||||||
|
assert.dom(row).includesText('Le Méridien');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it splits semicolon-separated operators onto separate lines', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Multi Operator Stop',
|
||||||
|
osmTags: {
|
||||||
|
operator: 'De Lijn;TEC',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
const row = this.element
|
||||||
|
.querySelector('span.icon[title="Operator"]')
|
||||||
|
.closest('.content-with-icon');
|
||||||
|
|
||||||
|
assert.dom(row).includesText('De Lijn');
|
||||||
|
assert.dom(row).includesText('TEC');
|
||||||
|
assert.true(
|
||||||
|
row.querySelector('span:not(.icon)').innerHTML.includes('<br>'),
|
||||||
|
'Operators are separated by a line break'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it suppresses invalid operator values of yes and no', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Invalid Operator',
|
||||||
|
osmTags: {
|
||||||
|
operator: 'yes',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('span.icon[title="Operator"]')
|
||||||
|
.doesNotExist('Operator block is hidden for operator=yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders operator wikidata and website links', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Linked Operator',
|
||||||
|
osmTags: {
|
||||||
|
operator: 'Flixbus',
|
||||||
|
'operator:wikidata': 'Q15712278',
|
||||||
|
'operator:website': 'https://www.flixbus.com/',
|
||||||
|
'operator:wikipedia': 'en:FlixBus',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
const row = this.element
|
||||||
|
.querySelector('span.icon[title="Operator"]')
|
||||||
|
.closest('.content-with-icon');
|
||||||
|
|
||||||
|
const wikidataLink = row.querySelector(
|
||||||
|
'a[href="https://www.wikidata.org/wiki/Q15712278"]'
|
||||||
|
);
|
||||||
|
assert.ok(wikidataLink, 'Wikidata link is rendered');
|
||||||
|
assert.dom(wikidataLink).hasText('Wikidata');
|
||||||
|
|
||||||
|
const websiteLink = row.querySelector('a[href="https://www.flixbus.com/"]');
|
||||||
|
assert.ok(websiteLink, 'Operator website link is rendered');
|
||||||
|
assert.dom(websiteLink).hasText('www.flixbus.com');
|
||||||
|
|
||||||
|
const wikipediaLink = row.querySelector(
|
||||||
|
'a[href="https://en.wikipedia.org/wiki/FlixBus"]'
|
||||||
|
);
|
||||||
|
assert.ok(wikipediaLink, 'Operator Wikipedia link is rendered');
|
||||||
|
assert.dom(wikipediaLink).hasText('Wikipedia');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders the operator block when only operator subkeys are present', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Subkey Only',
|
||||||
|
osmTags: {
|
||||||
|
'operator:wikidata': 'Q42',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('span.icon[title="Operator"]')
|
||||||
|
.exists('Operator block is rendered from subkeys alone');
|
||||||
|
assert
|
||||||
|
.dom('a[href="https://www.wikidata.org/wiki/Q42"]')
|
||||||
|
.exists('Wikidata link is rendered');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render } from '@ember/test-helpers';
|
||||||
|
import PlacePaymentMethods from 'marco/components/place-payment-methods';
|
||||||
|
|
||||||
|
module('Integration | Component | place-payment-methods', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it renders nothing when no payment tags are present', async function (assert) {
|
||||||
|
await render(<template><PlacePaymentMethods @tags={{hash}} /></template>);
|
||||||
|
assert.dom('.place-payment-methods').doesNotExist();
|
||||||
|
|
||||||
|
const otherTags = { amenity: 'restaurant', name: 'Bistro' };
|
||||||
|
await render(
|
||||||
|
<template><PlacePaymentMethods @tags={{otherTags}} /></template>
|
||||||
|
);
|
||||||
|
assert.dom('.place-payment-methods').doesNotExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders cash and card icons when accepted with aria-description and no title attribute', async function (assert) {
|
||||||
|
const tags = {
|
||||||
|
'payment:cash': 'yes',
|
||||||
|
'payment:cards': 'yes',
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlacePaymentMethods @tags={{tags}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('.place-payment-methods').exists();
|
||||||
|
assert.dom('[data-test-payment-method="cash"]').exists();
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="cash"]')
|
||||||
|
.doesNotHaveClass('is-denied');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="cash"]')
|
||||||
|
.doesNotHaveAttribute('title');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="cash"]')
|
||||||
|
.hasAttribute('aria-description', 'Cash accepted');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="cash"] > .icon')
|
||||||
|
.hasAttribute('style', /width:22px;height:22px;color:currentColor/);
|
||||||
|
assert.dom('[data-test-payment-method="cards"]').exists();
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="cards"]')
|
||||||
|
.doesNotHaveClass('is-denied');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="cards"]')
|
||||||
|
.doesNotHaveAttribute('title');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="cards"]')
|
||||||
|
.hasAttribute('aria-description', 'Cards accepted');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="cards"] > .icon')
|
||||||
|
.hasAttribute('style', /width:22px;height:22px;color:currentColor/);
|
||||||
|
assert.dom('[data-test-payment-method="bitcoin"]').doesNotExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it shows strike-through when cards are explicitly denied', async function (assert) {
|
||||||
|
const tags = {
|
||||||
|
'payment:cash': 'yes',
|
||||||
|
'payment:cards': 'no',
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlacePaymentMethods @tags={{tags}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('[data-test-payment-method="cards"]').exists();
|
||||||
|
assert.dom('[data-test-payment-method="cards"]').hasClass('is-denied');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="cash"]')
|
||||||
|
.doesNotHaveClass('is-denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it shows strike-through when cash is explicitly denied', async function (assert) {
|
||||||
|
const tags = {
|
||||||
|
'payment:cash': 'no',
|
||||||
|
'payment:cards': 'yes',
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlacePaymentMethods @tags={{tags}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('[data-test-payment-method="cash"]').exists();
|
||||||
|
assert.dom('[data-test-payment-method="cash"]').hasClass('is-denied');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="cards"]')
|
||||||
|
.doesNotHaveClass('is-denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders on-chain bitcoin without lightning zap badge', async function (assert) {
|
||||||
|
const tags = {
|
||||||
|
'currency:XBT': 'yes',
|
||||||
|
'payment:onchain': 'yes',
|
||||||
|
'payment:lightning': 'no',
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlacePaymentMethods @tags={{tags}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('[data-test-payment-method="bitcoin"]').exists();
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="bitcoin"]')
|
||||||
|
.doesNotHaveClass('is-denied');
|
||||||
|
assert.dom('[data-test-payment-badge="lightning"]').doesNotExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders bitcoin with lightning zap badge when lightning is accepted', async function (assert) {
|
||||||
|
const tags = {
|
||||||
|
'currency:XBT': 'yes',
|
||||||
|
'payment:lightning': 'yes',
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlacePaymentMethods @tags={{tags}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('[data-test-payment-method="bitcoin"]').exists();
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="bitcoin"]')
|
||||||
|
.doesNotHaveClass('is-denied');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="bitcoin"]')
|
||||||
|
.doesNotHaveAttribute('title');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="bitcoin"]')
|
||||||
|
.hasAttribute('aria-description', 'Bitcoin (Lightning) accepted');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="bitcoin"] > .icon')
|
||||||
|
.hasAttribute('style', /width:17px;height:22px;color:currentColor/);
|
||||||
|
assert.dom('[data-test-payment-badge="lightning"]').exists();
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-badge="lightning"] .icon')
|
||||||
|
.hasClass('icon-filled');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-badge="lightning"] .icon')
|
||||||
|
.hasAttribute('style', /width:13px/);
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-badge="lightning"] .icon')
|
||||||
|
.hasAttribute('style', /color:#fff/);
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-badge="lightning"]')
|
||||||
|
.doesNotHaveAttribute('title');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders bitcoin with strike-through when explicitly denied', async function (assert) {
|
||||||
|
const tags = {
|
||||||
|
'currency:XBT': 'no',
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlacePaymentMethods @tags={{tags}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('[data-test-payment-method="bitcoin"]').exists();
|
||||||
|
assert.dom('[data-test-payment-method="bitcoin"]').hasClass('is-denied');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="bitcoin"]')
|
||||||
|
.doesNotHaveAttribute('title');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-payment-method="bitcoin"]')
|
||||||
|
.hasAttribute('aria-description', 'No Bitcoin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it preserves stable order (cash, cards, bitcoin) regardless of whether denied or accepted', async function (assert) {
|
||||||
|
const tags = {
|
||||||
|
'payment:cash': 'no',
|
||||||
|
'payment:cards': 'yes',
|
||||||
|
'currency:XBT': 'yes',
|
||||||
|
'payment:lightning': 'yes',
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlacePaymentMethods @tags={{tags}} /></template>);
|
||||||
|
|
||||||
|
const methods = Array.from(
|
||||||
|
this.element.querySelectorAll('[data-test-payment-method]')
|
||||||
|
).map((el) => el.getAttribute('data-test-payment-method'));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
methods,
|
||||||
|
['cash', 'cards', 'bitcoin'],
|
||||||
|
'icons stay in fixed positions: cash, cards, bitcoin'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,7 +49,7 @@ module('Integration | Component | place-photo-upload', function (hooks) {
|
|||||||
|
|
||||||
assert.dom('.photo-tag-suggestions').exists();
|
assert.dom('.photo-tag-suggestions').exists();
|
||||||
assert.dom('.photo-tag-chip').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) {
|
test('it only allows one selected tag at a time', async function (assert) {
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Type "aw" (2 characters)
|
// Type "aw" (2 characters)
|
||||||
|
await focus('.search-input');
|
||||||
await fillIn('.search-input', 'aw');
|
await fillIn('.search-input', 'aw');
|
||||||
await waitFor('.search-results-popover', { timeout: 2000 });
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, click } from '@ember/test-helpers';
|
||||||
|
import TabNav from 'marco/components/tab-nav';
|
||||||
|
|
||||||
|
module('Integration | Component | tab-nav', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it renders all tab buttons with labels', async function (assert) {
|
||||||
|
this.tabs = [
|
||||||
|
{ label: 'Home', value: 'home' },
|
||||||
|
{ label: 'Explore', value: 'explore' },
|
||||||
|
];
|
||||||
|
this.active = 'home';
|
||||||
|
this.onChange = () => {};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<TabNav
|
||||||
|
@tabs={{this.tabs}}
|
||||||
|
@active={{this.active}}
|
||||||
|
@onChange={{this.onChange}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.tab-nav-button').exists({ count: 2 });
|
||||||
|
const buttons = this.element.querySelectorAll('.tab-nav-button');
|
||||||
|
assert.strictEqual(buttons[0].textContent.trim(), 'Home');
|
||||||
|
assert.strictEqual(buttons[1].textContent.trim(), 'Explore');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it applies is-active class to the active tab', async function (assert) {
|
||||||
|
this.tabs = [
|
||||||
|
{ label: 'Home', value: 'home' },
|
||||||
|
{ label: 'Explore', value: 'explore' },
|
||||||
|
];
|
||||||
|
this.active = 'explore';
|
||||||
|
this.onChange = () => {};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<TabNav
|
||||||
|
@tabs={{this.tabs}}
|
||||||
|
@active={{this.active}}
|
||||||
|
@onChange={{this.onChange}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = this.element.querySelectorAll('.tab-nav-button');
|
||||||
|
assert.false(
|
||||||
|
buttons[0].classList.contains('is-active'),
|
||||||
|
'first tab is not active'
|
||||||
|
);
|
||||||
|
assert.true(
|
||||||
|
buttons[1].classList.contains('is-active'),
|
||||||
|
'second tab is active'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a tab fires onChange with the tab value', async function (assert) {
|
||||||
|
this.tabs = [
|
||||||
|
{ label: 'Home', value: 'home' },
|
||||||
|
{ label: 'Explore', value: 'explore' },
|
||||||
|
];
|
||||||
|
this.active = 'home';
|
||||||
|
this.handleChange = (value) => {
|
||||||
|
this.active = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<TabNav
|
||||||
|
@tabs={{this.tabs}}
|
||||||
|
@active={{this.active}}
|
||||||
|
@onChange={{this.handleChange}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = this.element.querySelectorAll('.tab-nav-button');
|
||||||
|
await click(buttons[1]);
|
||||||
|
|
||||||
|
assert.strictEqual(this.active, 'explore', 'onChange called with explore');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import MapComponent from 'marco/components/map';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
|
module('Unit | Component | map', function () {
|
||||||
|
test('it decodes Planetiler vector tile POI ids into OSM ids and types', function (assert) {
|
||||||
|
const feature = {
|
||||||
|
get(key) {
|
||||||
|
if (key === 'mvt:layer') return 'poi';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
getId() {
|
||||||
|
return 12342;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = MapComponent.prototype.decodeVectorTileOsmFeature(feature);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
decoded: {
|
||||||
|
osmId: '1234',
|
||||||
|
osmType: 'way',
|
||||||
|
},
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it ignores non-POI vector tile features', function (assert) {
|
||||||
|
const feature = {
|
||||||
|
get(key) {
|
||||||
|
if (key === 'mvt:layer') return 'transportation';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
getId() {
|
||||||
|
return 12342;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
MapComponent.prototype.decodeVectorTileOsmFeature(feature),
|
||||||
|
{
|
||||||
|
decoded: null,
|
||||||
|
reason: 'unsupported source layer: transportation',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it ignores unsupported or invalid vector tile ids', function (assert) {
|
||||||
|
const feature = {
|
||||||
|
get(key) {
|
||||||
|
if (key === 'mvt:layer') return 'poi';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
getId() {
|
||||||
|
return 12340;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
MapComponent.prototype.decodeVectorTileOsmFeature(feature),
|
||||||
|
{
|
||||||
|
decoded: null,
|
||||||
|
reason: 'feature id suffix 0 is not an OSM type',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,976 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
class MockPlaceNameResolver extends Service {
|
||||||
|
resolveBookmark() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
resolveInBackground() {
|
||||||
|
return new Promise(() => {});
|
||||||
|
}
|
||||||
|
reset() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockNostrDataService extends Service {
|
||||||
|
@tracked profiles = {};
|
||||||
|
_contactPubkeys = new Set();
|
||||||
|
refreshProfilesCalls = [];
|
||||||
|
|
||||||
|
refreshProfiles(pubkeys) {
|
||||||
|
this.refreshProfilesCalls.push(pubkeys);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {}
|
||||||
|
loadActivityPhotos() {}
|
||||||
|
loadMyContributions() {}
|
||||||
|
loadProfile() {}
|
||||||
|
whenContactsLoaded() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchActivityPhotos() {
|
||||||
|
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchIncomingZaps() {
|
||||||
|
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.owner.register('service:placeNameResolver', MockPlaceNameResolver);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_updateZapItems 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._updateZapItems([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('_updateZapItems 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._updateZapItems([receipt], USER_PUBKEY);
|
||||||
|
|
||||||
|
assert.strictEqual(service.items.length, 0, 'non-photo zap filtered out');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_updateZapItems 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._updateZapItems([receipt], USER_PUBKEY);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
service.items.length,
|
||||||
|
0,
|
||||||
|
"zap for someone else's photo filtered out"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_updateZapItems 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._updateZapItems([receipt], USER_PUBKEY);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
service.items.length,
|
||||||
|
0,
|
||||||
|
'zap directed at someone else filtered out'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_updateZapItems 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._updateZapItems([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._updateZapItems(
|
||||||
|
[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 preserves existing items', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service.items = [{ fake: true }];
|
||||||
|
|
||||||
|
await service.load(null);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
service.items.length,
|
||||||
|
1,
|
||||||
|
'items preserved when already loaded'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retryUnresolvedProfiles refreshes only unresolved sender pubkeys', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service._sourceMode = 'explore';
|
||||||
|
service.nostrData._contactPubkeys = new Set();
|
||||||
|
service.nostrData.isTrustedEvent = () => true;
|
||||||
|
|
||||||
|
const OTHER_PUBKEY = 'c'.repeat(64);
|
||||||
|
service.nostrData.profiles[SENDER_PUBKEY] = { name: 'Alice' };
|
||||||
|
|
||||||
|
const resolvedPhoto = makePhotoEvent({
|
||||||
|
id: 'rp'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: 1000,
|
||||||
|
});
|
||||||
|
const unresolvedPhoto = makePhotoEvent({
|
||||||
|
id: 'up'.padEnd(64, '0'),
|
||||||
|
author: OTHER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:200',
|
||||||
|
created_at: 2000,
|
||||||
|
});
|
||||||
|
service._updateSocialItems([resolvedPhoto, unresolvedPhoto]);
|
||||||
|
|
||||||
|
assert.strictEqual(service.items.length, 2, 'both entries present');
|
||||||
|
assert.false(
|
||||||
|
service.items.find((i) => i.senderPubkey === SENDER_PUBKEY)
|
||||||
|
.senderProfileLoading,
|
||||||
|
'resolved sender not loading'
|
||||||
|
);
|
||||||
|
assert.true(
|
||||||
|
service.items.find((i) => i.senderPubkey === OTHER_PUBKEY)
|
||||||
|
.senderProfileLoading,
|
||||||
|
'unresolved sender still loading'
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.retryUnresolvedProfiles();
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
service.nostrData.refreshProfilesCalls.length,
|
||||||
|
1,
|
||||||
|
'refresh called once'
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
service.nostrData.refreshProfilesCalls[0],
|
||||||
|
[OTHER_PUBKEY],
|
||||||
|
'only the unresolved sender is refreshed'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retryUnresolvedProfiles does nothing when all senders are resolved', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service._sourceMode = 'explore';
|
||||||
|
service.nostrData._contactPubkeys = new Set();
|
||||||
|
service.nostrData.isTrustedEvent = () => true;
|
||||||
|
service.nostrData.profiles[SENDER_PUBKEY] = { name: 'Alice' };
|
||||||
|
|
||||||
|
service._updateSocialItems([
|
||||||
|
makePhotoEvent({
|
||||||
|
id: 'rp2'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: 1000,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.retryUnresolvedProfiles();
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
service.nostrData.refreshProfilesCalls.length,
|
||||||
|
0,
|
||||||
|
'no refresh when every sender is resolved'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSourceMode retries unresolved profiles after fetching', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service.nostrData._contactPubkeys = new Set();
|
||||||
|
service.nostrData.isTrustedEvent = () => true;
|
||||||
|
|
||||||
|
const photo = makePhotoEvent({
|
||||||
|
id: 'sm1'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
service.nostrData.fetchActivityPhotos = async (_since, _until, mode) => {
|
||||||
|
if (mode === 'explore')
|
||||||
|
return { cacheEvents: [photo], networkEvents: Promise.resolve([]) };
|
||||||
|
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.setSourceMode('explore');
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
service.nostrData.refreshProfilesCalls.length,
|
||||||
|
1,
|
||||||
|
'refresh triggered by mode switch'
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
service.nostrData.refreshProfilesCalls[0],
|
||||||
|
[SENDER_PUBKEY],
|
||||||
|
'refreshes the unresolved sender'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_updateSocialItems groups photos by author + place', function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
const photo1 = makePhotoEvent({
|
||||||
|
id: 'p1'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: 1000,
|
||||||
|
});
|
||||||
|
const photo2 = makePhotoEvent({
|
||||||
|
id: 'p2'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
service._updateSocialItems([photo1, photo2]);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
service.items.length,
|
||||||
|
1,
|
||||||
|
'one entry for same author + place'
|
||||||
|
);
|
||||||
|
assert.strictEqual(service.items[0].type, 'photo');
|
||||||
|
assert.strictEqual(service.items[0].photos.length, 2);
|
||||||
|
assert.strictEqual(service.items[0].senderPubkey, SENDER_PUBKEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_matchesSourceMode filters by contact pubkeys in home mode', function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service._sourceMode = 'home';
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
const followedPhoto = makePhotoEvent({ author: SENDER_PUBKEY });
|
||||||
|
const unfollowedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
|
||||||
|
const ownPhoto = makePhotoEvent({ author: USER_PUBKEY });
|
||||||
|
|
||||||
|
assert.true(
|
||||||
|
service._matchesSourceMode(followedPhoto),
|
||||||
|
'followed contact passes'
|
||||||
|
);
|
||||||
|
assert.false(
|
||||||
|
service._matchesSourceMode(unfollowedPhoto),
|
||||||
|
'unfollowed pubkey rejected'
|
||||||
|
);
|
||||||
|
assert.false(service._matchesSourceMode(ownPhoto), 'own photo excluded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_matchesSourceMode in explore mode includes trusted strangers, excludes own photos and followees', function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service._sourceMode = 'explore';
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
const STRANGER_PUBKEY = 'c'.repeat(64);
|
||||||
|
|
||||||
|
// Mock isTrustedEvent to return true for SENDER_PUBKEY and STRANGER_PUBKEY
|
||||||
|
service.nostrData.isTrustedEvent = (event) =>
|
||||||
|
event.pubkey === SENDER_PUBKEY || event.pubkey === STRANGER_PUBKEY;
|
||||||
|
|
||||||
|
const trustedStrangerPhoto = makePhotoEvent({ author: STRANGER_PUBKEY });
|
||||||
|
const trustedFolloweePhoto = makePhotoEvent({ author: SENDER_PUBKEY });
|
||||||
|
const untrustedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
|
||||||
|
const ownPhoto = makePhotoEvent({ author: USER_PUBKEY });
|
||||||
|
|
||||||
|
assert.true(
|
||||||
|
service._matchesSourceMode(trustedStrangerPhoto),
|
||||||
|
'trusted stranger passes in explore mode'
|
||||||
|
);
|
||||||
|
assert.false(
|
||||||
|
service._matchesSourceMode(trustedFolloweePhoto),
|
||||||
|
'trusted followee excluded in explore mode'
|
||||||
|
);
|
||||||
|
assert.false(
|
||||||
|
service._matchesSourceMode(untrustedPhoto),
|
||||||
|
'untrusted event rejected in explore mode'
|
||||||
|
);
|
||||||
|
assert.false(
|
||||||
|
service._matchesSourceMode(ownPhoto),
|
||||||
|
'own photo excluded in explore mode'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_matchesSourceMode in explore mode with no pubkey includes all trusted events', function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = null;
|
||||||
|
service._sourceMode = 'explore';
|
||||||
|
service.nostrData._contactPubkeys = null;
|
||||||
|
|
||||||
|
const STRANGER_PUBKEY = 'c'.repeat(64);
|
||||||
|
|
||||||
|
service.nostrData.isTrustedEvent = (event) =>
|
||||||
|
event.pubkey === SENDER_PUBKEY || event.pubkey === STRANGER_PUBKEY;
|
||||||
|
|
||||||
|
const trustedPhoto = makePhotoEvent({ author: SENDER_PUBKEY });
|
||||||
|
const trustedStrangerPhoto = makePhotoEvent({ author: STRANGER_PUBKEY });
|
||||||
|
const untrustedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
|
||||||
|
|
||||||
|
assert.true(
|
||||||
|
service._matchesSourceMode(trustedPhoto),
|
||||||
|
'trusted event passes with no pubkey'
|
||||||
|
);
|
||||||
|
assert.true(
|
||||||
|
service._matchesSourceMode(trustedStrangerPhoto),
|
||||||
|
'trusted stranger passes with no pubkey'
|
||||||
|
);
|
||||||
|
assert.false(
|
||||||
|
service._matchesSourceMode(untrustedPhoto),
|
||||||
|
'untrusted event rejected with no pubkey'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_updateSocialItems merges with zap items sorted by createdAt', function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
// Seed a zap item
|
||||||
|
service.nostrData.store.add(
|
||||||
|
makePhotoEvent({ id: PHOTO_EVENT_ID_1, placeIdentifier: 'osm:node:50' })
|
||||||
|
);
|
||||||
|
service._updateZapItems(
|
||||||
|
[
|
||||||
|
makeZapReceiptEvent({
|
||||||
|
zappedEventId: PHOTO_EVENT_ID_1,
|
||||||
|
created_at: 5000,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
USER_PUBKEY
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a social photo that is newer
|
||||||
|
const socialPhoto = makePhotoEvent({
|
||||||
|
id: 'sp1'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: 9000,
|
||||||
|
});
|
||||||
|
service._updateSocialItems([socialPhoto]);
|
||||||
|
|
||||||
|
assert.strictEqual(service.items.length, 2);
|
||||||
|
assert.strictEqual(
|
||||||
|
service.items[0].createdAt,
|
||||||
|
9000,
|
||||||
|
'newer social photo first'
|
||||||
|
);
|
||||||
|
assert.strictEqual(service.items[1].createdAt, 5000, 'older zap second');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explore mode excludes zap items from merged results', function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service._sourceMode = 'explore';
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
// Seed a zap item
|
||||||
|
service.nostrData.store.add(
|
||||||
|
makePhotoEvent({ id: PHOTO_EVENT_ID_1, placeIdentifier: 'osm:node:50' })
|
||||||
|
);
|
||||||
|
service._updateZapItems(
|
||||||
|
[
|
||||||
|
makeZapReceiptEvent({
|
||||||
|
zappedEventId: PHOTO_EVENT_ID_1,
|
||||||
|
created_at: 5000,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
USER_PUBKEY
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a trusted stranger social photo
|
||||||
|
const STRANGER_PUBKEY = 'c'.repeat(64);
|
||||||
|
service.nostrData.isTrustedEvent = (event) =>
|
||||||
|
event.pubkey === STRANGER_PUBKEY;
|
||||||
|
|
||||||
|
const socialPhoto = makePhotoEvent({
|
||||||
|
id: 'sp1'.padEnd(64, '0'),
|
||||||
|
author: STRANGER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: 9000,
|
||||||
|
});
|
||||||
|
service._updateSocialItems([socialPhoto]);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
service.items.length,
|
||||||
|
1,
|
||||||
|
'zap excluded in explore mode, only social photo shown'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
service.items[0].createdAt,
|
||||||
|
9000,
|
||||||
|
'only the social photo is present'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSourceMode resets and re-filters with fresh 30-day window', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service._sourceMode = 'explore';
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
service.nostrData.isTrustedEvent = () => false;
|
||||||
|
|
||||||
|
const followedPhoto = makePhotoEvent({
|
||||||
|
id: 'fp1'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
service.nostrData.fetchActivityPhotos = async (_since, _until, mode) => {
|
||||||
|
if (mode === 'home')
|
||||||
|
return {
|
||||||
|
cacheEvents: [followedPhoto],
|
||||||
|
networkEvents: Promise.resolve([]),
|
||||||
|
};
|
||||||
|
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||||
|
};
|
||||||
|
|
||||||
|
service._updateSocialItems([followedPhoto]);
|
||||||
|
assert.strictEqual(service.items.length, 0, 'no items in explore mode');
|
||||||
|
|
||||||
|
await service.setSourceMode('home');
|
||||||
|
|
||||||
|
assert.strictEqual(service._sourceMode, 'home', 'mode switched');
|
||||||
|
assert.ok(
|
||||||
|
service.items.length > 0,
|
||||||
|
'items loaded after switching to home mode'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop clears social items and resets state', function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
const photo = makePhotoEvent({
|
||||||
|
id: 'sp2'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
});
|
||||||
|
service._updateSocialItems([photo]);
|
||||||
|
|
||||||
|
assert.strictEqual(service.items.length, 1);
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
|
||||||
|
assert.strictEqual(service.items.length, 0, 'items cleared');
|
||||||
|
assert.strictEqual(service._socialItems.length, 0, 'social items cleared');
|
||||||
|
assert.strictEqual(service._zapItems.length, 0, 'zap items cleared');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMore fetches older events with until set to old since', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
const NOW = Math.floor(Date.now() / 1000);
|
||||||
|
service._since = NOW;
|
||||||
|
|
||||||
|
const recentPhoto = makePhotoEvent({
|
||||||
|
id: 'r1'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: NOW - 100,
|
||||||
|
});
|
||||||
|
service._updateSocialItems([recentPhoto]);
|
||||||
|
assert.ok(service.items.length > 0, 'has initial items');
|
||||||
|
|
||||||
|
const oldPhoto = makePhotoEvent({
|
||||||
|
id: 'old1'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:200',
|
||||||
|
created_at: NOW - 5000,
|
||||||
|
});
|
||||||
|
service.nostrData.fetchActivityPhotos = async (since, until) => {
|
||||||
|
assert.strictEqual(since, NOW - 30 * 24 * 60 * 60, 'since extended');
|
||||||
|
assert.strictEqual(until, NOW, 'until set to old since');
|
||||||
|
return {
|
||||||
|
cacheEvents: [oldPhoto],
|
||||||
|
networkEvents: Promise.resolve([]),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.loadMore();
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
service._since,
|
||||||
|
NOW - 30 * 24 * 60 * 60,
|
||||||
|
'_since extended'
|
||||||
|
);
|
||||||
|
assert.false(service.isLoadingMore, 'isLoadingMore reset');
|
||||||
|
assert.false(service._isLoadingMore, '_isLoadingMore reset');
|
||||||
|
assert.ok(
|
||||||
|
service.items.length >= 2,
|
||||||
|
'new items appended without re-processing'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMore fetches zaps alongside photos in home mode', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service._sourceMode = 'home';
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
const NOW = Math.floor(Date.now() / 1000);
|
||||||
|
service._since = NOW;
|
||||||
|
|
||||||
|
// Seed initial photo so loadMore doesn't bail
|
||||||
|
const recentPhoto = makePhotoEvent({
|
||||||
|
id: 'r5'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: NOW - 100,
|
||||||
|
});
|
||||||
|
service._updateSocialItems([recentPhoto]);
|
||||||
|
|
||||||
|
// Also seed a photo for the zap to reference
|
||||||
|
const zapPhoto = makePhotoEvent({
|
||||||
|
id: PHOTO_EVENT_ID_1,
|
||||||
|
author: USER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:50',
|
||||||
|
created_at: NOW - 200,
|
||||||
|
});
|
||||||
|
service.nostrData.store.add(zapPhoto);
|
||||||
|
|
||||||
|
const oldZapReceipt = makeZapReceiptEvent({
|
||||||
|
zappedEventId: PHOTO_EVENT_ID_1,
|
||||||
|
created_at: NOW - 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let zapCallCount = 0;
|
||||||
|
service.nostrData.fetchActivityPhotos = async () => ({
|
||||||
|
cacheEvents: [],
|
||||||
|
networkEvents: Promise.resolve([]),
|
||||||
|
});
|
||||||
|
service.nostrData.fetchIncomingZaps = async (_pubkey, since, until) => {
|
||||||
|
zapCallCount++;
|
||||||
|
assert.strictEqual(since, NOW - 30 * 24 * 60 * 60, 'zap since matches');
|
||||||
|
assert.strictEqual(until, NOW, 'zap until matches');
|
||||||
|
return {
|
||||||
|
cacheEvents: [oldZapReceipt],
|
||||||
|
networkEvents: Promise.resolve([]),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.loadMore();
|
||||||
|
|
||||||
|
assert.strictEqual(zapCallCount, 1, 'fetchIncomingZaps called once');
|
||||||
|
assert.ok(service.items.length >= 2, 'zap and photo items both present');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMore exponentially expands window on empty results', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
const NOW = Math.floor(Date.now() / 1000);
|
||||||
|
service._since = NOW;
|
||||||
|
|
||||||
|
const recentPhoto = makePhotoEvent({
|
||||||
|
id: 'r2'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: NOW - 100,
|
||||||
|
});
|
||||||
|
service._updateSocialItems([recentPhoto]);
|
||||||
|
|
||||||
|
const callArgs = [];
|
||||||
|
let callCount = 0;
|
||||||
|
service.nostrData.fetchActivityPhotos = async (since, until) => {
|
||||||
|
callArgs.push({ since, until });
|
||||||
|
callCount++;
|
||||||
|
if (callCount < 2)
|
||||||
|
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||||
|
return {
|
||||||
|
cacheEvents: [
|
||||||
|
makePhotoEvent({
|
||||||
|
id: 'old2'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:300',
|
||||||
|
created_at: NOW - 100000,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
networkEvents: Promise.resolve([]),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.loadMore();
|
||||||
|
|
||||||
|
assert.strictEqual(callCount, 2, 'retried with expanding window');
|
||||||
|
assert.strictEqual(callArgs[0].since, NOW - 30 * 24 * 60 * 60);
|
||||||
|
assert.strictEqual(
|
||||||
|
callArgs[1].since,
|
||||||
|
NOW - 30 * 24 * 60 * 60 - 60 * 24 * 60 * 60
|
||||||
|
);
|
||||||
|
assert.ok(service.items.length >= 2, 'events from 2nd call appended');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMore stops at MIN_SINCE floor with clamped final fetch', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
const MIN_SINCE = Math.floor(new Date('2026-04-20').getTime() / 1000);
|
||||||
|
service._since = MIN_SINCE + 60 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
const recentPhoto = makePhotoEvent({
|
||||||
|
id: 'r3'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: service._since - 100,
|
||||||
|
});
|
||||||
|
service._updateSocialItems([recentPhoto]);
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
service.nostrData.fetchActivityPhotos = async () => {
|
||||||
|
callCount++;
|
||||||
|
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.loadMore();
|
||||||
|
|
||||||
|
// First 30-day window: batchSince = MIN_SINCE + 30d (>= MIN_SINCE, executes)
|
||||||
|
// Second 60-day window: batchSince = MIN_SINCE - 30d (< MIN_SINCE, clamped to MIN_SINCE for final fetch)
|
||||||
|
assert.strictEqual(callCount, 2, 'one normal + one clamped final fetch');
|
||||||
|
assert.false(service.isLoadingMore, 'isLoadingMore reset after exhaustion');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMore guard prevents concurrent calls', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
const NOW = Math.floor(Date.now() / 1000);
|
||||||
|
service._since = NOW;
|
||||||
|
|
||||||
|
const recentPhoto = makePhotoEvent({
|
||||||
|
id: 'r4'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: NOW - 100,
|
||||||
|
});
|
||||||
|
service._updateSocialItems([recentPhoto]);
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
service.nostrData.fetchActivityPhotos = async () => {
|
||||||
|
callCount++;
|
||||||
|
return {
|
||||||
|
cacheEvents: [
|
||||||
|
makePhotoEvent({
|
||||||
|
id: 'old3'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:200',
|
||||||
|
created_at: NOW - 5000,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
networkEvents: Promise.resolve([]),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise1 = service.loadMore();
|
||||||
|
await service.loadMore();
|
||||||
|
await promise1;
|
||||||
|
|
||||||
|
assert.strictEqual(callCount, 1, 'only one loadMore sequence');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMore does nothing when no items', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service._since = Math.floor(Date.now() / 1000);
|
||||||
|
service.items = [];
|
||||||
|
|
||||||
|
let called = false;
|
||||||
|
service.nostrData.fetchActivityPhotos = async () => {
|
||||||
|
called = true;
|
||||||
|
return { cacheEvents: [], networkEvents: Promise.resolve([]) };
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.loadMore();
|
||||||
|
|
||||||
|
assert.false(called, 'no fetch when items is empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadMore does not append duplicate photo group', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._userPubkey = USER_PUBKEY;
|
||||||
|
service._sourceMode = 'explore';
|
||||||
|
service.sourceMode = 'explore';
|
||||||
|
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||||
|
|
||||||
|
const STRANGER_PUBKEY = 'c'.repeat(64);
|
||||||
|
service.nostrData.isTrustedEvent = (event) =>
|
||||||
|
event.pubkey === STRANGER_PUBKEY;
|
||||||
|
|
||||||
|
const NOW = Math.floor(Date.now() / 1000);
|
||||||
|
service._since = NOW;
|
||||||
|
|
||||||
|
const photo1 = makePhotoEvent({
|
||||||
|
id: 'd1'.padEnd(64, '0'),
|
||||||
|
author: STRANGER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: NOW - 100,
|
||||||
|
url: 'https://x.com/p1.jpg',
|
||||||
|
});
|
||||||
|
const photo2 = makePhotoEvent({
|
||||||
|
id: 'd2'.padEnd(64, '0'),
|
||||||
|
author: STRANGER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: NOW - 200,
|
||||||
|
url: 'https://x.com/p2.jpg',
|
||||||
|
});
|
||||||
|
const photo3 = makePhotoEvent({
|
||||||
|
id: 'd3'.padEnd(64, '0'),
|
||||||
|
author: STRANGER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:100',
|
||||||
|
created_at: NOW - 300,
|
||||||
|
url: 'https://x.com/p3.jpg',
|
||||||
|
});
|
||||||
|
|
||||||
|
service._updateSocialItems([photo1, photo2, photo3]);
|
||||||
|
assert.strictEqual(
|
||||||
|
service.items.length,
|
||||||
|
1,
|
||||||
|
'one grouped entry with 3 photos'
|
||||||
|
);
|
||||||
|
assert.strictEqual(service.items[0].photos.length, 3);
|
||||||
|
|
||||||
|
service.nostrData.fetchActivityPhotos = async () => ({
|
||||||
|
cacheEvents: [photo1, photo2, photo3],
|
||||||
|
networkEvents: Promise.resolve([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.loadMore();
|
||||||
|
|
||||||
|
assert.strictEqual(service.items.length, 1, 'duplicate group not appended');
|
||||||
|
assert.strictEqual(
|
||||||
|
service.items[0].photos.length,
|
||||||
|
3,
|
||||||
|
'photo count unchanged'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop resets isLoadingMore and isLoading', function (assert) {
|
||||||
|
const service = this.owner.lookup('service:activity');
|
||||||
|
service._isLoadingMore = true;
|
||||||
|
service.isLoadingMore = true;
|
||||||
|
service.isLoading = true;
|
||||||
|
|
||||||
|
service.stop();
|
||||||
|
|
||||||
|
assert.false(service.isLoadingMore, 'isLoadingMore reset');
|
||||||
|
assert.false(service._isLoadingMore, '_isLoadingMore reset');
|
||||||
|
assert.false(service.isLoading, 'isLoading reset');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
module('Unit | Service | contributions', function (hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
|
|
||||||
@@ -66,212 +60,57 @@ module('Unit | Service | contributions', function (hooks) {
|
|||||||
this.owner.register('service:osm', MockOsmService);
|
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 events = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
|
||||||
|
|
||||||
const service = this.owner.lookup('service:contributions');
|
const service = this.owner.lookup('service:contributions');
|
||||||
service.storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
|
const storage = this.owner.lookup('service:storage');
|
||||||
service.storage.findPlaceById = (id) =>
|
storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
|
||||||
|
storage.findPlaceById = (id) =>
|
||||||
id === '123' ? { id: '123', title: 'Bookmarked Café' } : null;
|
id === '123' ? { id: '123', title: 'Bookmarked Café' } : null;
|
||||||
|
|
||||||
service._updateItems(events);
|
service._updateItems(events);
|
||||||
|
|
||||||
assert.strictEqual(service.items.length, 1);
|
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.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) {
|
test('stop clears items and resets resolver state', async function (assert) {
|
||||||
const events = [
|
|
||||||
makePhotoEvent({
|
|
||||||
id: 'e1',
|
|
||||||
created_at: 1000,
|
|
||||||
placeIdentifier: 'osm:node:42',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const service = this.owner.lookup('service:contributions');
|
const service = this.owner.lookup('service:contributions');
|
||||||
service.osm.fetchOsmObjectsBatch = async () => {
|
const events = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
|
||||||
const map = new Map();
|
|
||||||
map.set('node:42', { title: 'Test Place' });
|
|
||||||
return map;
|
|
||||||
};
|
|
||||||
|
|
||||||
service._updateItems(events);
|
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(
|
assert.strictEqual(
|
||||||
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:42'),
|
resolver._lastBatchSignature,
|
||||||
'Test Place',
|
'',
|
||||||
'Name is persisted to the IndexedDB name cache'
|
'resolver state reset'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('names stored in a previous session are resolved from the IndexedDB cache', async function (assert) {
|
test('multiple _updateItems calls re-group events correctly', function (assert) {
|
||||||
const events = [
|
|
||||||
makePhotoEvent({
|
|
||||||
id: 'e1',
|
|
||||||
created_at: 1000,
|
|
||||||
placeIdentifier: 'osm:node:77',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const service = this.owner.lookup('service:contributions');
|
const service = this.owner.lookup('service:contributions');
|
||||||
await service.localForage.set(
|
const events1 = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
|
||||||
NAME_CACHE_STORE,
|
const events2 = [makePhotoEvent({ id: 'e2', created_at: 2000 })];
|
||||||
'osm:node:77',
|
|
||||||
'Persisted Place'
|
|
||||||
);
|
|
||||||
|
|
||||||
service._updateItems(events);
|
service._updateItems(events1);
|
||||||
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'
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
service.items[0].placeName,
|
service.items.length,
|
||||||
'OSM node 555',
|
1,
|
||||||
'Fallback name is applied'
|
'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) {
|
service._updateItems(events2);
|
||||||
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');
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
service.items[0].placeName,
|
service.items.length,
|
||||||
'OSM node 777',
|
1,
|
||||||
'Fallback is shown immediately'
|
'second update replaces with new group'
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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'
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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');
|
let service = this.owner.lookup('service:osm');
|
||||||
|
|
||||||
service.fetchWithRetry = async () => ({
|
service.fetchWithRetry = async () => ({
|
||||||
@@ -343,13 +343,38 @@ module('Unit | Service | osm', function (hooks) {
|
|||||||
|
|
||||||
await service.fetchOsmObjectsBatch([{ osmType: 'node', osmId: '100' }]);
|
await service.fetchOsmObjectsBatch([{ osmType: 'node', osmId: '100' }]);
|
||||||
|
|
||||||
assert.false(
|
assert.true(
|
||||||
service.cachedPlaces.has('node:100'),
|
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(
|
assert.notOk(
|
||||||
await service.localForage.get('osm-cache', 'node:100'),
|
await service.localForage.get('osm-cache', 'way:200'),
|
||||||
'Batch fetch does not write to the persistent OSM cache'
|
'Batch fetch does NOT write incomplete results to persistent cache'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deduplicated batch resolves entries from a second call with same place identifiers', async function (assert) {
|
||||||
|
const resolver = this.owner.lookup('service:place-name-resolver');
|
||||||
|
const localForage = this.owner.lookup('service:localForage');
|
||||||
|
|
||||||
|
let fetchCount = 0;
|
||||||
|
resolver.osm.fetchOsmObjectsBatch = async () => {
|
||||||
|
fetchCount++;
|
||||||
|
const map = new Map();
|
||||||
|
map.set('node:222', { title: 'Shared Place' });
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
// First call with entry1 — triggers a batch fetch
|
||||||
|
const entry1 = makeEntry({ placeIdentifier: 'osm:node:222' });
|
||||||
|
const promise1 = resolver.resolveInBackground([entry1]);
|
||||||
|
await flushPromises();
|
||||||
|
await promise1;
|
||||||
|
|
||||||
|
// Verify the name persisted so the second call's cache check finds it
|
||||||
|
assert.strictEqual(entry1.placeName, 'Shared Place');
|
||||||
|
assert.strictEqual(fetchCount, 1, 'only one batch fetch fired');
|
||||||
|
|
||||||
|
// Simulate the real-world race: a second set of entries for the same
|
||||||
|
// place arrives before the cache is consulted. Clear the persistent cache
|
||||||
|
// to simulate the incognito scenario where the batch hasn't persisted yet.
|
||||||
|
await localForage.clear('place-name-cache');
|
||||||
|
|
||||||
|
// Second call with a fresh entry2 — should resolve from the batch result
|
||||||
|
// that was already persisted (or re-use the pending batch if still in flight)
|
||||||
|
const entry2 = makeEntry({ placeIdentifier: 'osm:node:222' });
|
||||||
|
await resolver.resolveInBackground([entry2]);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
entry2.placeName,
|
||||||
|
'Shared Place',
|
||||||
|
'second entry resolves even when batch was from a prior call'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending batch is shared when second call fires before first completes', async function (assert) {
|
||||||
|
const resolver = this.owner.lookup('service:place-name-resolver');
|
||||||
|
|
||||||
|
let fetchCount = 0;
|
||||||
|
resolver.osm.fetchOsmObjectsBatch = async () => {
|
||||||
|
fetchCount++;
|
||||||
|
const map = new Map();
|
||||||
|
map.set('node:333', { title: 'Concurrent Place' });
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
// First call — don't await yet (simulates subscription firing)
|
||||||
|
const entry1 = makeEntry({ placeIdentifier: 'osm:node:333' });
|
||||||
|
const promise1 = resolver.resolveInBackground([entry1]);
|
||||||
|
|
||||||
|
// Second call with a different entry for the same place — should piggyback
|
||||||
|
const entry2 = makeEntry({ placeIdentifier: 'osm:node:333' });
|
||||||
|
const promise2 = resolver.resolveInBackground([entry2]);
|
||||||
|
|
||||||
|
await Promise.all([promise1, promise2]);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
assert.strictEqual(fetchCount, 1, 'only one batch fetch fired');
|
||||||
|
assert.strictEqual(entry1.placeName, 'Concurrent Place');
|
||||||
|
assert.strictEqual(entry2.placeName, 'Concurrent Place');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import {
|
||||||
|
ActivityEntry,
|
||||||
|
parseZapReceipt,
|
||||||
|
enrichWithPhoto,
|
||||||
|
groupSocialPhotos,
|
||||||
|
} 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const SOCIAL_PK_A = 'a'.repeat(64);
|
||||||
|
const SOCIAL_PK_B = 'b'.repeat(64);
|
||||||
|
|
||||||
|
function makeSocialPhotoEvent(opts = {}) {
|
||||||
|
return {
|
||||||
|
id: opts.id || `e${(opts.idx ?? 0).toString().padStart(63, '0')}`,
|
||||||
|
pubkey: opts.author || SOCIAL_PK_A,
|
||||||
|
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 | groupSocialPhotos', function () {
|
||||||
|
test('returns empty for no events', function (assert) {
|
||||||
|
assert.deepEqual(groupSocialPhotos([]), []);
|
||||||
|
assert.deepEqual(groupSocialPhotos(null), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groups by author + place within time threshold', function (assert) {
|
||||||
|
const events = [
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 1,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 1000,
|
||||||
|
}),
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 2,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 2000,
|
||||||
|
}),
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 3,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 3000,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = groupSocialPhotos(events);
|
||||||
|
assert.strictEqual(entries.length, 1, 'one entry for same author + place');
|
||||||
|
assert.strictEqual(entries[0].type, 'photo');
|
||||||
|
assert.strictEqual(entries[0].photos.length, 3);
|
||||||
|
assert.strictEqual(entries[0].senderPubkey, SOCIAL_PK_A);
|
||||||
|
assert.strictEqual(entries[0].createdAt, 3000, 'newest created_at');
|
||||||
|
assert.strictEqual(entries[0].placeIdentifier, 'osm:node:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separates entries by author for the same place', function (assert) {
|
||||||
|
const events = [
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 1,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 1000,
|
||||||
|
}),
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 2,
|
||||||
|
author: SOCIAL_PK_B,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 2000,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = groupSocialPhotos(events);
|
||||||
|
assert.strictEqual(entries.length, 2, 'two entries (one per author)');
|
||||||
|
assert.strictEqual(entries[0].senderPubkey, SOCIAL_PK_B, 'newest first');
|
||||||
|
assert.strictEqual(entries[1].senderPubkey, SOCIAL_PK_A);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separates entries by place for the same author', function (assert) {
|
||||||
|
const events = [
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 1,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 1000,
|
||||||
|
}),
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 2,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:2',
|
||||||
|
created_at: 2000,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = groupSocialPhotos(events);
|
||||||
|
assert.strictEqual(entries.length, 2, 'two entries (one per place)');
|
||||||
|
assert.strictEqual(
|
||||||
|
entries[0].placeIdentifier,
|
||||||
|
'osm:node:2',
|
||||||
|
'newest first'
|
||||||
|
);
|
||||||
|
assert.strictEqual(entries[1].placeIdentifier, 'osm:node:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splits by time proximity within same author + place', function (assert) {
|
||||||
|
const events = [
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 1,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 1000,
|
||||||
|
}),
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 2,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 2000,
|
||||||
|
}),
|
||||||
|
// 5-hour gap (> 3h threshold)
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 3,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 20000,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = groupSocialPhotos(events);
|
||||||
|
assert.strictEqual(entries.length, 2, 'split by time gap');
|
||||||
|
assert.strictEqual(entries[0].photos.length, 1, 'newer group has 1 photo');
|
||||||
|
assert.strictEqual(entries[1].photos.length, 2, 'older group has 2 photos');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sorts entries newest-first', function (assert) {
|
||||||
|
const events = [
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 1,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 1000,
|
||||||
|
}),
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 2,
|
||||||
|
author: SOCIAL_PK_B,
|
||||||
|
placeIdentifier: 'osm:node:2',
|
||||||
|
created_at: 5000,
|
||||||
|
}),
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 3,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:node:1',
|
||||||
|
created_at: 2000,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = groupSocialPhotos(events);
|
||||||
|
assert.strictEqual(entries[0].createdAt, 5000, 'newest entry first');
|
||||||
|
assert.strictEqual(entries[1].createdAt, 2000, 'second entry');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets osmType and osmId from placeIdentifier', function (assert) {
|
||||||
|
const events = [
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 1,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
placeIdentifier: 'osm:way:999',
|
||||||
|
created_at: 1000,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = groupSocialPhotos(events);
|
||||||
|
assert.strictEqual(entries[0].osmType, 'way');
|
||||||
|
assert.strictEqual(entries[0].osmId, '999');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters out events without an i tag', function (assert) {
|
||||||
|
const event = makeSocialPhotoEvent({ idx: 1, author: SOCIAL_PK_A });
|
||||||
|
event.tags = [['imeta', 'url https://x.com/photo.jpg', 'dim 800x600']];
|
||||||
|
|
||||||
|
const entries = groupSocialPhotos([event]);
|
||||||
|
assert.strictEqual(entries.length, 0, 'no i tag → filtered out');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies kind 5 deletions', function (assert) {
|
||||||
|
const photoId = 'e'.padStart(64, '0');
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: photoId,
|
||||||
|
pubkey: SOCIAL_PK_A,
|
||||||
|
kind: 360,
|
||||||
|
created_at: 1000,
|
||||||
|
tags: [
|
||||||
|
['i', 'osm:node:1'],
|
||||||
|
['imeta', 'url https://x.com/photo.jpg', 'dim 800x600'],
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
sig: 'sig',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'd'.padStart(64, '0'),
|
||||||
|
pubkey: SOCIAL_PK_A,
|
||||||
|
kind: 5,
|
||||||
|
created_at: 5000,
|
||||||
|
tags: [['e', photoId]],
|
||||||
|
content: '',
|
||||||
|
sig: 'sig',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = groupSocialPhotos(events);
|
||||||
|
assert.strictEqual(entries.length, 0, 'deleted photo is filtered out');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters out entries with no usable photos (malformed imeta)', function (assert) {
|
||||||
|
const event = makeSocialPhotoEvent({ idx: 1, author: SOCIAL_PK_A });
|
||||||
|
event.tags = [
|
||||||
|
['i', 'osm:node:1'],
|
||||||
|
['imeta', 'dim 800x600'], // no url
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = groupSocialPhotos([event]);
|
||||||
|
assert.strictEqual(entries.length, 0, 'malformed imeta → filtered out');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('photo is set to first photo in the group', function (assert) {
|
||||||
|
const events = [
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 1,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
url: 'https://x.com/a.jpg',
|
||||||
|
created_at: 1000,
|
||||||
|
}),
|
||||||
|
makeSocialPhotoEvent({
|
||||||
|
idx: 2,
|
||||||
|
author: SOCIAL_PK_A,
|
||||||
|
url: 'https://x.com/b.jpg',
|
||||||
|
created_at: 2000,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = groupSocialPhotos(events);
|
||||||
|
assert.ok(entries[0].photo, 'photo is set');
|
||||||
|
assert.strictEqual(
|
||||||
|
entries[0].photo.url,
|
||||||
|
'https://x.com/a.jpg',
|
||||||
|
'first photo (oldest) is the thumbnail'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,12 +55,34 @@ module('Unit | Utility | format-text', function () {
|
|||||||
|
|
||||||
test('formatRelativeDate returns an absolute date for timestamps older than a week', function (assert) {
|
test('formatRelativeDate returns an absolute date for timestamps older than a week', function (assert) {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
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);
|
const result = formatRelativeDate(oldTimestamp);
|
||||||
// The exact format depends on the locale, but it should contain a year
|
// The exact format depends on the locale, but it should contain a year
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.includes(String(new Date().getFullYear())),
|
result.includes(String(new Date().getFullYear() - 1)),
|
||||||
'Contains the year'
|
'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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
mergeRequiredRelays,
|
mergeRequiredRelays,
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
parsePlacePhotos,
|
parsePlacePhotos,
|
||||||
|
relayUrlMatches,
|
||||||
uniqNormalizedRelays,
|
uniqNormalizedRelays,
|
||||||
} from 'marco/utils/nostr';
|
} from 'marco/utils/nostr';
|
||||||
|
|
||||||
@@ -324,4 +325,44 @@ module('Unit | Utility | nostr', function () {
|
|||||||
'wss://custom.example.com',
|
'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'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -38,6 +38,11 @@ module('Unit | Utility | osm-icons', function () {
|
|||||||
assert.strictEqual(result, null);
|
assert.strictEqual(result, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it returns person-sleeping-in-bed for leisure=resort', function (assert) {
|
||||||
|
let result = getIconNameForTags({ leisure: 'resort' });
|
||||||
|
assert.strictEqual(result, 'person-sleeping-in-bed');
|
||||||
|
});
|
||||||
|
|
||||||
test('all icons used in POI_ICON_RULES exist in the icons utility', function (assert) {
|
test('all icons used in POI_ICON_RULES exist in the icons utility', function (assert) {
|
||||||
for (let rule of POI_ICON_RULES) {
|
for (let rule of POI_ICON_RULES) {
|
||||||
let icon = getIcon(rule.icon);
|
let icon = getIcon(rule.icon);
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { parsePaymentMethods } from 'marco/utils/payment';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
|
module('Unit | Utility | payment', function () {
|
||||||
|
test('it returns hasPaymentInfo: false for empty or non-payment tags', function (assert) {
|
||||||
|
assert.deepEqual(parsePaymentMethods(undefined), {
|
||||||
|
hasPaymentInfo: false,
|
||||||
|
cash: null,
|
||||||
|
cards: null,
|
||||||
|
bitcoin: { status: null, lightning: false, onchain: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(parsePaymentMethods({}), {
|
||||||
|
hasPaymentInfo: false,
|
||||||
|
cash: null,
|
||||||
|
cards: null,
|
||||||
|
bitcoin: { status: null, lightning: false, onchain: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
parsePaymentMethods({ amenity: 'cafe', name: 'Coffee Shop' }),
|
||||||
|
{
|
||||||
|
hasPaymentInfo: false,
|
||||||
|
cash: null,
|
||||||
|
cards: null,
|
||||||
|
bitcoin: { status: null, lightning: false, onchain: false },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
module('cash', function () {
|
||||||
|
test('it identifies cash as accepted', function (assert) {
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:cash': 'yes' }).cash,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:cash': 'only' }).cash,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:coins': 'yes' }).cash,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:notes': 'yes' }).cash,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it identifies cash as denied', function (assert) {
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:cash': 'no' }).cash,
|
||||||
|
'denied'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:coins': 'no', 'payment:notes': 'no' })
|
||||||
|
.cash,
|
||||||
|
'denied'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it keeps cash as null when cash tags are absent', function (assert) {
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:cards': 'yes' }).cash,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module('cards', function () {
|
||||||
|
test('it identifies cards as accepted from generic or card tags', function (assert) {
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:cards': 'yes' }).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:credit_cards': 'yes' }).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:debit_cards': 'yes' }).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:contactless': 'yes' }).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:visa': 'yes' }).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:mastercard': 'yes' }).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:american_express': 'yes' }).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:girocard': 'yes' }).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:apple_pay': 'yes' }).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:google_pay': 'yes' }).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it identifies cards as denied when explicitly disabled', function (assert) {
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:cards': 'no' }).cards,
|
||||||
|
'denied'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({
|
||||||
|
'payment:credit_cards': 'no',
|
||||||
|
'payment:debit_cards': 'no',
|
||||||
|
}).cards,
|
||||||
|
'denied'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:cash': 'only' }).cards,
|
||||||
|
'denied'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it accepts cards if debit is accepted even if credit_cards is no', function (assert) {
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({
|
||||||
|
'payment:credit_cards': 'no',
|
||||||
|
'payment:debit_cards': 'yes',
|
||||||
|
}).cards,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module('bitcoin', function () {
|
||||||
|
test('it identifies on-chain bitcoin acceptance', function (assert) {
|
||||||
|
const result = parsePaymentMethods({ 'currency:XBT': 'yes' });
|
||||||
|
assert.strictEqual(result.bitcoin.status, 'accepted');
|
||||||
|
assert.true(result.bitcoin.onchain);
|
||||||
|
assert.false(result.bitcoin.lightning);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it identifies bitcoin from payment:onchain tag', function (assert) {
|
||||||
|
const result = parsePaymentMethods({ 'payment:onchain': 'yes' });
|
||||||
|
assert.strictEqual(result.bitcoin.status, 'accepted');
|
||||||
|
assert.true(result.bitcoin.onchain);
|
||||||
|
assert.false(result.bitcoin.lightning);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it identifies bitcoin with lightning', function (assert) {
|
||||||
|
const result = parsePaymentMethods({
|
||||||
|
'currency:XBT': 'yes',
|
||||||
|
'payment:lightning': 'yes',
|
||||||
|
'payment:onchain': 'no',
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.bitcoin.status, 'accepted');
|
||||||
|
assert.true(result.bitcoin.lightning);
|
||||||
|
assert.false(result.bitcoin.onchain);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it identifies bitcoin with both lightning and on-chain', function (assert) {
|
||||||
|
const result = parsePaymentMethods({
|
||||||
|
'currency:XBT': 'yes',
|
||||||
|
'payment:lightning': 'yes',
|
||||||
|
'payment:onchain': 'yes',
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.bitcoin.status, 'accepted');
|
||||||
|
assert.true(result.bitcoin.lightning);
|
||||||
|
assert.true(result.bitcoin.onchain);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it supports fallback bitcoin tags', function (assert) {
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:bitcoin': 'yes' }).bitcoin.status,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'currency:BTC': 'yes' }).bitcoin.status,
|
||||||
|
'accepted'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it identifies bitcoin as denied when explicitly no', function (assert) {
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'currency:XBT': 'no' }).bitcoin.status,
|
||||||
|
'denied'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
parsePaymentMethods({ 'payment:bitcoin': 'no' }).bitcoin.status,
|
||||||
|
'denied'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles mixed tags and sets hasPaymentInfo to true', function (assert) {
|
||||||
|
const result = parsePaymentMethods({
|
||||||
|
'payment:cash': 'yes',
|
||||||
|
'payment:cards': 'no',
|
||||||
|
'currency:XBT': 'yes',
|
||||||
|
'payment:lightning': 'yes',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.true(result.hasPaymentInfo);
|
||||||
|
assert.strictEqual(result.cash, 'accepted');
|
||||||
|
assert.strictEqual(result.cards, 'denied');
|
||||||
|
assert.strictEqual(result.bitcoin.status, 'accepted');
|
||||||
|
assert.true(result.bitcoin.lightning);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,4 +35,11 @@ module('Unit | Utility | poi-category-matcher', function () {
|
|||||||
|
|
||||||
assert.ok(categoryIds.includes('things-to-do'));
|
assert.ok(categoryIds.includes('things-to-do'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('leisure=resort matches accommodation category', function (assert) {
|
||||||
|
const tags = { leisure: 'resort' };
|
||||||
|
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||||
|
|
||||||
|
assert.ok(categoryIds.includes('accommodation'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user