Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b0b52a58
|
||
|
|
12728f449b
|
||
|
|
fe5e85b029
|
||
|
|
c6a0b0da5e
|
||
|
|
553faa5edd
|
||
|
|
dda0a9846d
|
||
|
|
4f423c8abc
|
||
|
|
231a08c2f9
|
||
|
|
7fccc56c92
|
||
|
|
878387b9a8
|
||
|
|
0f6bfb7e74
|
||
|
|
faea3d26cd
|
@@ -7,6 +7,7 @@ import { service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import { encodePointer } from 'applesauce-core/helpers/pointers';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import config from 'marco/config/environment';
|
||||
import DropdownMenu from './dropdown-menu';
|
||||
@@ -15,6 +16,8 @@ import ZapPhotoModal from './zap-photo-modal';
|
||||
import Icon from './icon';
|
||||
import formatRelativeDate from '../helpers/format-relative-date';
|
||||
|
||||
const MAX_NEVENT_RELAY_HINTS = 3;
|
||||
|
||||
const GalleryContent = <template>
|
||||
<div
|
||||
class="photo-gallery-overlay"
|
||||
@@ -236,8 +239,23 @@ export default class PhotoGallery extends Component {
|
||||
@action
|
||||
async copyEventId(closeMenu) {
|
||||
if (this.currentPhoto?.eventId) {
|
||||
let value = this.currentPhoto.eventId;
|
||||
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');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy event ID:', err);
|
||||
@@ -290,7 +308,14 @@ export default class PhotoGallery extends Component {
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.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
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
@@ -211,7 +211,14 @@ export default class PlacePhotoUpload extends Component {
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.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.toast.show('Photo published successfully');
|
||||
|
||||
@@ -339,6 +339,33 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
*
|
||||
|
||||
@@ -8,18 +8,11 @@ export default class NostrRelayService extends Service {
|
||||
if (!relays || relays.length === 0) {
|
||||
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[]>
|
||||
// and automatically handles reconnecting and retrying.
|
||||
const responses = await this.pool.publish(relays, event);
|
||||
|
||||
// Check if at least one relay accepted the event
|
||||
const success = responses.some((res) => res.ok);
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
`Failed to publish event. Responses: ${JSON.stringify(responses)}`
|
||||
);
|
||||
}
|
||||
|
||||
return responses;
|
||||
// The publish method is a wrapper around the event method that returns a
|
||||
// Promise<PublishResponse[]> and automatically handles reconnecting and
|
||||
// retrying. It resolves with the per-relay responses even when no relay
|
||||
// accepted the event; callers are responsible for checking `ok` so that
|
||||
// failed attempts can still be recorded.
|
||||
return await this.pool.publish(relays, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ import anchor from '@waysidemapping/pinhead/dist/icons/anchor.svg?raw';
|
||||
import airportTerminal from '@waysidemapping/pinhead/dist/icons/airport_terminal.svg?raw';
|
||||
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||
import barrelWithBunghole from '@waysidemapping/pinhead/dist/icons/barrel_with_bunghole.svg?raw';
|
||||
import 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 banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
|
||||
import banknoteInSlot from '@waysidemapping/pinhead/dist/icons/banknote_in_slot.svg?raw';
|
||||
@@ -95,7 +97,9 @@ import fish from '@waysidemapping/pinhead/dist/icons/fish.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 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 gableRoofedBuilding from '@waysidemapping/pinhead/dist/icons/gable_roofed_building.svg?raw';
|
||||
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
||||
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
|
||||
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||
@@ -139,6 +143,8 @@ import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?r
|
||||
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
|
||||
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.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 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';
|
||||
@@ -164,6 +170,8 @@ const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
barbell,
|
||||
'barrel-with-bunghole': barrelWithBunghole,
|
||||
bicycle,
|
||||
bulldozer,
|
||||
banknote,
|
||||
'banknote-in-slot': banknoteInSlot,
|
||||
'badge-shield-with-fire': badgeShieldWithFire,
|
||||
@@ -216,6 +224,8 @@ const ICONS = {
|
||||
'flower-bouquet': flowerBouquet,
|
||||
'fork-and-knife': forkAndKnife,
|
||||
fort,
|
||||
fortress,
|
||||
'gable-roofed-building': gableRoofedBuilding,
|
||||
'gasoline-pump': gasolinePump,
|
||||
gift,
|
||||
globe,
|
||||
@@ -284,6 +294,8 @@ const ICONS = {
|
||||
target,
|
||||
'trash-2': trash2,
|
||||
'upload-cloud': uploadCloud,
|
||||
'town-buildings': townBuildings,
|
||||
'transit-vehicle-on-railway-track': transitVehicleOnRailwayTrack,
|
||||
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||
user,
|
||||
'user-check': userCheck,
|
||||
|
||||
+27
-13
@@ -99,18 +99,9 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
|
||||
{ tags: { leisure: 'marina' }, icon: 'anchor' },
|
||||
{ tags: { landuse: 'vineyard' }, icon: 'grapes' },
|
||||
|
||||
// Transport
|
||||
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'terminal' }, icon: 'airport-terminal' },
|
||||
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
|
||||
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
|
||||
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
|
||||
{ tags: { bus: true }, icon: 'bus' },
|
||||
{
|
||||
tags: { railway: 'tram_stop' },
|
||||
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
|
||||
},
|
||||
{ tags: { landuse: 'cemetery' }, icon: 'memorial-stone-with-inscription' },
|
||||
{ tags: { landuse: 'construction' }, icon: 'bulldozer' },
|
||||
{ tags: { landuse: 'residential' }, icon: 'town-buildings' },
|
||||
|
||||
// Tourism
|
||||
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
|
||||
@@ -131,9 +122,11 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { historic: 'canal' }, icon: 'winding_way_wide' },
|
||||
{ tags: { historic: 'bridge' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'bridge_site' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'aqueduct' }, icon: 'bridge' },
|
||||
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||
{ tags: { historic: 'city_gate' }, icon: 'city-gate' },
|
||||
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||
{ tags: { historic: 'monastery' }, icon: 'fortress' },
|
||||
{ tags: { building: 'tower', historic: 'yes' }, icon: 'castle-keep' },
|
||||
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||
@@ -153,7 +146,23 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
||||
{ tags: { historic: 'ruins' }, 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
|
||||
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
|
||||
@@ -190,6 +199,7 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
|
||||
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
|
||||
{ tags: { landuse: 'recreation_ground' }, icon: 'person-running' },
|
||||
{ tags: { sport: true }, icon: 'person-running' },
|
||||
|
||||
// Healthcare
|
||||
@@ -204,6 +214,10 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { building: 'commercial' }, icon: 'commercial-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' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
```
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.34.1",
|
||||
"version": "1.34.3",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -42,7 +42,7 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-B0dlspz_.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-CZdZiL1p.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -62,6 +62,12 @@ export class MockNostrDataService extends Service {
|
||||
return this.profiles[pubkey];
|
||||
}
|
||||
|
||||
getEventRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
recordPublishResult() {}
|
||||
|
||||
refreshProfiles() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -109,14 +115,14 @@ export class MockNostrDataService extends Service {
|
||||
|
||||
export class MockNostrRelayService extends Service {
|
||||
pool = {
|
||||
publish: () => Promise.resolve([{ ok: true }]),
|
||||
publish: () => Promise.resolve([{ ok: true, from: 'wss://relay.test' }]),
|
||||
subscribe: () => {},
|
||||
unsubscribe: () => {},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
async publish() {
|
||||
return [{ ok: true }];
|
||||
return [{ ok: true, from: 'wss://relay.test' }];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
|
||||
import Service from '@ember/service';
|
||||
import { decodePointer } from 'applesauce-core/helpers/pointers';
|
||||
import PhotoGallery from 'marco/components/photo-gallery';
|
||||
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
|
||||
import sinon from 'sinon';
|
||||
@@ -163,8 +164,11 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
const confirmStub = sinon.stub(window, 'confirm').returns(true);
|
||||
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 recordPublishSpy = sinon.spy(this.nostrData, 'recordPublishResult');
|
||||
const toastSpy = sinon.spy(this.toast, 'show');
|
||||
|
||||
await render(
|
||||
@@ -224,6 +228,17 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
'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
|
||||
assert.ok(
|
||||
toastSpy.calledWith('Photo deleted successfully'),
|
||||
@@ -261,7 +276,9 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
sinon.stub(window, 'confirm').returns(true);
|
||||
sinon.stub(this.blossom, 'delete').resolves();
|
||||
sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||
sinon
|
||||
.stub(this.nostrRelay, 'publish')
|
||||
.resolves([{ ok: true, from: 'wss://relay.test' }]);
|
||||
sinon.stub(this.nostrData.store, 'add');
|
||||
|
||||
await render(
|
||||
@@ -284,9 +301,27 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
});
|
||||
|
||||
test('it copies event id to clipboard', async function (assert) {
|
||||
const eventId = '1'.repeat(64);
|
||||
this.nostrAuth.pubkey = USER_A;
|
||||
this.photos = [
|
||||
{
|
||||
eventId,
|
||||
pubkey: USER_A,
|
||||
placeIdentifier: 'osm:node:12345',
|
||||
url: 'photo.jpg',
|
||||
},
|
||||
];
|
||||
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
|
||||
.stub(navigator.clipboard, 'writeText')
|
||||
.resolves();
|
||||
@@ -317,7 +352,23 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
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(
|
||||
toastSpy.calledWith('Event ID copied to clipboard'),
|
||||
'success toast was shown'
|
||||
|
||||
@@ -498,6 +498,56 @@ module('Unit | Service | nostr-data | provenance', function (hooks) {
|
||||
);
|
||||
assert.true(service.store.hasEvent(eventId), 'event added to store');
|
||||
});
|
||||
|
||||
test('getEventRelays returns normalized relays for an event', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(904);
|
||||
|
||||
service._recordProvenance(eventId, 'wss://one.example/');
|
||||
service._recordProvenance(eventId, 'WSS://Two.Example');
|
||||
|
||||
assert.deepEqual(
|
||||
service.getEventRelays(eventId).sort(),
|
||||
['wss://one.example', 'wss://two.example'],
|
||||
'returns normalized, unique relays'
|
||||
);
|
||||
assert.deepEqual(
|
||||
service.getEventRelays('unknown-event'),
|
||||
[],
|
||||
'returns an empty array for unknown events'
|
||||
);
|
||||
});
|
||||
|
||||
test('recordPublishResult records only successful relays into provenance', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
const eventId = makeEventId(905);
|
||||
|
||||
service.recordPublishResult(eventId, [
|
||||
{ ok: true, from: 'wss://accepted.example' },
|
||||
{ ok: false, from: 'wss://rejected.example', message: 'blocked' },
|
||||
]);
|
||||
|
||||
const relays = service._eventRelays.get(eventId);
|
||||
assert.ok(relays, 'provenance recorded');
|
||||
assert.true(
|
||||
relays.has('wss://accepted.example'),
|
||||
'accepted relay is recorded'
|
||||
);
|
||||
assert.false(
|
||||
relays.has('wss://rejected.example'),
|
||||
'rejected relay is not recorded'
|
||||
);
|
||||
|
||||
const persisted = await service.localForage.get(
|
||||
'event-relay-provenance',
|
||||
eventId
|
||||
);
|
||||
assert.deepEqual(
|
||||
persisted,
|
||||
['wss://accepted.example'],
|
||||
'accepted relay is persisted'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | trust evaluation', function (hooks) {
|
||||
|
||||
Reference in New Issue
Block a user