Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
263ecbbad7
|
No files matched your search
@@ -8,11 +8,8 @@
|
||||
<br>
|
||||
|
||||
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 to sync place bookmarks
|
||||
across apps and devices. It also supports the decentralized sharing of place
|
||||
photos (and soon reviews) via [Nostr][2].
|
||||
your privacy and choices. It allows users to connect their own remote storage
|
||||
to sync place bookmarks across apps and devices.
|
||||
|
||||
This README outlines the details of collaborating on this Ember application, or
|
||||
just building and deploying it for yourself.
|
||||
@@ -82,4 +79,3 @@ To run the script, you need `imagemagick` and `librsvg` installed:
|
||||
- [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
|
||||
|
||||
[1]: https://en.wikipedia.org/wiki/Marco_Polo
|
||||
[2]: https://nostr.org
|
||||
@@ -1,132 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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 { and } from 'ember-truth-helpers';
|
||||
import cachedImage from '../modifiers/cached-image';
|
||||
import { getCategoryById } from '../utils/poi-categories';
|
||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@@ -43,7 +43,7 @@ export default class AppHeaderComponent extends Component {
|
||||
if (qp?.q) {
|
||||
this.searchQuery = qp.q;
|
||||
} else if (qp?.category) {
|
||||
const category = getCategoryById(qp.category);
|
||||
const category = POI_CATEGORIES.find((c) => c.id === qp.category);
|
||||
this.searchQuery = category ? category.label : qp.category;
|
||||
} else {
|
||||
this.searchQuery = '';
|
||||
|
||||
@@ -22,18 +22,12 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
|
||||
<li>
|
||||
<button type="button" {{on "click" @onSavedPlaces}}>
|
||||
<Icon @name="bookmark" @size={{20}} />
|
||||
<span>Saved Places</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" @onActivity}}>
|
||||
<Icon @name="activity" @size={{20}} />
|
||||
<span>Activity</span>
|
||||
<span>Collections</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" @onContributions}}>
|
||||
<Icon @name="user-check" @size={{20}} />
|
||||
<Icon @name="activity" @size={{20}} />
|
||||
<span>My Contributions</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -28,11 +28,6 @@ export default class AppMenu extends Component {
|
||||
this.router.transitionTo('contributions');
|
||||
}
|
||||
|
||||
@action
|
||||
goToActivity() {
|
||||
this.router.transitionTo('activity');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar app-menu-pane">
|
||||
{{#if (eq this.currentView "menu")}}
|
||||
@@ -41,7 +36,6 @@ export default class AppMenu extends Component {
|
||||
@onClose={{@onClose}}
|
||||
@onSavedPlaces={{this.goToSavedPlaces}}
|
||||
@onContributions={{this.goToContributions}}
|
||||
@onActivity={{this.goToActivity}}
|
||||
/>
|
||||
|
||||
{{else if (eq this.currentView "settings")}}
|
||||
|
||||
@@ -20,7 +20,6 @@ const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : '');
|
||||
export default class AppMenuSettingsNostr extends Component {
|
||||
@service settings;
|
||||
@service nostrData;
|
||||
@service nostrAuth;
|
||||
@service toast;
|
||||
|
||||
@tracked newReadRelay = '';
|
||||
@@ -368,111 +367,109 @@ export default class AppMenuSettingsNostr extends Component {
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.nostrAuth.isConnected}}
|
||||
<div class="form-group">
|
||||
<label for="new-write-relay">Write Relays</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
|
||||
id="nostr-media-server"
|
||||
<div class="form-group">
|
||||
<label for="new-write-relay">Write Relays</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"
|
||||
{{on "change" (fn @onChange "nostrMediaServer")}}
|
||||
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}}
|
||||
>
|
||||
{{#each this.mediaServerOptions as |server|}}
|
||||
<option
|
||||
value={{server}}
|
||||
selected={{if
|
||||
(eq server this.settings.nostrMediaServer)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{server}}
|
||||
</option>
|
||||
{{/each}}
|
||||
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">
|
||||
<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 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}}
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -38,7 +38,7 @@ export default class ContributionsTimelineComponent extends Component {
|
||||
</button>
|
||||
<h2 class="sidebar-header-text-centered">
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon @name="user-check" @size={{20}} @color="#898989" />
|
||||
<Icon @name="activity" @size={{20}} @color="#898989" />
|
||||
</span>
|
||||
My Contributions
|
||||
</h2>
|
||||
|
||||
+1
-20
@@ -2,16 +2,6 @@ import Component from '@glimmer/component';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
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 {
|
||||
get svg() {
|
||||
return getIcon(this.args.name);
|
||||
@@ -21,21 +11,13 @@ export default class IconComponent extends Component {
|
||||
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() {
|
||||
return this.args.color || '#898989';
|
||||
}
|
||||
|
||||
get style() {
|
||||
return htmlSafe(
|
||||
`width:${formatDimension(this.width)};height:${formatDimension(this.height)};color:${this.color}`
|
||||
`width:${this.size}px;height:${this.size}px;color:${this.color}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +35,6 @@ export default class IconComponent extends Component {
|
||||
class="icon {{if this.isFilled 'icon-filled'}}"
|
||||
style={{this.style}}
|
||||
title={{this.title}}
|
||||
...attributes
|
||||
>
|
||||
{{htmlSafe this.svg}}
|
||||
</span>
|
||||
|
||||
+1
-116
@@ -21,7 +21,6 @@ import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
|
||||
import { apply } from 'ol-mapbox-style';
|
||||
import { getIcon } from '../utils/icons';
|
||||
import { getIconNameForTags } from '../utils/osm-icons';
|
||||
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||
|
||||
export default class MapComponent extends Component {
|
||||
@service osm;
|
||||
@@ -1006,62 +1005,6 @@ 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) {
|
||||
if (!this.mapInstance || !this.crosshairElement) return;
|
||||
|
||||
@@ -1198,7 +1141,6 @@ export default class MapComponent extends Component {
|
||||
});
|
||||
let clickedBookmark = null;
|
||||
let clickedSearchResult = null;
|
||||
let clickedTileOsmFeature = null;
|
||||
let selectedFeatureName = null;
|
||||
|
||||
if (features && features.length > 0) {
|
||||
@@ -1213,35 +1155,6 @@ export default class MapComponent extends Component {
|
||||
clickedBookmark = bookmarkFeature.get('originalPlace');
|
||||
} else if (searchResultFeature) {
|
||||
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
|
||||
const props = features[0].getProperties();
|
||||
@@ -1266,19 +1179,6 @@ export default class MapComponent extends Component {
|
||||
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
|
||||
if (this.args.isSidebarOpen) {
|
||||
// If it's a bookmark or search result, we allow "switching" to it even if sidebar is open
|
||||
@@ -1292,15 +1192,6 @@ export default class MapComponent extends Component {
|
||||
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
|
||||
if (this.args.onOutsideClick) {
|
||||
this.args.onOutsideClick();
|
||||
@@ -1321,12 +1212,6 @@ export default class MapComponent extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickedTileOsmFeature) {
|
||||
console.debug('Clicked vector tile POI:', clickedTileOsmFeature);
|
||||
transitionToExplicitOsmPlace(clickedTileOsmFeature);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mapUi.searchResults && this.mapUi.searchResults.length > 0) {
|
||||
console.debug('Clearing active search and markers on map click');
|
||||
this.router.transitionTo('index');
|
||||
@@ -1370,7 +1255,7 @@ export default class MapComponent extends Component {
|
||||
lat: lat.toFixed(6),
|
||||
lon: lon.toFixed(6),
|
||||
q: null, // Clear q to force spatial search
|
||||
category: NEARBY_CATEGORY.id, // Represent nearby as a first-class category
|
||||
category: null, // Clear category to force spatial search
|
||||
selected: selectedFeatureName || null,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -16,8 +15,6 @@ 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"
|
||||
@@ -55,7 +52,7 @@ const GalleryContent = <template>
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
type="button"
|
||||
{{on "click" (fn @deletePhoto closeMenu)}}
|
||||
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
||||
>Delete Photo</button>
|
||||
{{/if}}
|
||||
</DropdownMenu>
|
||||
@@ -239,23 +236,8 @@ export default class PhotoGallery extends Component {
|
||||
@action
|
||||
async copyEventId(closeMenu) {
|
||||
if (this.currentPhoto?.eventId) {
|
||||
let value = this.currentPhoto.eventId;
|
||||
try {
|
||||
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);
|
||||
await navigator.clipboard.writeText(this.currentPhoto.eventId);
|
||||
this.toast.show('Event ID copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy event ID:', err);
|
||||
@@ -277,27 +259,21 @@ export default class PhotoGallery extends Component {
|
||||
this.zapModalOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
deletePhoto(closeMenu, e) {
|
||||
e?.stopPropagation();
|
||||
if (closeMenu) closeMenu();
|
||||
deletePhotoTask = task(async (closeMenu) => {
|
||||
if (
|
||||
!confirm(
|
||||
'Are you sure you want to delete this photo? This cannot be undone.'
|
||||
)
|
||||
) {
|
||||
if (closeMenu) closeMenu();
|
||||
return;
|
||||
}
|
||||
this.deletePhotoTask.perform();
|
||||
}
|
||||
|
||||
deletePhotoTask = task(async () => {
|
||||
const deletedEventId = this.currentPhoto.eventId;
|
||||
const deletedIndex = this.args.photos?.indexOf(this.currentPhoto) ?? -1;
|
||||
|
||||
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
|
||||
const tags = [['e', deletedEventId]];
|
||||
const tags = [['e', eventId]];
|
||||
|
||||
if (this.currentPhoto.placeIdentifier) {
|
||||
tags.push(['i', this.currentPhoto.placeIdentifier]);
|
||||
@@ -308,14 +284,7 @@ export default class PhotoGallery extends Component {
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
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.');
|
||||
}
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
|
||||
// Remove from local store by adding the kind 5 to it
|
||||
this.nostrData.store.add(event);
|
||||
@@ -347,20 +316,12 @@ export default class PhotoGallery extends Component {
|
||||
|
||||
this.toast.show('Photo deleted successfully');
|
||||
|
||||
// Navigate to the next remaining photo, or close the gallery if none left.
|
||||
// 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)];
|
||||
}
|
||||
if (closeMenu) closeMenu();
|
||||
this.handleClose();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete photo:', e);
|
||||
this.toast.show('Failed to delete photo: ' + e.message);
|
||||
if (closeMenu) closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -372,7 +333,7 @@ export default class PhotoGallery extends Component {
|
||||
@handleKeydown={{this.handleKeydown}}
|
||||
@copyEventId={{this.copyEventId}}
|
||||
@canDeletePhoto={{this.canDeletePhoto}}
|
||||
@deletePhoto={{this.deletePhoto}}
|
||||
@deletePhotoTask={{this.deletePhotoTask}}
|
||||
@handleClose={{this.handleClose}}
|
||||
@photos={{@photos}}
|
||||
@currentPhoto={{this.currentPhoto}}
|
||||
@@ -395,7 +356,7 @@ export default class PhotoGallery extends Component {
|
||||
@handleKeydown={{this.handleKeydown}}
|
||||
@copyEventId={{this.copyEventId}}
|
||||
@canDeletePhoto={{this.canDeletePhoto}}
|
||||
@deletePhoto={{this.deletePhoto}}
|
||||
@deletePhotoTask={{this.deletePhotoTask}}
|
||||
@handleClose={{this.handleClose}}
|
||||
@photos={{@photos}}
|
||||
@currentPhoto={{this.currentPhoto}}
|
||||
|
||||
@@ -15,7 +15,6 @@ import NostrConnect from './nostr-connect';
|
||||
import Modal from './modal';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
import PhotoGallery from './photo-gallery';
|
||||
import PlacePaymentMethods from './place-payment-methods';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
@@ -316,70 +315,6 @@ export default class PlaceDetails extends Component {
|
||||
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() {
|
||||
if (!this.tags.cuisine) return null;
|
||||
return this.tags.cuisine
|
||||
@@ -532,8 +467,6 @@ export default class PlaceDetails extends Component {
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<PlacePaymentMethods @tags={{this.tags}} />
|
||||
|
||||
<div class="meta-info">
|
||||
|
||||
{{#if this.cuisine}}
|
||||
@@ -635,45 +568,6 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{/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 class="meta-info">
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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,14 +211,7 @@ export default class PlacePhotoUpload extends Component {
|
||||
.modifyPublicTags(() => tags)
|
||||
.as(this.nostrAuth.signer)
|
||||
.sign();
|
||||
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.');
|
||||
}
|
||||
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
|
||||
this.nostrData.store.add(event);
|
||||
|
||||
this.toast.show('Photo published successfully');
|
||||
|
||||
@@ -11,7 +11,6 @@ import PlaceDetails from './place-details';
|
||||
import Icon from './icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||
import restoreScroll from '../modifiers/restore-scroll';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@@ -151,11 +150,7 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
get isNearbySearch() {
|
||||
const qp = this.router.currentRoute.queryParams;
|
||||
// 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)
|
||||
);
|
||||
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||
}
|
||||
|
||||
get hasHeaderPhoto() {
|
||||
|
||||
@@ -270,7 +270,6 @@ export default class SearchBoxComponent extends Component {
|
||||
(or
|
||||
(eq this.mapUi.loadingState.type "text")
|
||||
(eq this.mapUi.loadingState.type "category")
|
||||
(eq this.mapUi.loadingState.type "nearby")
|
||||
)
|
||||
}}
|
||||
<Icon @name="loading-ring" @size={{20}} />
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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>
|
||||
@@ -1,80 +0,0 @@
|
||||
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,8 +14,6 @@ function getPlaceTime(place) {
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
const SAVED_LIST_ID = 'saved';
|
||||
|
||||
export default class ListsListController extends Controller {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@@ -23,34 +21,18 @@ export default class ListsListController extends Controller {
|
||||
|
||||
@tracked model;
|
||||
@tracked loadedPlaces = [];
|
||||
@tracked loadedListId = null;
|
||||
|
||||
get listId() {
|
||||
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) => {
|
||||
// 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
|
||||
try {
|
||||
this.loadedPlaces = await this.storage.getPlacesInList(listId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load places in list', listId, e);
|
||||
this.loadedPlaces = [];
|
||||
this.loadedListId = null; // Allow a retry on next entry
|
||||
}
|
||||
});
|
||||
|
||||
@@ -59,12 +41,6 @@ export default class ListsListController extends Controller {
|
||||
}
|
||||
|
||||
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);
|
||||
if (list && list.color) {
|
||||
return list.color;
|
||||
@@ -75,21 +51,11 @@ export default class ListsListController extends Controller {
|
||||
}
|
||||
|
||||
get listTitle() {
|
||||
if (this.listId === SAVED_LIST_ID) {
|
||||
return 'Saved Places';
|
||||
}
|
||||
|
||||
const list = this.storage.lists.find((l) => l.id === this.listId);
|
||||
return list ? list.title : 'Collections';
|
||||
}
|
||||
|
||||
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 placeRefsIds = new Set(
|
||||
currentList?.placeRefs?.map((ref) => ref.id) || []
|
||||
|
||||
+40
-64
@@ -2,7 +2,6 @@ import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { getDistance } from '../utils/geo';
|
||||
import { NEARBY_CATEGORY } from '../utils/poi-categories';
|
||||
|
||||
export default class SearchController extends Controller {
|
||||
@service osm;
|
||||
@@ -20,40 +19,6 @@ export default class SearchController extends Controller {
|
||||
selected = 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) => {
|
||||
// 1. Check if the incoming parameters match our currently loaded search
|
||||
const isSameSearch =
|
||||
@@ -85,22 +50,8 @@ export default class SearchController extends Controller {
|
||||
let loadingValue = null;
|
||||
|
||||
try {
|
||||
// Case 0: Nearby Search (map click or ?category=nearby)
|
||||
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) {
|
||||
// Case 0: Category Search (category parameter present)
|
||||
if (params.category && lat && lon) {
|
||||
loadingType = 'category';
|
||||
loadingValue = params.category;
|
||||
this.mapUi.startLoading(loadingType, loadingValue);
|
||||
@@ -137,7 +88,7 @@ export default class SearchController extends Controller {
|
||||
}))
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
// Case 2: Text Search (q parameter present)
|
||||
// Case 1: Text Search (q parameter present)
|
||||
else if (params.q) {
|
||||
loadingType = 'text';
|
||||
loadingValue = params.q;
|
||||
@@ -170,6 +121,43 @@ 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) {
|
||||
console.error('Search request failed.', error);
|
||||
this.toast.show('Search request failed. Please try again.');
|
||||
@@ -188,18 +176,6 @@ export default class SearchController extends Controller {
|
||||
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;
|
||||
|
||||
if (targetName && pois.length > 0) {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 872 B |
@@ -1,31 +0,0 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -16,17 +16,30 @@ export default modifier((element) => {
|
||||
|
||||
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;
|
||||
const arrowSize = 6;
|
||||
|
||||
// Vertical placement: above trigger, flip below if no room
|
||||
let placement = 'top';
|
||||
let top = rect.top - tipRect.height - arrowSize;
|
||||
if (top < arrowSize) {
|
||||
placement = 'bottom';
|
||||
top = rect.bottom + arrowSize;
|
||||
}
|
||||
|
||||
// Horizontal: center on trigger, clamp to viewport
|
||||
let left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||||
left = Math.max(
|
||||
margin,
|
||||
Math.min(left, window.innerWidth - tipRect.width - margin)
|
||||
arrowSize,
|
||||
Math.min(left, window.innerWidth - tipRect.width - arrowSize)
|
||||
);
|
||||
|
||||
// Arrow always points at trigger center, even when clamped
|
||||
const arrowLeft = rect.left + rect.width / 2 - left;
|
||||
|
||||
tooltipEl.dataset.placement = placement;
|
||||
tooltipEl.style.top = `${top}px`;
|
||||
tooltipEl.style.left = `${left}px`;
|
||||
tooltipEl.style.setProperty('--arrow-left', `${arrowLeft}px`);
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ Router.map(function () {
|
||||
this.route('list', { path: '/:list_id' });
|
||||
});
|
||||
this.route('contributions');
|
||||
this.route('activity');
|
||||
this.route('oauth', function () {
|
||||
this.route('osm-callback', { path: '/osm/callback' });
|
||||
});
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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,22 +1,26 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ListsListRoute extends Route {
|
||||
@service storage;
|
||||
|
||||
model(params) {
|
||||
// Resolve instantly so transition happens in 0ms!
|
||||
return { list_id: params.list_id };
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
console.debug('DEBUG: setupController controller is:', controller);
|
||||
console.debug(
|
||||
'DEBUG: controller.loadPlacesTask is:',
|
||||
controller?.loadPlacesTask
|
||||
);
|
||||
controller.model = model;
|
||||
super.setupController(controller, model);
|
||||
if (model.list_id === 'saved') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller && controller.loadPlacesTask) {
|
||||
controller.loadPlacesTask.perform(model.list_id);
|
||||
} else {
|
||||
console.error('controller.loadPlacesTask is undefined');
|
||||
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ export default class SearchRoute extends Route {
|
||||
|
||||
// Trigger the background task to fetch results
|
||||
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) {
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
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,6 +2,8 @@ import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { groupPhotoContributions } from '../utils/contributions';
|
||||
|
||||
const NAME_CACHE_STORE = 'contributions-name-cache';
|
||||
|
||||
/**
|
||||
* Orchestrates loading the user's own Nostr contributions, grouping them into
|
||||
* timeline entries, and resolving place names from bookmarks or the OSM API.
|
||||
@@ -23,11 +25,77 @@ import { groupPhotoContributions } from '../utils/contributions';
|
||||
export default class ContributionsService extends Service {
|
||||
@service nostrData;
|
||||
@service nostrAuth;
|
||||
@service placeNameResolver;
|
||||
@service storage;
|
||||
@service osm;
|
||||
@service localForage;
|
||||
|
||||
@tracked items = [];
|
||||
|
||||
_sub = null;
|
||||
_pendingBatchPromise = null;
|
||||
_lastBatchSignature = '';
|
||||
_unresolvable = new Set();
|
||||
|
||||
/**
|
||||
* Async name resolution. Checks, in order:
|
||||
* 1. Bookmarks (sync, instant).
|
||||
* 2. The persistent IndexedDB name cache (per-entry key).
|
||||
* 3. The OSM service's IndexedDB cache (from place detail visits).
|
||||
*
|
||||
* @param {object} entry A contribution entry with `osmType`, `osmId`, and `placeIdentifier`.
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async _resolveCachedName(entry) {
|
||||
// 1. Try bookmarks (instant)
|
||||
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||
if (bookmark?.title) return bookmark.title;
|
||||
|
||||
// 2. Try the persistent name cache (IndexedDB, per-entry, survives sessions)
|
||||
const cachedName = await this.localForage.get(
|
||||
NAME_CACHE_STORE,
|
||||
entry.placeIdentifier
|
||||
);
|
||||
if (cachedName) return cachedName;
|
||||
|
||||
// 3. Try OSM IndexedDB cache (from place detail visits)
|
||||
const cached = await this.osm.getCachedOsmObject(
|
||||
entry.osmType,
|
||||
entry.osmId
|
||||
);
|
||||
return cached?.title || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously resolves names for still-loading entries from the caches.
|
||||
* Falls through to `_maybeBatchFetchNames` for anything that remains
|
||||
* unresolved. Fire-and-forget from `_updateItems` so the list renders
|
||||
* immediately with bookmark-resolved names.
|
||||
*
|
||||
* @param {Array} entries
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _resolveFromCache(entries) {
|
||||
const pending = entries.filter((e) => e.placeNameLoading);
|
||||
if (pending.length === 0) {
|
||||
this._maybeBatchFetchNames(this.items);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
pending.map(async (entry) => {
|
||||
const name = await this._resolveCachedName(entry);
|
||||
if (name) {
|
||||
entry.placeName = name;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Re-render with whatever resolved, then kick off the network batch for
|
||||
// entries that are still loading.
|
||||
this.items = [...this.items];
|
||||
this._maybeBatchFetchNames(this.items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the user's contributions and subscribes to live updates.
|
||||
@@ -63,7 +131,9 @@ export default class ContributionsService extends Service {
|
||||
this._sub = null;
|
||||
}
|
||||
this.items = [];
|
||||
this.placeNameResolver.reset();
|
||||
this._lastBatchSignature = '';
|
||||
this._pendingBatchPromise = null;
|
||||
this._unresolvable.clear();
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
@@ -78,9 +148,9 @@ export default class ContributionsService extends Service {
|
||||
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
||||
// first render shows bookmarked place names without a "Loading…" flicker.
|
||||
for (const entry of entries) {
|
||||
const bookmarkName = this.placeNameResolver.resolveBookmark(entry.osmId);
|
||||
if (bookmarkName) {
|
||||
entry.placeName = bookmarkName;
|
||||
const bookmark = this.storage.findPlaceById(entry.osmId);
|
||||
if (bookmark?.title) {
|
||||
entry.placeName = bookmark.title;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -89,8 +159,121 @@ export default class ContributionsService extends Service {
|
||||
|
||||
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
|
||||
// cache, then fall through to the network batch for the rest.
|
||||
void this.placeNameResolver.resolveInBackground(entries).then(() => {
|
||||
this.items = [...this.items];
|
||||
});
|
||||
void this._resolveFromCache(entries);
|
||||
}
|
||||
|
||||
_isUnresolvable(entry) {
|
||||
return this._unresolvable.has(entry.placeIdentifier);
|
||||
}
|
||||
|
||||
_maybeBatchFetchNames(entries) {
|
||||
const unresolved = entries.filter(
|
||||
(e) => e.placeNameLoading && !this._isUnresolvable(e)
|
||||
);
|
||||
|
||||
// Apply fallbacks for items already marked unresolvable in this session
|
||||
for (const entry of entries) {
|
||||
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
|
||||
entry.placeName = this._fallbackName(entry);
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolved.length === 0) {
|
||||
if (this.items.some((i) => !i.placeNameLoading)) {
|
||||
this.items = [...this.items];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a stable signature so we only fire one batch request per unique set
|
||||
const signature = unresolved
|
||||
.map((e) => e.placeIdentifier)
|
||||
.sort()
|
||||
.join('|');
|
||||
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
|
||||
return;
|
||||
}
|
||||
this._lastBatchSignature = signature;
|
||||
|
||||
this._pendingBatchPromise = this._batchResolveNames(unresolved)
|
||||
.then((nameMap) => {
|
||||
// Merge resolved names back into the current `items`.
|
||||
for (const item of this.items) {
|
||||
if (!item.placeNameLoading) continue;
|
||||
if (nameMap.has(item.placeIdentifier)) {
|
||||
const name = nameMap.get(item.placeIdentifier);
|
||||
item.placeName = name;
|
||||
item.placeNameLoading = false;
|
||||
} else {
|
||||
// Could not be resolved (deleted object, network error for this item).
|
||||
// Mark as unresolvable for this session and use a fallback.
|
||||
this._unresolvable.add(item.placeIdentifier);
|
||||
item.placeName = this._fallbackName(item);
|
||||
item.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
// Trigger a re-render
|
||||
this.items = [...this.items];
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[contributions] Batch name resolution failed', e);
|
||||
// On a total failure, mark all as unresolvable and apply fallbacks
|
||||
for (const item of this.items) {
|
||||
if (item.placeNameLoading) {
|
||||
this._unresolvable.add(item.placeIdentifier);
|
||||
item.placeName = this._fallbackName(item);
|
||||
item.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
this.items = [...this.items];
|
||||
})
|
||||
.finally(() => {
|
||||
this._pendingBatchPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
_fallbackName(item) {
|
||||
return `OSM ${item.osmType} ${item.osmId}`;
|
||||
}
|
||||
|
||||
async _batchResolveNames(entries) {
|
||||
const nameMap = new Map();
|
||||
const toFetch = [];
|
||||
|
||||
// Re-check cache in case it was populated between the trigger and now
|
||||
for (const entry of entries) {
|
||||
const cached = await this._resolveCachedName(entry);
|
||||
if (cached) {
|
||||
nameMap.set(entry.placeIdentifier, cached);
|
||||
} else {
|
||||
toFetch.push({ osmType: entry.osmType, osmId: entry.osmId });
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.length === 0) return nameMap;
|
||||
|
||||
const places = await this.osm.fetchOsmObjectsBatch(toFetch);
|
||||
|
||||
// Persist each newly-resolved name to the IndexedDB name cache as its own
|
||||
// entry (per-placeIdentifier key) so we don't re-fetch next session.
|
||||
const writePromises = [];
|
||||
for (const entry of entries) {
|
||||
if (nameMap.has(entry.placeIdentifier)) continue;
|
||||
const cacheKey = `${entry.osmType}:${entry.osmId}`;
|
||||
const place = places.get(cacheKey);
|
||||
if (place?.title) {
|
||||
nameMap.set(entry.placeIdentifier, place.title);
|
||||
writePromises.push(
|
||||
this.localForage.set(
|
||||
NAME_CACHE_STORE,
|
||||
entry.placeIdentifier,
|
||||
place.title
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(writePromises);
|
||||
return nameMap;
|
||||
}
|
||||
}
|
||||
+17
-540
@@ -5,8 +5,7 @@ import { EMPTY, from, timeout, filter, connect, take, takeUntil } from 'rxjs';
|
||||
import { EventStore } from 'applesauce-core/event-store';
|
||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||
import { ContactsModel } from 'applesauce-core/models/contacts';
|
||||
import { isEventPointer, npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
||||
import { RelayGroup } from 'applesauce-relay';
|
||||
@@ -57,7 +56,6 @@ export default class NostrDataService extends Service {
|
||||
|
||||
@tracked profile = null;
|
||||
@tracked mailboxes = null;
|
||||
@tracked contacts = null;
|
||||
@tracked blossomServers = [];
|
||||
@tracked placePhotos = [];
|
||||
@tracked myContributionEvents = [];
|
||||
@@ -72,15 +70,6 @@ export default class NostrDataService extends Service {
|
||||
_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.
|
||||
@@ -89,28 +78,16 @@ export default class NostrDataService extends Service {
|
||||
|
||||
_profileSub = null;
|
||||
_mailboxesSub = null;
|
||||
_contactsSub = null;
|
||||
_blossomSub = null;
|
||||
_photosSub = null;
|
||||
_contributionsSub = null;
|
||||
_deletionsSub = null;
|
||||
_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;
|
||||
_zapReceiptsNetworkSub = null;
|
||||
_zapRefreshTimer = null;
|
||||
_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;
|
||||
_cachePromise = null;
|
||||
@@ -146,11 +123,10 @@ export default class NostrDataService extends Service {
|
||||
this._stopPersisting = persistEventsToCache(
|
||||
this.store,
|
||||
async (events) => {
|
||||
// Only cache profiles, mailboxes, contacts, blossom servers, place photos, and deletions
|
||||
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
|
||||
const toCache = events.filter(
|
||||
(e) =>
|
||||
e.kind === 0 ||
|
||||
e.kind === 3 ||
|
||||
e.kind === 5 ||
|
||||
e.kind === 10002 ||
|
||||
e.kind === 10063 ||
|
||||
@@ -173,17 +149,20 @@ export default class NostrDataService extends Service {
|
||||
// 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);
|
||||
// Centralized kind-5 deletion handling: when a deletion event enters the
|
||||
// store, drop the provenance entries for the events it deletes so the
|
||||
// trust map and IDB don't accumulate dead entries.
|
||||
this._deletionsSub = this.store
|
||||
.timeline([{ kinds: [5] }])
|
||||
.subscribe((events) => {
|
||||
for (const event of events) {
|
||||
for (const tag of event.tags || []) {
|
||||
if (tag[0] === 'e' && tag[1]) {
|
||||
this._removeProvenance(tag[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Feed events from the relay pool into the event store
|
||||
this.nostrRelay.pool.relays$.subscribe(() => {
|
||||
@@ -262,7 +241,6 @@ export default class NostrDataService extends Service {
|
||||
/**
|
||||
* 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) {
|
||||
@@ -270,8 +248,6 @@ export default class NostrDataService extends Service {
|
||||
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;
|
||||
@@ -339,33 +315,6 @@ 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.
|
||||
*
|
||||
@@ -594,367 +543,11 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const newPubkeys = pubkeys.filter(
|
||||
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||
);
|
||||
|
||||
const retryPubkeys = [];
|
||||
|
||||
for (const pubkey of newPubkeys) {
|
||||
const sub = this.store
|
||||
.model(ProfileModel, pubkey)
|
||||
@@ -962,60 +555,7 @@ export default class NostrDataService extends Service {
|
||||
this.profiles = { ...this.profiles, [pubkey]: profileContent };
|
||||
});
|
||||
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) {
|
||||
@@ -1036,15 +576,8 @@ export default class NostrDataService extends Service {
|
||||
// Reset state
|
||||
this.profile = null;
|
||||
this.mailboxes = null;
|
||||
this.contacts = null;
|
||||
this._contactPubkeys = null;
|
||||
this.blossomServers = [];
|
||||
|
||||
// Create a deferred that resolves when contacts are first loaded
|
||||
this._contactsPromise = new Promise((resolve) => {
|
||||
this._contactsResolver = resolve;
|
||||
});
|
||||
|
||||
this._cleanupSubscriptions();
|
||||
|
||||
// Setup models to track state reactively FIRST
|
||||
@@ -1061,26 +594,6 @@ export default class NostrDataService extends Service {
|
||||
this.mailboxes = mailboxesData;
|
||||
});
|
||||
|
||||
this._contactsSub = this.store
|
||||
.model(ContactsModel, pubkey)
|
||||
.subscribe((contacts) => {
|
||||
this.contacts = contacts;
|
||||
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
|
||||
// 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
|
||||
.replaceable(10063, pubkey)
|
||||
.subscribe((event) => {
|
||||
@@ -1110,7 +623,7 @@ export default class NostrDataService extends Service {
|
||||
const cachedEvents = await this.cache.query([
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 3, 10002, 10063],
|
||||
kinds: [0, 10002, 10063],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1131,7 +644,7 @@ export default class NostrDataService extends Service {
|
||||
.request(profileRelays, [
|
||||
{
|
||||
authors: [pubkey],
|
||||
kinds: [0, 3, 10002, 10063],
|
||||
kinds: [0, 10002, 10063],
|
||||
},
|
||||
])
|
||||
.subscribe({
|
||||
@@ -1144,23 +657,6 @@ 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() {
|
||||
if (this.profile) {
|
||||
if (this.profile.nip05) {
|
||||
@@ -1310,11 +806,6 @@ export default class NostrDataService extends Service {
|
||||
this._mailboxesSub.unsubscribe();
|
||||
this._mailboxesSub = null;
|
||||
}
|
||||
if (this._contactsSub) {
|
||||
this._contactsSub.unsubscribe();
|
||||
this._contactsSub = null;
|
||||
this._contactPubkeys = null;
|
||||
}
|
||||
if (this._blossomSub) {
|
||||
this._blossomSub.unsubscribe();
|
||||
this._blossomSub = null;
|
||||
@@ -1329,26 +820,12 @@ export default class NostrDataService extends Service {
|
||||
}
|
||||
this._cleanupZapReceiptSubs();
|
||||
this._clearZapRefreshTimer();
|
||||
if (this._incomingZapsNetworkSub) {
|
||||
this._incomingZapsNetworkSub.unsubscribe();
|
||||
this._incomingZapsNetworkSub = null;
|
||||
}
|
||||
if (this._activityPhotosNetworkSub) {
|
||||
this._activityPhotosNetworkSub.unsubscribe();
|
||||
this._activityPhotosNetworkSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this._cleanupSubscriptions();
|
||||
this._clearProfileSubs();
|
||||
this._attemptedProfilePubkeys.clear();
|
||||
|
||||
if (this._deletionsSub) {
|
||||
this._deletionsSub.unsubscribe();
|
||||
this._deletionsSub = null;
|
||||
}
|
||||
|
||||
if (this._stopPersisting) {
|
||||
this._stopPersisting();
|
||||
|
||||
@@ -8,11 +8,18 @@ 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. 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);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
+6
-21
@@ -196,7 +196,7 @@ out center;
|
||||
|
||||
async getCategoryPois(bounds, categoryId, lat, lon) {
|
||||
const category = getCategoryById(categoryId);
|
||||
if (!category || !Array.isArray(category.filter) || !bounds) return [];
|
||||
if (!category || !bounds) return [];
|
||||
|
||||
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
|
||||
|
||||
@@ -457,11 +457,11 @@ out center;
|
||||
// request using comma-separated IDs (max ~50 per request).
|
||||
//
|
||||
// Note: the multi-fetch endpoint returns ways/relations WITHOUT their child
|
||||
// nodes, so normalized data may lack lat/lon and geometry.
|
||||
// We only write complete results (those with lat/lon) to the general OSM
|
||||
// cache. Nodes from the batch endpoint always include coordinates, so they
|
||||
// are cached. Ways/relations without child node coordinates are not cached,
|
||||
// preserving the full single-object endpoint for place detail navigation.
|
||||
// nodes, so normalized data may lack lat/lon and geometry. We intentionally
|
||||
// do NOT write these results to the general OSM cache — only the returned
|
||||
// Map is used by the caller (the contributions service manages its own
|
||||
// name cache). This prevents incomplete data from breaking place detail
|
||||
// navigation, which needs the full single-object endpoint.
|
||||
const MAX_IDS_PER_REQUEST = 50;
|
||||
|
||||
for (let i = 0; i < group.length; i += MAX_IDS_PER_REQUEST) {
|
||||
@@ -488,21 +488,6 @@ out center;
|
||||
);
|
||||
if (normalized) {
|
||||
result.set(item.cacheKey, normalized);
|
||||
// Warm the OSM cache for complete results (nodes always have
|
||||
// lat/lon from the batch endpoint; ways/relations only when
|
||||
// child nodes are included in the response).
|
||||
if (
|
||||
normalized.lat != null &&
|
||||
normalized.lon != null &&
|
||||
normalized.title
|
||||
) {
|
||||
this._storeInMemoryAndLocalStorage(
|
||||
item.cacheKey,
|
||||
osmType,
|
||||
item.osmId,
|
||||
normalized
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,6 @@ export default class StorageService extends Service {
|
||||
this.rs.on('connected', () => {
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
this.initialSyncDone = false;
|
||||
|
||||
if (this.isNewConnection) {
|
||||
this.toast.show('Remote storage connected', 3000);
|
||||
@@ -72,7 +71,6 @@ export default class StorageService extends Service {
|
||||
});
|
||||
|
||||
this.rs.on('not-connected', () => {
|
||||
this.initialSyncDone = true;
|
||||
this.loadLists();
|
||||
});
|
||||
|
||||
|
||||
+20
-294
@@ -728,6 +728,26 @@ body {
|
||||
animation: tooltip-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 6px solid transparent;
|
||||
left: var(--arrow-left, 50%);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tooltip[data-placement='top']::after {
|
||||
top: 100%;
|
||||
border-top-color: var(--body-text-color);
|
||||
}
|
||||
|
||||
.tooltip[data-placement='bottom']::after {
|
||||
bottom: 100%;
|
||||
border-bottom-color: var(--body-text-color);
|
||||
}
|
||||
|
||||
@keyframes tooltip-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -1175,71 +1195,6 @@ abbr[title] {
|
||||
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 {
|
||||
/* TODO: remove this scoped rule in favor of a global box-sizing reset
|
||||
(e.g. `*, *::before, *::after { box-sizing: border-box; }`) at the top
|
||||
@@ -2482,38 +2437,6 @@ button.create-place {
|
||||
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-list {
|
||||
list-style: none;
|
||||
@@ -2757,200 +2680,3 @@ button.create-place {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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,7 +23,6 @@ export default class ApplicationComponent extends Component {
|
||||
name === 'search' ||
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name === 'activity' ||
|
||||
name.startsWith('lists'))
|
||||
);
|
||||
}
|
||||
@@ -52,7 +51,6 @@ export default class ApplicationComponent extends Component {
|
||||
name === 'place' ||
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name === 'activity' ||
|
||||
name.startsWith('lists')
|
||||
) {
|
||||
this.mapUi.clearSelection();
|
||||
@@ -60,7 +58,6 @@ export default class ApplicationComponent extends Component {
|
||||
if (
|
||||
name === 'menu' ||
|
||||
name === 'contributions' ||
|
||||
name === 'activity' ||
|
||||
name.startsWith('lists')
|
||||
) {
|
||||
this.router.transitionTo('index');
|
||||
@@ -72,9 +69,6 @@ export default class ApplicationComponent extends Component {
|
||||
} else {
|
||||
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,19 +5,12 @@ import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import restoreScroll from '../../modifiers/restore-scroll';
|
||||
|
||||
export default class ListsIndexTemplate extends Component {
|
||||
@service storage;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
|
||||
savedList = {
|
||||
id: 'saved',
|
||||
title: 'Saved',
|
||||
color: 'var(--default-list-color)',
|
||||
};
|
||||
|
||||
styleFor(color) {
|
||||
const finalColor =
|
||||
color ||
|
||||
@@ -27,16 +20,8 @@ export default class ListsIndexTemplate extends Component {
|
||||
return htmlSafe(`background-color: ${finalColor}`);
|
||||
}
|
||||
|
||||
get scrollTop() {
|
||||
return this.mapUi.getScrollPosition('lists-index');
|
||||
}
|
||||
|
||||
@action
|
||||
selectList(listId) {
|
||||
const sidebarContent = document.querySelector('.sidebar-content');
|
||||
if (sidebarContent) {
|
||||
this.mapUi.saveScrollPosition('lists-index', sidebarContent.scrollTop);
|
||||
}
|
||||
this.router.transitionTo('lists.list', listId);
|
||||
}
|
||||
|
||||
@@ -61,39 +46,15 @@ export default class ListsIndexTemplate extends Component {
|
||||
<span class="sidebar-header-icon-wrapper">
|
||||
<Icon @name="bookmark" @size={{20}} @color="#898989" />
|
||||
</span>
|
||||
Saved Places
|
||||
Collections
|
||||
</h2>
|
||||
<button type="button" class="close-btn" {{on "click" this.close}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content" {{restoreScroll this.scrollTop}}>
|
||||
<div class="sidebar-content">
|
||||
<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|}}
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -7,7 +7,7 @@ import PlacesSidebar from '#components/places-sidebar';
|
||||
@title={{@controller.listTitle}}
|
||||
@color={{@controller.listColor}}
|
||||
@scrollTop={{@controller.scrollTop}}
|
||||
@isLoading={{@controller.isLoading}}
|
||||
@isLoading={{@controller.loadPlacesTask.isRunning}}
|
||||
@onSelect={{@controller.selectPlace}}
|
||||
@onClose={{@controller.close}}
|
||||
@onBack={{@controller.backToLists}}
|
||||
|
||||
@@ -18,20 +18,14 @@ export default class SearchTemplate extends Component {
|
||||
this.mapUi.showSidebar();
|
||||
this.mapUi.preventNextZoom = true;
|
||||
// We don't need to manually set currentSearch here because
|
||||
// it was already set in the search controller
|
||||
// it was already set in the route's setupController
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
// 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();
|
||||
}
|
||||
this.mapUi.hideSidebar();
|
||||
}
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
* @returns {object|null} A photo object, or null if no usable `imeta` was found
|
||||
*/
|
||||
export function parsePhotoFromEvent(event) {
|
||||
function parsePhotoFromEvent(event) {
|
||||
const tags = event.tags || [];
|
||||
|
||||
const eventTags = tags
|
||||
@@ -120,7 +120,7 @@ export function parsePhotoFromEvent(event) {
|
||||
* @param {Array} events Mixed kind 360 and kind 5 events
|
||||
* @returns {Array} Surviving kind 360 events
|
||||
*/
|
||||
export function applyDeletions(events) {
|
||||
function applyDeletions(events) {
|
||||
const deletedIds = new Set();
|
||||
for (const event of events) {
|
||||
if (event.kind === 5) {
|
||||
|
||||
@@ -26,10 +26,9 @@ export function formatRelativeDate(timestamp) {
|
||||
if (diffMin < 60) return `${diffMin} min ago`;
|
||||
if (diffHr < 24) return `${diffHr} hr ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
const isCurrentYear = date.getFullYear() === now.getFullYear();
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(isCurrentYear ? {} : { year: 'numeric' }),
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.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 checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
|
||||
@@ -40,7 +39,6 @@ import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
|
||||
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import userCheck from 'feather-icons/dist/icons/user-check.svg?raw';
|
||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||
import check from 'feather-icons/dist/icons/check.svg?raw';
|
||||
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
||||
@@ -54,8 +52,6 @@ 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';
|
||||
@@ -98,9 +94,7 @@ 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';
|
||||
@@ -118,7 +112,6 @@ 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 openBook from '@waysidemapping/pinhead/dist/icons/open_book.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 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';
|
||||
@@ -144,8 +137,6 @@ 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';
|
||||
@@ -156,7 +147,6 @@ import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/wome
|
||||
/*
|
||||
* Custom/local icons
|
||||
*/
|
||||
import bitcoin from '../icons/bitcoin.svg?raw';
|
||||
import loadingRing from '../icons/270-ring.svg?raw';
|
||||
import nostrich from '../icons/nostrich-2.svg?raw';
|
||||
import remotestorage from '../icons/remotestorage.svg?raw';
|
||||
@@ -171,19 +161,15 @@ const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
barbell,
|
||||
'barrel-with-bunghole': barrelWithBunghole,
|
||||
bicycle,
|
||||
bulldozer,
|
||||
banknote,
|
||||
'banknote-in-slot': banknoteInSlot,
|
||||
'badge-shield-with-fire': badgeShieldWithFire,
|
||||
'beach-umbrella-in-ground': beachUmbrellaInGround,
|
||||
'beer-mug-with-foam': beerMugWithFoam,
|
||||
bitcoin,
|
||||
bookmark,
|
||||
'boxing-glove-up': boxingGloveUp,
|
||||
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
||||
bridge,
|
||||
briefcase,
|
||||
bus,
|
||||
camera,
|
||||
'feather-camera': featherCamera,
|
||||
@@ -226,8 +212,6 @@ const ICONS = {
|
||||
'flower-bouquet': flowerBouquet,
|
||||
'fork-and-knife': forkAndKnife,
|
||||
fort,
|
||||
fortress,
|
||||
'gable-roofed-building': gableRoofedBuilding,
|
||||
'gasoline-pump': gasolinePump,
|
||||
gift,
|
||||
globe,
|
||||
@@ -261,7 +245,6 @@ const ICONS = {
|
||||
nostrich,
|
||||
'open-book': openBook,
|
||||
palace,
|
||||
'payment-card': paymentCard,
|
||||
'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall,
|
||||
'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track':
|
||||
personBoardingTramWithDestinationDisplayAndPantographOnTramTrack,
|
||||
@@ -296,11 +279,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,
|
||||
'village-buildings': villageBuildings,
|
||||
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||
@@ -321,10 +301,7 @@ const ICONS = {
|
||||
};
|
||||
|
||||
const FILLED_ICONS = [
|
||||
'banknote',
|
||||
'bitcoin',
|
||||
'fork-and-knife',
|
||||
'payment-card',
|
||||
'wikipedia',
|
||||
'whatsapp',
|
||||
'cup-and-saucer',
|
||||
|
||||
+13
-28
@@ -99,9 +99,18 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
|
||||
{ tags: { leisure: 'marina' }, icon: 'anchor' },
|
||||
{ tags: { landuse: 'vineyard' }, icon: 'grapes' },
|
||||
{ tags: { landuse: 'cemetery' }, icon: 'memorial-stone-with-inscription' },
|
||||
{ tags: { landuse: 'construction' }, icon: 'bulldozer' },
|
||||
{ tags: { landuse: 'residential' }, icon: 'town-buildings' },
|
||||
|
||||
// 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',
|
||||
},
|
||||
|
||||
// Tourism
|
||||
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
|
||||
@@ -122,11 +131,9 @@ 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' },
|
||||
@@ -146,30 +153,13 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
||||
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
||||
{ tags: { historic: 'ruin' }, 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' },
|
||||
{ tags: { historic: 'yes' }, icon: 'camera' },
|
||||
|
||||
// Accommodation
|
||||
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
|
||||
{ tags: { tourism: 'hostel' }, icon: 'person-sleeping-in-bed' },
|
||||
{ tags: { tourism: 'motel' }, icon: 'person-sleeping-in-bed' },
|
||||
{ tags: { tourism: 'guest_house' }, icon: 'person-sleeping-in-bed' },
|
||||
{ tags: { leisure: 'resort' }, icon: 'person-sleeping-in-bed' },
|
||||
|
||||
// Sports / Motorsports
|
||||
{ tags: { sport: 'motor' }, icon: 'flag-checkered' },
|
||||
@@ -199,7 +189,6 @@ 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
|
||||
@@ -214,10 +203,6 @@ 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' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -55,26 +55,11 @@ export const POI_CATEGORIES = [
|
||||
id: 'accommodation',
|
||||
label: 'Hotels',
|
||||
icon: 'person-sleeping-in-bed',
|
||||
filter: [
|
||||
'["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]',
|
||||
'["leisure"="resort"]',
|
||||
],
|
||||
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'],
|
||||
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) {
|
||||
if (id === NEARBY_CATEGORY.id) return NEARBY_CATEGORY;
|
||||
return POI_CATEGORIES.find((c) => c.id === id);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -6,9 +6,6 @@
|
||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Fediverse account -->
|
||||
<link rel="me" href="https://kosmos.social/@marco">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Marco">
|
||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.34.5",
|
||||
"version": "1.30.1",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
@@ -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 +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;--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
|
||||
@@ -0,0 +1 @@
|
||||
:root{--body-text-color:#333;--primary-background-color:#fff;--hover-bg:#f8f9fa;--divider-color:#eee;--sidebar-width:350px;--link-color:#2a7fff;--link-color-visited:#6a4fbf;--marker-color-primary:#ea4335;--marker-color-dark:#b31412;--danger-color:var(--marker-color-primary);--danger-color-dark:var(--marker-color-dark);--default-list-color:#fc3}html,body{overscroll-behavior:none;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent;height:100%}button{-webkit-tap-highlight-color:transparent}body{color:var(--body-text-color);margin:0;font-family:Noto Sans,sans-serif;font-size:16px;font-weight:400}#root,#ember-testing{height:100%}.map-container{-webkit-tap-highlight-color:transparent;background:#f8f9fa;outline:none;position:absolute;inset:0}#rs-widget-container{z-index:4000;display:none;position:absolute;top:60px;right:10px}#rs-widget-container.visible{display:block}.rs-backdrop{z-index:3999;position:fixed;inset:0}.app-header{z-index:3000;pointer-events:none;grid-template-columns:1fr auto 1fr;grid-template-areas:"search chips user";align-items:center;gap:1rem;height:60px;padding:.5rem 1rem;display:grid;position:absolute;top:0;left:0;right:0}@media (width<=768px){.app-header{grid-template-columns:1fr auto;grid-template-areas:"search user""chips chips";row-gap:8px;height:auto;padding:.5rem .5rem 0}}.header-left,.header-right,.header-center{pointer-events:auto}.header-left{grid-area:search;justify-self:start;align-items:center;width:100%;display:flex}@media (width>768px){.header-left{min-width:300px;max-width:350px}}.header-right{grid-area:user;justify-self:end}.header-center{grid-area:chips;justify-content:center;min-width:0;display:flex}@media (width>768px){.header-center .category-chips-scroll{width:auto;max-width:100%}}@media (width<=768px){.header-center{justify-content:start;width:100%;overflow:hidden}.header-center.searching{display:none}}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;padding:0;display:flex}.user-avatar-placeholder{background:#2a3743;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;width:40px;height:40px;display:flex}.user-avatar{object-fit:cover;border-radius:50%;flex-shrink:0;width:40px;height:40px;display:block}.photo-preview-img{max-width:100%;height:auto}.dropzone{text-align:center;cursor:pointer;aspect-ratio:4/3;background-color:#ffffff05;border:2px dashed #ccc;border-radius:8px;margin:1.5rem 0 1rem;transition:all .2s}.dropzone.is-dragging{background-color:#61afef0d;border-color:#61afef}.dropzone-label{cursor:pointer;color:#898989;flex-direction:column;justify-content:center;align-items:center;gap:1rem;width:100%;height:100%;display:flex}.dropzone-label p{margin:0}.file-input-hidden{display:none}.photo-upload-item{aspect-ratio:4/3;background:#1e262e;border-radius:6px;width:100%;margin-bottom:1.25rem;position:relative;overflow:hidden}.photo-upload-item img{object-fit:contain;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.photo-upload-item .overlay,.photo-upload-item .btn-remove-photo{z-index:2}.photo-upload-item .overlay{background:#0009;flex-direction:column;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.upload-status-text{color:#fff;text-shadow:0 1px 3px #000c;text-align:center;margin-top:1rem;padding:0 1rem;font-size:.9rem}.photo-upload-item .error-overlay{cursor:pointer;background:#e06c75cc;border:none;width:100%;margin:0;padding:0}.photo-upload-item .btn-remove-photo{cursor:pointer;color:#fff;background:#000000b3;border:none;border-radius:50%;justify-content:center;align-items:center;width:24px;height:24px;padding:0;display:flex;position:absolute;top:4px;right:4px}.photo-upload-item .btn-remove-photo:hover{background:var(--danger-color)}.spin-animation{animation:1s linear infinite spin}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.btn-publish{width:100%}.user-menu-container{position:relative}.user-menu-popover{z-index:3001;background:#fff;border-radius:8px;width:280px;margin-top:10px;padding:1rem;position:absolute;top:100%;right:0;box-shadow:0 4px 12px #00000026}.menu-backdrop{z-index:3000;position:fixed;inset:0}.account-list{margin:0;padding:0;list-style:none}.account-item{border-bottom:1px solid var(--divider-color);flex-direction:column;padding:1rem 0;display:flex}.account-item:last-child{border-bottom:none;padding-bottom:0}.account-item:first-child{padding-top:0}.account-header{justify-content:space-between;align-items:center;display:flex}.account-status{color:#898989;white-space:nowrap;text-overflow:ellipsis;margin-top:.35rem;margin-left:calc(18px + .75rem);font-size:.85rem;overflow:hidden}.account-status strong{font-weight:600}.account-item.disabled{opacity:.5;pointer-events:none}.account-info{align-items:center;gap:.75rem;display:flex}.btn-text{cursor:pointLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
var t=Object.create,e=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,s=Object.prototype.hasOwnProperty,o=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports),a=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:"Module"}),r},l=(t,i,o,a)=>{if(i&&"object"==typeof i||"function"==typeof i)for(var l,c=r(i),u=0,h=c.length;u<h;u++)l=c[u],s.call(t,l)||l===o||e(t,l,{get:(t=>i[t]).bind(null,l),enumerable:!(a=n(i,l))||a.enumerable});return t},c=(n,r,o)=>(o=null!=n?t(i(n)):{},l(!r&&n&&n.__esModule&&s.call(n,"default")?o:e(o,"default",{value:n,enumerable:!0}),n)),u=(t=>"undefined"!=typeof require?require:"undefined"!=typeof Proxy?new Proxy(t,{get:(t,e)=>("undefined"!=typeof require?require:t)[e]}):t)(function(t){if("undefined"!=typeof require)return require.apply(this,arguments);throw Error('Calling `require` for "'+t+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();var h,d=Object.prototype,p=Symbol("undefined"),f=function(t){return t[t.ADD=0]="ADD",t[t.ONCE=1]="ONCE",t[t.REMOVE=2]="REMOVE",t}(f||{}),g=1,m=class{_descriptors;_mixins;_isInit;_lazyChains;_values;_revisions;source;proto;_parent;_listeners;_listenersVersion=1;_inheritedEnd=-1;_flattenedVersion=0;constructor(t){this._parent=void 0,this._descriptors=void 0,this._mixins=void 0,this._lazyChains=void 0,this._values=void 0,this._revisions=void 0,this._isInit=!1,this.source=t,this.proto=void 0===t.constructor?void 0:t.constructor.prototype,this._listeners=void 0}get parent(){let t=this._parent;if(void 0===t){let e=y(this.source);this._parent=t=null===e||e===d?null:_(e)}return t}setInitializing(){this._isInit=!0}unsetInitializing(){this._isInit=!1}isInitializing(){return this._isInit}isPrototypeMeta(t){return this.proto===this.source&&this.source===t}_getOrCreateOwnMap(t){return this[t]||(this[t]=Object.create(null))}_getOrCreateOwnSet(t){return this[t]||(this[t]=new Set)}_findInheritedMap(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r){let t=r.get(e);if(void 0!==t)return t}n=n.parent}}_hasInInheritedSet(t,e){let n=this;for(;null!==n;){let r=n[t];if(void 0!==r&&r.has(e))return!0;n=n.parent}return!1}valueFor(t){let e=this._values;return void 0!==e?e[t]:void 0}setValueFor(t,e){this._getOrCreateOwnMap("_values")[t]=e}revisionFor(t){let e=this._revisions;return void 0!==e?e[t]:void 0}setRevisionFor(t,e){this._getOrCreateOwnMap("_revisions")[t]=e}writableLazyChainsFor(t){let e=this._getOrCreateOwnMap("_lazyChains"),n=e[t];return void 0===n&&(n=e[t]=[]),n}readableLazyChainsFor(t){let e=this._lazyChains;if(void 0!==e)return e[t]}addMixin(t){this._getOrCreateOwnSet("_mixins").add(t)}hasMixin(t){return this._hasInInheritedSet("_mixins",t)}forEachMixins(t){let e,n=this;for(;null!==n;){let r=n._mixins;void 0!==r&&(e=void 0===e?new Set:e,r.forEach(n=>{e.has(n)||(e.add(n),t(n))})),n=n.parent}}writeDescriptors(t,e){(this._descriptors||(this._descriptors=new Map)).set(t,e)}peekDescriptors(t){let e=this._findInheritedMap("_descriptors",t);return e===p?void 0:e}removeDescriptors(t){this.writeDescriptors(t,p)}forEachDescriptors(t){let e,n=this;for(;null!==n;){let r=n._descriptors;void 0!==r&&(e=void 0===e?new Set:e,r.forEach((n,r)=>{e.has(r)||(e.add(r),n!==p&&t(r,n))})),n=n.parent}}addToListeners(t,e,n,r,i){this.pushListener(t,e,n,r?f.ONCE:f.ADD,i)}removeFromListeners(t,e,n){this.pushListener(t,e,n,f.REMOVE)}pushListener(t,e,n,r,i=!1){let s=this.writableListeners(),o=x(s,t,e,n);if(-1!==o&&o<this._inheritedEnd&&(s.splice(o,1),this._inheritedEnd--,o=-1),-1===o)s.push({event:t,target:e,method:n,kind:r,sync:i});else{let t=s[o];r===f.REMOVE&&t.kind!==f.REMOVE?s.splice(o,1):(t.kind=r,t.sync=i)}}writableListeners(){return this._flattenedVersion!==g||this.source!==this.proto&&-1!==this._inheritedEnd||g++,-1===this._inheritedEnd&&(this._inheritedEnd=0,this._listeners=[]),this._listeners}flattenedListeners(){if(this._flattenedVersion<g){let t=this.parent;if(null!==t){let e=t.flattenedListeners();if(void 0!==e)if(void 0===this._listeners)this._listeners=e;else{let t=this._listeners;this._inheritedEnd>0&&(t.splice(0,this._inheritedEnd),this._inheritedEnd=0);for(Line truncated
|
||||
@@ -1 +1 @@
|
||||
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-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
|
||||
import{$ as e,$n as t,$r as a,$t as n,A as s,An as r,Ar as i,At as o,B as l,Bn as g,Br as d,Bt as h,Cn as c,Cr as u,Ct as p,Dn as _,Dr as m,Dt as y,En as C,Er as b,Et as f,F as A,Fn as M,Fr as I,Ft as D,G as T,Gn as v,Gr as O,Gt as E,H as S,Hn as R,Hr as j,Ht as x,I as w,In as P,Ir as N,It as V,J as F,Jn as G,Jr as L,Jt as k,K as U,Kn as Y,Kr as B,Kt as H,L as $,Ln as z,Lr as W,Lt as q,M as Z,Mn as J,Mr as K,Mt as Q,N as X,Nn as ee,Nr as te,Nt as ae,On as ne,Or as se,Ot as re,P as ie,Pn as oe,Pr as le,Pt as ge,Q as de,Qn as he,Qr as ce,Qt as ue,R as pe,Rn as _e,Rr as me,Rt as ye,Sn as Ce,Sr as be,St as fe,Tn as Ae,Tr as Me,Tt as Ie,U as De,Un as Te,Ur as ve,Ut as Oe,V as Ee,Vn as Se,Vr as Re,Vt as je,W as xe,Wn as we,Wr as Pe,Wt as Ne,X as Ve,Xn as Fe,Xr as Ge,Xt as Le,Y as ke,Yn as Ue,Yr as Ye,Yt as Be,Z as He,Zn as $e,Zr as ze,Zt as We,_ as qe,_n as Ze,_r as Je,_t as Ke,a as Qe,ai as Xe,an as et,ar as tt,at,bn as nt,br as st,bt as rt,c as it,cn as ot,cr as lt,ct as gt,d as dt,dn as ht,dr as ct,dt as ut,ei as pt,en as _t,er as mt,et as yt,f as Ct,fn as bt,fr as ft,ft as At,g as Mt,gn as It,gr as Dt,gt as Tt,h as vt,hn as Ot,hr as Et,ht as St,i as Rt,ii as jt,in as xt,ir as wt,it as Pt,j as Nt,jn as Vt,jr as Ft,jt as Gt,k as Lt,kn as kt,kr as Ut,kt as Yt,l as Bt,ln as Ht,lr as $t,lt as zt,m as Wt,mn as qt,mr as Zt,mt as Jt,n as Kt,ni as Qt,nn as Xt,nr as ea,nt as ta,o as aa,oi as na,on as sa,or as ra,ot as ia,p as oa,pn as la,pr as ga,pt as da,q as ha,qn as ca,qr as ua,qt as pa,r as _a,ri as ma,rn as ya,rr as Ca,rt as ba,s as fa,si as Aa,sn as Ma,sr as Ia,st as Da,t as Ta,ti as va,tn as Oa,tr as Ea,tt as Sa,u as Ra,un as ja,ur as xa,ut as wa,v as Pa,vn as Na,vr as Va,vt as Fa,wn as Ga,wr as La,wt as ka,xn as Ua,xr as Ya,xt as Ba,yn as Ha,yr as $a,yt as za,z as Wa,zn as qa,zr as Za,zt as Ja}from"./main-DeiUGvCJ.js";var Ka=Aa({default:()=>Ga}),Qa=Aa({default:()=>tn}),Xa={willChange:"_arrangedContentArrayWillChange",didChange:"_arrangedContentArrayDidChange"};function en(e,t){return"[]"===t?(e._revalidate(),e._arrTag):"length"===t?(e._revalidate(),e._lengthTag):B(e,t)}var tn=class extends Y{_objectsDirtyIndex=0;_objects=null;_lengthDirty=!0;_length=0;_arrangedContent=null;_arrangedContentIsUpdating=!1;_arrangedContentTag=null;_arrangedContentRevision=null;_lengthTag=null;_arrTag=null;init(e){super.init(e),W(this,en)}[b](){this._revalidate()}willDestroy(){this._removeArrangedContentArrayObserver()}objectAtContent(e){let t=be(this,"arrangedContent");return le(t,e)}replace(e,t,a){this.replaceContent(e,t,a)}replaceContent(e,t,a){let n=be(this,"content");$a(n,e,t,a)}objectAt(e){if(this._revalidate(),null===this._objects&&(this._objects=[]),-1!==this._objectsDirtyIndex&&e>=this._objectsDirtyIndex){let e=be(this,"arrangedContent");if(e){let t=this._objects.length=be(e,"length");for(let e=this._objectsDirtyIndex;e<t;e++)this._objects[e]=this.objectAtContent(e)}else this._objects.length=0;this._objectsDirtyIndex=-1}return this._objects[e]}get length(){if(this._revalidate(),this._lengthDirty){let e=be(this,"arrangedContent");this._length=e?be(e,"length"):0,this._lengthDirty=!1}return L(this._lengthTag),this._length}set length(e){let t,a=this.length-e;if(0===a)return;a<0&&(t=new Array(-a),a=0);let n=be(this,"content");n&&($a(n,e,a,t),this._invalidate())}_updateArrangedContentArray(e){let t=null===this._objects?0:this._objects.length,a=e?be(e,"length"):0;this._removeArrangedContentArrayObserver(),Je(this,0,t,a),this._invalidate(),Dt(this,0,t,a,!1),this._addArrangedContentArrayObserver(e)}_addArrangedContentArrayObserver(e){e&&!e.isDestroyed&&(Et(e,this,Xa),this._arrangedContent=e)}_removeArrangedContentArrayObserver(){this._arrangedContent&&Va(this._arrangedContent,this,Xa)}_arrangedContentArrayWillChange(){}_arrangedContentArrayDidChange(e,t,a,n){Je(this,t,a,n);let s=t;s<0&&(s+=be(this._arrangedContent,"length")+a-n),(-1===this._objectsDirtyIndex||this._objectsDirtyIndex>s)&&(this._objectsDirtyIndex=s),this._lengthDirty=!0,Dt(this,t,a,n,!1)}_invalidate(){this._objectsDirtyIndex=0,this._lengthDirty=!0}_revalidate(){if(!0!==this._arrangedContentIsUpdating&&(null===this._arrangedContentTag||!Ye(this._arrangedContentTag,this._arrangedContentRevision))){let e=this.get("arrangedContent");null===this._arrangedContentTag?this._addArrangedContentArrayObserver(e):(this._arrangedContentIsUpdating=!0,this._updateArrangedContentArray(e),this._arrangedContentIsUpdating=!1);let t=this._arrangedContentTag=B(this,"arrangedContent");this._arrangedContentRevision=Ge(this._arrangedContentTag),O(e)?(this._lengthTag=ua([t,I(e,"length")]),this._arrTag=ua([t,I(e,"[]")])):this._lengthTag=this._arrTag=t}}};tn.reopen(Ga,{arrangedContent:st("content")});var an=Aa({Meta:()=>va,UNDEFINED:()=>Qt,counters:()=>ma,meta:()=>jt,peekMeta:()=>Xe,setMeta:()=>na}),nn=Aa({ActionHandler:()=>Fe,Comparable:()=>C,ContainerProxyMixin:()=>Ua,MutableEnumerable:()=>_,RSVP:()=>wt,RegistryProxyMixin:()=>nt,TargetActionSupport:()=>Pa,_ProxyMixin:()=>Sa,_contentFor:()=>taLine truncated
|
||||
@@ -1 +1 @@
|
||||
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-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
|
||||
import{C as t,D as e,E as i,O as r,S as n,T as s,b as a,w as o,x as h,y as u}from"./main-DeiUGvCJ.js";function l(){for(var t=[],r=0;r<arguments.length;r++)t[r]=arguments[r];return 1===(t=n(t)).length?s(t[0]):new e(function(t){return function(e){for(var r=[],n=function(n){r.push(s(t[n]).subscribe(i(e,function(t){if(r){for(var i=0;i<r.length;i++)i!==n&&r[i].unsubscribe();r=null}e.next(t)})))},a=0;r&&!e.closed&&a<t.length;a++)n(a)}}(t))}var d=32,c=class t{_raw;length;constructor(t){this._raw=new Uint8Array(t||512),this.length=t?t.length:0}unwrap(){return this._raw.subarray(0,this.length)}get capacity(){return this._raw.byteLength}extend(e){if(e instanceof t&&(e=e.unwrap()),"number"!=typeof e.length)throw Error("bad length");const i=e.length+this.length;if(this.capacity<i){const t=this._raw,e=Math.max(2*this.capacity,i);this._raw=new Uint8Array(e),this._raw.set(t)}this._raw.set(e,this.length),this.length+=e.length}shift(){const t=this._raw[0];return this._raw=this._raw.subarray(1),this.length--,t}shiftN(t=1){const e=this._raw.subarray(0,t);return this._raw=this._raw.subarray(t),this.length-=t,e}};function f(t){let e=0;for(;;){if(0===t.length)throw Error("parse ends prematurely");let i=t.shift();if(e=e<<7|127&i,!(128&i))break}return e}function m(t){if(0===t)return new c([0]);let e=[];for(;0!==t;)e.push(127&t),t>>>=7;e.reverse();for(let i=0;i<e.length-1;i++)e[i]|=128;return new c(e)}function g(t,e){if(t.length<e)throw Error("parse ends prematurely");return t.shiftN(e)}var p=class{buf;sha256;constructor(){this.buf=new Uint8Array(d),this.sha256=async t=>u.create().update(t).digest()}setToZero(){this.buf=new Uint8Array(d)}add(t){let e=0,i=0,r=new DataView(this.buf.buffer),n=new DataView(t.buffer);for(let s=0;s<8;s++){let t=4*s,a=r.getUint32(t,!0);a+=e,a+=n.getUint32(t,!0),a>4294967295&&(i=1),r.setUint32(t,4294967295&a,!0),e=i,i=0}}negate(){let t=new DataView(this.buf.buffer);for(let i=0;i<8;i++){let e=4*i;t.setUint32(e,~t.getUint32(e,!0))}let e=new Uint8Array(d);e[0]=1,this.add(e)}async getFingerprint(t){let e=new c;return e.extend(this.buf),e.extend(m(t)),(await this.sha256(e.unwrap())).subarray(0,16)}},w=class{items;sealed;constructor(){this.items=[],this.sealed=!1}insert(t,e){if(this.sealed)throw Error("already sealed");if((e=y(e)).byteLength!==d)throw Error("bad id size for added item");this.items.push({timestamp:t,id:e})}seal(){if(this.sealed)throw Error("already sealed");this.sealed=!0,this.items.sort(A);for(let t=1;t<this.items.length;t++)if(0===A(this.items[t-1],this.items[t]))throw Error("duplicate item inserted")}unseal(){this.sealed=!1}size(){return this._checkSealed(),this.items.length}getItem(t){if(this._checkSealed(),t>=this.items.length)throw Error("out of range");return this.items[t]}iterate(t,e,i){this._checkSealed(),this._checkBounds(t,e);for(let r=t;r<e&&i(this.items[r],r);++r);}findLowerBound(t,e,i){return this._checkSealed(),this._checkBounds(t,e),this._binarySearch(this.items,t,e,t=>A(t,i)<0)}async fingerprint(t,e){let i=new p;return i.setToZero(),this.iterate(t,e,t=>(i.add(t.id),!0)),await i.getFingerprint(e-t)}_checkSealed(){if(!this.sealed)throw Error("not sealed")}_checkBounds(t,e){if(t>e||e>this.items.length)throw Error("bad range")}_binarySearch(t,e,i,r){let n=i-e;for(;n>0;){let i=e,s=Math.floor(n/2);i+=s,r(t[i])?(e=++i,n-=s+1):n=s}return e}},b=class{storage;frameSizeLimit;lastTimestampIn;lastTimestampOut;isInitiator;wantUint8ArrayOutput;constructor(t,e=0){if(0!==e&&e<4096)throw Error("frameSizeLimit too small");this.storage=t,this.frameSizeLimit=e,this.lastTimestampIn=0,this.lastTimestampOut=0}_bound(t,e){return{timestamp:t,id:e||new Uint8Array(0)}}async initiate(){if(this.isInitiator)throw Error("already initiated");this.isInitiator=!0;let t=new c;return t.extend([97]),await this.splitRange(0,this.storage.size(),this._bound(Number.MAX_VALUE),t),this._renderOutput(t)}setInitiator(){this.isInitiator=!0}async reconcile(t){let e=[],i=[],r=new c(y(t));this.lastTimestampIn=this.lastTimestampOut=0;let n=new c;n.extend([97]);let s=g(r,1)[0];if(s<96||s>111)throw Error("invalid negentropy protocol version byte");if(97!==s){if(this.isInitiator)throw Error("unsupported negentropy protocol version requested: "+(s-96));return[this._renderOutput(n),e,i]}let a=this.storage.size(),o=this._bound(0),h=0,u=!1;for(;0!==r.length;){let t=new c,s=()=>{u&&(u=!1,t.extend(this.encodeBound(o)),t.extend(m(0)))},l=this.decodeBound(r),p=f(r),w=h,b=this.storage.findLowerBound(h,a,l);if(0===p)u=!0;else if(1===p)0!==E(g(r,16),await this.storage.fingerprint(w,b))?(s(),await this.splitRange(w,b,l,t)):u=!0;else{if(2!==p)throw Error("unexpected mode");{let a=f(r),o={};for(let t=0;t<a;t++){let t=g(r,d);this.isInitiator&&(o[t]=t)}if(this.isInitiator){u=!0,this.storage.iterate(w,b,t=>{let i=t.id;return o[i]?delete o[i]:this.isInitiator&&e.push(this.wantUint8ArrayOutput?i:x(i)),!0});for(let t of Object.values(o))i.push(this.wantUint8ArrayOutput?t:x(t))}else{s();let e=new c,i=0,r=l;this.storage.iterate(w,b,(t,s)=>this.exceededFLine truncated
|
||||
+2
-5
@@ -6,9 +6,6 @@
|
||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Fediverse account -->
|
||||
<link rel="me" href="https://kosmos.social/@marco">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Marco">
|
||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
@@ -42,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-BlRXtNAu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bv3zmRKA.css">
|
||||
<script type="module" crossorigin src="/assets/main-DeiUGvCJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DWxZH-9E.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
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,16 +16,8 @@ class MockStorageService extends Service {
|
||||
id: 'place-123',
|
||||
title: 'Mountain Trail',
|
||||
geohash: 'u33dc0',
|
||||
createdAt: '2023-01-02T12:00:00.000Z',
|
||||
osmTags: { name: 'Mountain Trail' },
|
||||
},
|
||||
{
|
||||
id: 'place-456',
|
||||
title: 'Beach View',
|
||||
geohash: 'u33dc1',
|
||||
createdAt: '2023-01-03T12:00:00.000Z',
|
||||
osmTags: { name: 'Beach View' },
|
||||
},
|
||||
];
|
||||
lists = [
|
||||
{
|
||||
@@ -82,56 +74,27 @@ module('Acceptance | collections navigation', function (hooks) {
|
||||
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
|
||||
assert
|
||||
.dom('.app-menu')
|
||||
.includesText('Saved Places', 'Menu contains Saved Places link');
|
||||
.includesText('Collections', 'Menu contains Collections link');
|
||||
|
||||
// 3. Transition to Saved Places index (list of lists)
|
||||
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Saved Places"
|
||||
// 3. Transition to Collections Index (List of lists)
|
||||
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Collections"
|
||||
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
|
||||
assert
|
||||
.dom('.sidebar-header-text-centered')
|
||||
.includesText(
|
||||
'Saved Places',
|
||||
'Header is centered and titled Saved Places'
|
||||
);
|
||||
.includesText('Collections', 'Header is centered and titled Collections');
|
||||
assert
|
||||
.dom('.lists-index-item')
|
||||
.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');
|
||||
.exists({ count: 2 }, 'Renders our 2 mocked list items');
|
||||
|
||||
// 4. Transition to the synthetic Saved list
|
||||
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"
|
||||
// 4. Transition to a specific list (Want to go)
|
||||
await click(document.querySelectorAll('.lists-index-item')[0]); // Click "Want to go"
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/lists/to-go',
|
||||
'Transitions instantly to /lists/to-go'
|
||||
);
|
||||
|
||||
// 7. Verify background loading spinner shows up, then results populate
|
||||
// 5. Verify background loading spinner shows up, then results populate
|
||||
await waitFor('.places-list');
|
||||
assert
|
||||
.dom('.places-list .place-name')
|
||||
@@ -140,37 +103,19 @@ module('Acceptance | collections navigation', function (hooks) {
|
||||
.dom('.places-list .place-type')
|
||||
.hasText('Saved place', 'Place type displays Saved place correctly');
|
||||
|
||||
// 8. Click back button in collection list header
|
||||
// 6. Click back button in collection list header
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
|
||||
|
||||
// 9. Click back button in collections index header
|
||||
// 7. Click back button in collections index header
|
||||
await click('.sidebar-header .back-btn');
|
||||
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
|
||||
|
||||
// 10. Close sidebar
|
||||
// 8. Close sidebar
|
||||
await click('.sidebar-header .close-btn');
|
||||
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) {
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
@@ -191,7 +136,7 @@ module('Acceptance | collections navigation', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('places inside Saved Places are sorted by createdAt descending', async function (assert) {
|
||||
test('places inside a collection are sorted by createdAt descending', async function (assert) {
|
||||
class SortedMockStorageService extends Service {
|
||||
initialSyncDone = true;
|
||||
savedPlaces = [
|
||||
@@ -258,7 +203,7 @@ module('Acceptance | collections navigation', function (hooks) {
|
||||
this.owner.unregister('service:storage');
|
||||
this.owner.register('service:storage', SortedMockStorageService);
|
||||
|
||||
await visit('/lists/saved');
|
||||
await visit('/lists/to-go');
|
||||
await waitFor('.places-list');
|
||||
|
||||
const placeNames = Array.from(
|
||||
@@ -268,88 +213,7 @@ module('Acceptance | collections navigation', function (hooks) {
|
||||
assert.deepEqual(
|
||||
placeNames,
|
||||
['Newest Place', 'Middle Place', 'Oldest Place'],
|
||||
'Saved Places are ordered by createdAt in descending order'
|
||||
'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,47 +129,4 @@ module('Acceptance | navigation', function (hooks) {
|
||||
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,19 +54,7 @@ class MockOsmService extends Service {
|
||||
return [];
|
||||
}
|
||||
async getNearbyPois() {
|
||||
return new Promise((resolve) => {
|
||||
osmResolve = () => {
|
||||
resolve([
|
||||
{
|
||||
title: 'Nearby Place',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmId: '999',
|
||||
osmType: 'node',
|
||||
},
|
||||
]);
|
||||
};
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +68,7 @@ module('Acceptance | search loading', function (hooks) {
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
});
|
||||
|
||||
test('search shows loading indicator for text, category and nearby searches', async function (assert) {
|
||||
test('search shows loading indicator but nearby search does not', async function (assert) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
// 1. Text Search
|
||||
@@ -133,25 +121,11 @@ module('Acceptance | search loading', function (hooks) {
|
||||
);
|
||||
|
||||
// 3. Nearby Search
|
||||
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();
|
||||
|
||||
await visit('/search?lat=1&lon=1');
|
||||
assert.strictEqual(
|
||||
mapUi.loadingState,
|
||||
null,
|
||||
'Loading state is cleared after nearby search'
|
||||
'Loading state is NOT set for nearby search'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click } from '@ember/test-helpers';
|
||||
import { visit, currentURL } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
@@ -277,9 +277,6 @@ module('Acceptance | search', function (hooks) {
|
||||
async getCategoryPois() {
|
||||
return [];
|
||||
}
|
||||
async getNearbyPois() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
@@ -339,117 +336,10 @@ module('Acceptance | search', function (hooks) {
|
||||
'Search input is populated with mapped category label'
|
||||
);
|
||||
|
||||
// 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
|
||||
// 3. Go back to index
|
||||
await visit('/');
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.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,16 +62,6 @@ export class MockNostrDataService extends Service {
|
||||
return this.profiles[pubkey];
|
||||
}
|
||||
|
||||
getEventRelays() {
|
||||
return [];
|
||||
}
|
||||
|
||||
recordPublishResult() {}
|
||||
|
||||
refreshProfiles() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
get activeReadRelays() {
|
||||
return [];
|
||||
}
|
||||
@@ -115,14 +105,14 @@ export class MockNostrDataService extends Service {
|
||||
|
||||
export class MockNostrRelayService extends Service {
|
||||
pool = {
|
||||
publish: () => Promise.resolve([{ ok: true, from: 'wss://relay.test' }]),
|
||||
publish: () => Promise.resolve([{ ok: true }]),
|
||||
subscribe: () => {},
|
||||
unsubscribe: () => {},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
async publish() {
|
||||
return [{ ok: true, from: 'wss://relay.test' }];
|
||||
return [{ ok: true }];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,301 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { click, fillIn, render, settled } from '@ember/test-helpers';
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import AppMenuSettingsNostr from 'marco/components/app-menu/settings/nostr';
|
||||
import {
|
||||
excludeRequiredRelays,
|
||||
@@ -68,10 +67,6 @@ class MockNostrDataService extends Service {
|
||||
async clearCache() {}
|
||||
}
|
||||
|
||||
class MockNostrAuthService extends Service {
|
||||
@tracked isConnected = true;
|
||||
}
|
||||
|
||||
function readRows(element) {
|
||||
const list = element.querySelectorAll('.relay-list')[0];
|
||||
return [...list.querySelectorAll('li')];
|
||||
@@ -93,7 +88,6 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
localStorage.removeItem('marco:settings');
|
||||
|
||||
this.owner.register('service:nostrData', MockNostrDataService);
|
||||
this.owner.register('service:nostrAuth', MockNostrAuthService);
|
||||
this.settings = this.owner.lookup('service:settings');
|
||||
this.onChange = () => {};
|
||||
});
|
||||
@@ -289,37 +283,6 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -330,9 +293,30 @@ module('Integration | Component | app-menu/settings/nostr', function (hooks) {
|
||||
removeBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await settled();
|
||||
|
||||
const tooltip = document.body.querySelector('.tooltip');
|
||||
assert.dom('.tooltip', document.body).exists('tooltip appears on hover');
|
||||
assert.dom('.tooltip', document.body).hasText('Remove relay');
|
||||
|
||||
// Verify arrow is rendered
|
||||
assert.ok(tooltip.dataset.placement, 'tooltip has placement attribute');
|
||||
const arrowStyle = getComputedStyle(tooltip, '::after');
|
||||
assert.strictEqual(arrowStyle.content, '""', 'arrow pseudo-element exists');
|
||||
|
||||
// Verify arrow points in correct direction based on placement
|
||||
if (tooltip.dataset.placement === 'top') {
|
||||
assert.notStrictEqual(
|
||||
arrowStyle.borderTopColor,
|
||||
'rgba(0, 0, 0, 0)',
|
||||
'arrow points down when tooltip is above trigger'
|
||||
);
|
||||
} else {
|
||||
assert.notStrictEqual(
|
||||
arrowStyle.borderBottomColor,
|
||||
'rgba(0, 0, 0, 0)',
|
||||
'arrow points up when tooltip is below trigger'
|
||||
);
|
||||
}
|
||||
|
||||
removeBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
await settled();
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@ module('Integration | Component | category-chips', function (hooks) {
|
||||
// Check for some expected labels
|
||||
assert.dom(this.element).includesText('Restaurants');
|
||||
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) {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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,7 +2,6 @@ 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';
|
||||
@@ -134,9 +133,6 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
|
||||
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) {
|
||||
@@ -164,11 +160,8 @@ 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([{ ok: true, from: 'wss://relay.test' }]);
|
||||
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
|
||||
const storeStub = sinon.stub(this.nostrData.store, 'add');
|
||||
const recordPublishSpy = sinon.spy(this.nostrData, 'recordPublishResult');
|
||||
const toastSpy = sinon.spy(this.toast, 'show');
|
||||
|
||||
await render(
|
||||
@@ -228,100 +221,18 @@ 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'),
|
||||
'success toast was shown'
|
||||
);
|
||||
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');
|
||||
assert.ok(closed, 'gallery was closed after deletion');
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -352,23 +263,7 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
||||
|
||||
await click(copyBtn);
|
||||
|
||||
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(clipboardStub.calledWith('event1'), 'copied correct event id');
|
||||
assert.ok(
|
||||
toastSpy.calledWith('Event ID copied to clipboard'),
|
||||
'success toast was shown'
|
||||
|
||||
@@ -411,158 +411,4 @@ module('Integration | Component | place-details', function (hooks) {
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -394,7 +394,6 @@ module('Integration | Component | search-box', function (hooks) {
|
||||
);
|
||||
|
||||
// Type "aw" (2 characters)
|
||||
await focus('.search-input');
|
||||
await fillIn('.search-input', 'aw');
|
||||
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,976 +0,0 @@
|
||||
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,6 +51,12 @@ class MockOsmService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
const NAME_CACHE_STORE = 'contributions-name-cache';
|
||||
|
||||
function flushPromises() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
module('Unit | Service | contributions', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
@@ -60,57 +66,212 @@ module('Unit | Service | contributions', function (hooks) {
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
});
|
||||
|
||||
test('_updateItems groups events into entries and resolves bookmark names', function (assert) {
|
||||
test('_updateItems resolves names from bookmarks immediately', function (assert) {
|
||||
const events = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
const storage = this.owner.lookup('service:storage');
|
||||
storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
|
||||
storage.findPlaceById = (id) =>
|
||||
service.storage.savedPlaces = [{ id: '123', title: 'Bookmarked Café' }];
|
||||
service.storage.findPlaceById = (id) =>
|
||||
id === '123' ? { id: '123', title: 'Bookmarked Café' } : null;
|
||||
|
||||
service._updateItems(events);
|
||||
|
||||
assert.strictEqual(service.items.length, 1);
|
||||
assert.false(service.items[0].placeNameLoading);
|
||||
assert.strictEqual(service.items[0].placeName, 'Bookmarked Café');
|
||||
assert.false(service.items[0].placeNameLoading, 'Not loading');
|
||||
assert.strictEqual(
|
||||
service.items[0].placeName,
|
||||
'Bookmarked Café',
|
||||
'Resolved from bookmark'
|
||||
);
|
||||
});
|
||||
|
||||
test('stop clears items and resets resolver state', async function (assert) {
|
||||
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');
|
||||
const events = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
|
||||
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);
|
||||
});
|
||||
|
||||
test('resolved names are persisted to the IndexedDB name cache', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:42',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service.osm.fetchOsmObjectsBatch = async () => {
|
||||
const map = new Map();
|
||||
map.set('node:42', { title: 'Test Place' });
|
||||
return map;
|
||||
};
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(
|
||||
await service.localForage.get(NAME_CACHE_STORE, '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 events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:77',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
await service.localForage.set(
|
||||
NAME_CACHE_STORE,
|
||||
'osm:node:77',
|
||||
'Persisted Place'
|
||||
);
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(service.items[0].placeName, 'Persisted Place');
|
||||
assert.false(service.items[0].placeNameLoading);
|
||||
});
|
||||
|
||||
test('batch fetch failure applies a fallback name so items do not stay loading', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:555',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
// OSM batch returns empty (simulating all-failed fetch)
|
||||
service.osm.fetchOsmObjectsBatch = async () => new Map();
|
||||
|
||||
service._updateItems(events);
|
||||
|
||||
assert.strictEqual(service.items.length, 1);
|
||||
// Wait for the background batch to complete
|
||||
await flushPromises();
|
||||
|
||||
service.stop();
|
||||
|
||||
assert.strictEqual(service.items.length, 0, 'items cleared');
|
||||
const resolver = this.owner.lookup('service:place-name-resolver');
|
||||
assert.false(
|
||||
service.items[0].placeNameLoading,
|
||||
'Item is no longer loading after batch failure'
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolver._lastBatchSignature,
|
||||
'',
|
||||
'resolver state reset'
|
||||
service.items[0].placeName,
|
||||
'OSM node 555',
|
||||
'Fallback name is applied'
|
||||
);
|
||||
});
|
||||
|
||||
test('multiple _updateItems calls re-group events correctly', function (assert) {
|
||||
test('fallback names are NOT persisted to the name cache (so they can be re-fetched next session)', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:666',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
const events1 = [makePhotoEvent({ id: 'e1', created_at: 1000 })];
|
||||
const events2 = [makePhotoEvent({ id: 'e2', created_at: 2000 })];
|
||||
service.osm.fetchOsmObjectsBatch = async () => new Map();
|
||||
|
||||
service._updateItems(events1);
|
||||
assert.strictEqual(
|
||||
service.items.length,
|
||||
1,
|
||||
'first update creates one entry'
|
||||
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'
|
||||
);
|
||||
});
|
||||
|
||||
service._updateItems(events2);
|
||||
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(
|
||||
service.items.length,
|
||||
1,
|
||||
'second update replaces with new group'
|
||||
service.items[0].placeName,
|
||||
'OSM node 777',
|
||||
'Fallback is shown immediately'
|
||||
);
|
||||
});
|
||||
|
||||
test('successful batch resolution stores names in the name cache', async function (assert) {
|
||||
const events = [
|
||||
makePhotoEvent({
|
||||
id: 'e1',
|
||||
created_at: 1000,
|
||||
placeIdentifier: 'osm:node:111',
|
||||
}),
|
||||
];
|
||||
|
||||
const service = this.owner.lookup('service:contributions');
|
||||
service.osm.fetchOsmObjectsBatch = async () => {
|
||||
const map = new Map();
|
||||
map.set('node:111', { title: 'Resolved Café' });
|
||||
return map;
|
||||
};
|
||||
|
||||
service._updateItems(events);
|
||||
await flushPromises();
|
||||
|
||||
assert.strictEqual(service.items[0].placeName, 'Resolved Café');
|
||||
assert.strictEqual(
|
||||
await service.localForage.get(NAME_CACHE_STORE, 'osm:node:111'),
|
||||
'Resolved Café',
|
||||
'Name is stored in the name cache'
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -329,7 +329,7 @@ module('Unit | Service | osm', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchOsmObjectsBatch writes complete results to the general OSM cache', async function (assert) {
|
||||
test('fetchOsmObjectsBatch does not write to the general OSM cache', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
service.fetchWithRetry = async () => ({
|
||||
@@ -343,38 +343,13 @@ module('Unit | Service | osm', function (hooks) {
|
||||
|
||||
await service.fetchOsmObjectsBatch([{ osmType: 'node', osmId: '100' }]);
|
||||
|
||||
assert.true(
|
||||
service.cachedPlaces.has('node:100'),
|
||||
'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)'
|
||||
service.cachedPlaces.has('node:100'),
|
||||
'Batch fetch does not write to the in-memory OSM cache'
|
||||
);
|
||||
assert.notOk(
|
||||
await service.localForage.get('osm-cache', 'way:200'),
|
||||
'Batch fetch does NOT write incomplete results to persistent cache'
|
||||
await service.localForage.get('osm-cache', 'node:100'),
|
||||
'Batch fetch does not write to the persistent OSM cache'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,461 +0,0 @@
|
||||
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,34 +55,12 @@ module('Unit | Utility | format-text', function () {
|
||||
|
||||
test('formatRelativeDate returns an absolute date for timestamps older than a week', function (assert) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oldTimestamp = now - 86400 * 400; // 400 days ago (previous year)
|
||||
const oldTimestamp = now - 86400 * 30; // 30 days ago
|
||||
const result = formatRelativeDate(oldTimestamp);
|
||||
// The exact format depends on the locale, but it should contain a year
|
||||
assert.ok(
|
||||
result.includes(String(new Date().getFullYear() - 1)),
|
||||
'Contains the previous year'
|
||||
);
|
||||
});
|
||||
|
||||
test('formatRelativeDate omits year for same-year dates older than a week', function (assert) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sameYearTimestamp = now - 86400 * 30; // 30 days ago (same year)
|
||||
const result = formatRelativeDate(sameYearTimestamp);
|
||||
// Should NOT contain the current year
|
||||
assert.notOk(
|
||||
result.includes(String(new Date().getFullYear())),
|
||||
'Does not contain the current year'
|
||||
'Contains the 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');
|
||||
});
|
||||
});
|
||||
@@ -38,11 +38,6 @@ module('Unit | Utility | osm-icons', function () {
|
||||
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) {
|
||||
for (let rule of POI_ICON_RULES) {
|
||||
let icon = getIcon(rule.icon);
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
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,11 +35,4 @@ module('Unit | Utility | poi-category-matcher', function () {
|
||||
|
||||
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