Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
107340d26e
|
||
|
|
20fa3e00ce
|
||
|
|
7dfd58d075
|
||
|
|
9d928217ca
|
||
|
|
1b60915e3f
|
||
|
|
39e5240af2
|
||
|
|
35aa3c950f
|
||
|
|
a4a8d123d3
|
||
|
|
92c6190d28
|
||
|
|
36989e1dc2
|
||
|
|
256659fbfb
|
||
|
|
ad762b138f
|
@@ -0,0 +1,132 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import formatRelativeDate from '../helpers/format-relative-date';
|
||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||
import Icon from './icon';
|
||||
|
||||
export default class ActivityPhotoItem extends Component {
|
||||
get item() {
|
||||
return this.args.item;
|
||||
}
|
||||
|
||||
get senderName() {
|
||||
return this.item?.senderName;
|
||||
}
|
||||
|
||||
get senderDisplayName() {
|
||||
const name = this.senderName;
|
||||
if (name) return name;
|
||||
const pubkey = this.item?.senderPubkey;
|
||||
if (!pubkey) return 'Someone';
|
||||
try {
|
||||
return `${npubEncode(pubkey).slice(0, 12)}…`;
|
||||
} catch {
|
||||
return `${pubkey.slice(0, 12)}…`;
|
||||
}
|
||||
}
|
||||
|
||||
get senderAvatar() {
|
||||
return this.item?.senderAvatar;
|
||||
}
|
||||
|
||||
get hasPhoto() {
|
||||
return this.photos.length > 0;
|
||||
}
|
||||
|
||||
get photos() {
|
||||
const photos = this.item?.photos;
|
||||
if (photos && photos.length > 0) return photos;
|
||||
const single = this.item?.photo;
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
get primaryPhoto() {
|
||||
return this.photos[0];
|
||||
}
|
||||
|
||||
get extraPhotoCount() {
|
||||
return Math.max(0, this.photos.length - 1);
|
||||
}
|
||||
|
||||
get photoCountText() {
|
||||
const count = this.photos.length;
|
||||
return count === 1 ? 'a photo' : `${count} photos`;
|
||||
}
|
||||
|
||||
get photoThumbUrl() {
|
||||
return this.primaryPhoto?.thumbUrl || this.primaryPhoto?.url;
|
||||
}
|
||||
|
||||
get placeName() {
|
||||
return this.item?.placeName;
|
||||
}
|
||||
|
||||
get placeNameLoading() {
|
||||
return this.item?.placeNameLoading;
|
||||
}
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="activity-item"
|
||||
{{on "click" (fn @onSelect @item)}}
|
||||
>
|
||||
<div class="header">
|
||||
<span class="sender-line">
|
||||
<span class="sender-name">{{this.senderDisplayName}}</span>
|
||||
{{! template-lint-disable no-whitespace-for-layout }}
|
||||
<span class="action"> shared {{this.photoCountText}}</span>
|
||||
</span>
|
||||
<span class="amount">
|
||||
<Icon
|
||||
@name="feather-camera"
|
||||
@size={{16}}
|
||||
@color="var(--secondary-text-color)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="context">
|
||||
<div class="context-images">
|
||||
{{#if this.senderAvatar}}
|
||||
<img
|
||||
class="sender-avatar"
|
||||
src={{this.senderAvatar}}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
{{else}}
|
||||
<div class="sender-avatar-placeholder">
|
||||
<Icon @name="user" @size={{16}} @color="#999" />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if this.hasPhoto}}
|
||||
<div class="context-thumb">
|
||||
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
|
||||
{{#if (gt this.extraPhotoCount 0)}}
|
||||
<span
|
||||
class="context-thumb-badge"
|
||||
>+{{this.extraPhotoCount}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="context-text">
|
||||
<span class="activity-place-name">
|
||||
{{#if this.placeName}}
|
||||
<span class="place-name-text">{{this.placeName}}</span>
|
||||
{{else if this.placeNameLoading}}{{else}}
|
||||
<span class="place-name-text">Unnamed place</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
<span class="activity-date">{{formatRelativeDate
|
||||
@item.createdAt
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
}
|
||||
@@ -3,16 +3,25 @@ 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();
|
||||
@@ -47,12 +56,18 @@ export default class ActivityTimelineComponent extends Component {
|
||||
</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 (not @isConnected)}}
|
||||
{{else if (and (not @isConnected) (eq @sourceMode "home"))}}
|
||||
<p class="empty-state">
|
||||
<a
|
||||
href="#"
|
||||
@@ -69,8 +84,17 @@ export default class ActivityTimelineComponent extends Component {
|
||||
{{#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>
|
||||
|
||||
@@ -50,54 +50,54 @@ export default class ActivityZapItem extends Component {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="activity-zap-item"
|
||||
class="activity-item"
|
||||
{{on "click" (fn @onSelect @item)}}
|
||||
>
|
||||
<div class="zap-header">
|
||||
<span class="zap-sender-line">
|
||||
<span class="zap-sender-name">{{this.senderDisplayName}}</span>
|
||||
<div class="header">
|
||||
<span class="sender-line">
|
||||
<span class="sender-name">{{this.senderDisplayName}}</span>
|
||||
{{! template-lint-disable no-whitespace-for-layout }}
|
||||
<span class="zap-action"> zapped your photo</span>
|
||||
<span class="action"> zapped your photo</span>
|
||||
</span>
|
||||
<span class="zap-amount">{{this.item.amountSats}} ⚡</span>
|
||||
<span class="amount">{{this.item.amountSats}} ⚡</span>
|
||||
</div>
|
||||
|
||||
<div class="zap-context">
|
||||
<div class="zap-context-images">
|
||||
<div class="context">
|
||||
<div class="context-images">
|
||||
{{#if this.senderAvatar}}
|
||||
<img
|
||||
class="zap-sender-avatar"
|
||||
class="sender-avatar"
|
||||
src={{this.senderAvatar}}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
{{else}}
|
||||
<div class="zap-sender-avatar-placeholder">
|
||||
<div class="sender-avatar-placeholder">
|
||||
<Icon @name="user" @size={{16}} @color="#999" />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if this.hasPhoto}}
|
||||
<div class="zap-context-thumb">
|
||||
<div class="context-thumb">
|
||||
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="zap-context-text">
|
||||
<span class="zap-place-name">
|
||||
<div class="context-text">
|
||||
<span class="activity-place-name">
|
||||
{{#if this.placeName}}
|
||||
{{this.placeName}}
|
||||
{{else if this.placeNameLoading}}
|
||||
<span class="contribution-name-loading">Loading…</span>
|
||||
{{else}}
|
||||
<span class="contribution-name-loading">Unnamed place</span>
|
||||
<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="zap-date">{{formatRelativeDate @item.createdAt}}</span>
|
||||
<span class="activity-date">{{formatRelativeDate
|
||||
@item.createdAt
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.item.message}}
|
||||
<div class="zap-message">{{this.item.message}}</div>
|
||||
<div class="message">{{this.item.message}}</div>
|
||||
{{/if}}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -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="activity" @size={{20}} @color="#898989" />
|
||||
<Icon @name="user-check" @size={{20}} @color="#898989" />
|
||||
</span>
|
||||
My Contributions
|
||||
</h2>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
<template>
|
||||
<nav class="tab-nav">
|
||||
{{#each @tabs as |tab|}}
|
||||
<button
|
||||
type="button"
|
||||
class="tab-nav-button {{if (eq @active tab.value) 'is-active'}}"
|
||||
{{on "click" (fn @onChange tab.value)}}
|
||||
>
|
||||
{{tab.label}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</nav>
|
||||
</template>
|
||||
@@ -10,7 +10,6 @@ export default class ActivityController extends Controller {
|
||||
@service activity;
|
||||
|
||||
loadTask = task({ restartable: true }, async (pubkey) => {
|
||||
if (!pubkey) return;
|
||||
await this.activity.load(pubkey);
|
||||
});
|
||||
|
||||
@@ -22,10 +21,32 @@ export default class ActivityController extends Controller {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element, [callback, disabled]) => {
|
||||
if (disabled) return;
|
||||
|
||||
let observer;
|
||||
let cancelled = false;
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (cancelled) return;
|
||||
if (entries[0]?.isIntersecting) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '200px',
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export default class ActivityRoute extends Route {
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.activity.stop();
|
||||
// Don't call activity.stop() — keep state alive when navigating to place
|
||||
// details and back. The service's willDestroy() handles final cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
+349
-59
@@ -2,7 +2,15 @@ import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||
import { getProfileContent } from 'applesauce-core/helpers/profile';
|
||||
import { parseZapReceipt, enrichWithPhoto } from '../utils/activity';
|
||||
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
|
||||
@@ -10,8 +18,7 @@ import { parseZapReceipt, enrichWithPhoto } from '../utils/activity';
|
||||
* timeline.
|
||||
*
|
||||
* The flow is:
|
||||
* 1. Subscribe to `nostrData.store.timeline(...)` for kind 9735 zap receipts
|
||||
* where the user is the recipient (`#p` filter).
|
||||
* 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`
|
||||
@@ -25,45 +32,117 @@ export default class ActivityService extends Service {
|
||||
@service placeNameResolver;
|
||||
|
||||
@tracked items = [];
|
||||
@tracked sourceMode = 'home';
|
||||
@tracked isLoading = false;
|
||||
@tracked isLoadingMore = false;
|
||||
|
||||
_sub = null;
|
||||
_profileSubs = new Map();
|
||||
_userPubkey = null;
|
||||
_zapItems = [];
|
||||
_socialItems = [];
|
||||
_lastSocialEvents = [];
|
||||
_since = null;
|
||||
_sourceMode = 'home';
|
||||
_isLoadingMore = false;
|
||||
_cacheReadyCallback = null;
|
||||
|
||||
/**
|
||||
* Loads the user's incoming zap receipts and subscribes to live updates.
|
||||
* Loads the user's incoming zap receipts and social photo activity.
|
||||
*
|
||||
* @param {string} pubkey The user's Nostr pubkey
|
||||
* 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 (!pubkey) {
|
||||
this.items = [];
|
||||
return;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
this._userPubkey = pubkey;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
const filters = [{ kinds: [9735], '#p': [pubkey] }];
|
||||
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
|
||||
this._socialItems = [];
|
||||
this._zapItems = [];
|
||||
this._lastSocialEvents = [];
|
||||
this._mergeItems();
|
||||
|
||||
console.debug('[activity] Subscribing to zap receipts', {
|
||||
filters,
|
||||
pubkey,
|
||||
activeReadRelays: this.nostrData.activeReadRelays,
|
||||
});
|
||||
this.isLoading = true;
|
||||
this._cacheReadyCallback = () => {
|
||||
this.isLoading = false;
|
||||
};
|
||||
|
||||
// Subscribe to the store timeline so we get live updates as receipts
|
||||
// arrive and are added to the store.
|
||||
this._sub = this.nostrData.store.timeline(filters).subscribe((events) => {
|
||||
this._updateItems(events, pubkey);
|
||||
});
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await this._fetchWithBackoff(this._since, now);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this._cacheReadyCallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the user's kind 360 photo events are in the store first so
|
||||
// enrichWithPhoto can look them up when zap receipts arrive.
|
||||
await this.nostrData.loadMyContributions(pubkey);
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Then load zap receipts — adding them to the store triggers the
|
||||
// timeline subscription, and by now the photo events are available.
|
||||
await this.nostrData.loadIncomingZaps(pubkey);
|
||||
try {
|
||||
const startSince = this._since - SINCE_WINDOW;
|
||||
await this._fetchWithBackoff(startSince, this._since);
|
||||
} finally {
|
||||
this._isLoadingMore = false;
|
||||
this.isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,13 +150,16 @@ export default class ActivityService extends Service {
|
||||
* activity route.
|
||||
*/
|
||||
stop() {
|
||||
if (this._sub) {
|
||||
this._sub.unsubscribe();
|
||||
this._sub = null;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -86,35 +168,182 @@ export default class ActivityService extends Service {
|
||||
super.willDestroy(...arguments);
|
||||
}
|
||||
|
||||
_updateItems(receipts, pubkey) {
|
||||
/**
|
||||
* 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);
|
||||
|
||||
for (const entry of newEntries) {
|
||||
this._resolveSender(entry);
|
||||
}
|
||||
|
||||
for (const entry of newEntries) {
|
||||
if (entry.osmId) {
|
||||
const bookmarkName = this.placeNameResolver.resolveBookmark(
|
||||
entry.osmId
|
||||
);
|
||||
if (bookmarkName) {
|
||||
entry.placeName = bookmarkName;
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._socialItems = [...this._socialItems, ...newEntries];
|
||||
this._mergeItems();
|
||||
|
||||
void this.placeNameResolver
|
||||
.resolveInBackground(newEntries)
|
||||
.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;
|
||||
|
||||
// Only keep zaps for the user's own kind 360 photos
|
||||
if (!enrichWithPhoto(entry, this.nostrData.store, pubkey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve sender profile (async, fire-and-forget)
|
||||
this._resolveSender(entry);
|
||||
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
// Sort newest-first by created_at
|
||||
entries.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
console.debug('[activity] Zap receipts received', {
|
||||
total: receipts.length,
|
||||
matched: entries.length,
|
||||
pubkey,
|
||||
});
|
||||
|
||||
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
||||
// first render shows bookmarked place names without a "Loading…" flicker.
|
||||
for (const entry of entries) {
|
||||
if (entry.osmId) {
|
||||
const bookmarkName = this.placeNameResolver.resolveBookmark(
|
||||
@@ -127,23 +356,91 @@ export default class ActivityService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
this.items = entries;
|
||||
return entries;
|
||||
}
|
||||
|
||||
_updateZapItems(receipts, pubkey) {
|
||||
const entries = this._processZapEvents(receipts, pubkey);
|
||||
|
||||
this._zapItems = entries;
|
||||
this._mergeItems();
|
||||
|
||||
// 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];
|
||||
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;
|
||||
|
||||
// Set up a ProfileModel subscription for this sender so the entry's
|
||||
// tracked fields update when the profile arrives from cache or network.
|
||||
// The event loader (configured in nostrData) auto-fetches missing kind 0
|
||||
// events from the IDB cache and relays.
|
||||
if (!this._profileSubs.has(pubkey)) {
|
||||
const sub = this.nostrData.store
|
||||
.model(ProfileModel, pubkey)
|
||||
@@ -153,17 +450,12 @@ export default class ActivityService extends Service {
|
||||
this._profileSubs.set(pubkey, sub);
|
||||
}
|
||||
|
||||
// Read immediately in case the profile is already cached
|
||||
this._applySenderProfile(entry, pubkey);
|
||||
}
|
||||
|
||||
_applySenderProfile(entry, pubkey) {
|
||||
// Try nostrData's profiles dict first (populated by other parts of the app)
|
||||
let profile = this.nostrData.getProfile(pubkey);
|
||||
|
||||
// Fall back to reading the kind 0 event directly from the store. This
|
||||
// handles the re-open case where the event is already in the store from
|
||||
// a previous load but nostrData.profiles wasn't populated by this service.
|
||||
if (!profile) {
|
||||
const event = this.nostrData.store.getReplaceable(0, pubkey);
|
||||
if (event) {
|
||||
@@ -179,7 +471,6 @@ export default class ActivityService extends Service {
|
||||
}
|
||||
|
||||
_applyProfileToPubkey(pubkey, profileContent) {
|
||||
// Update all entries matching this sender
|
||||
let changed = false;
|
||||
for (const entry of this.items) {
|
||||
if (entry.senderPubkey === pubkey) {
|
||||
@@ -192,7 +483,6 @@ export default class ActivityService extends Service {
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
// Trigger re-render
|
||||
this.items = [...this.items];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,11 @@ export default class NostrDataService extends Service {
|
||||
// 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.
|
||||
@@ -97,6 +102,12 @@ export default class NostrDataService extends Service {
|
||||
_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;
|
||||
_currentPlaceEntityId = null;
|
||||
@@ -600,6 +611,312 @@ export default class NostrDataService extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -637,6 +954,11 @@ export default class NostrDataService extends Service {
|
||||
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
|
||||
@@ -658,7 +980,19 @@ export default class NostrDataService extends Service {
|
||||
.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
|
||||
@@ -724,6 +1058,23 @@ export default class NostrDataService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when contacts are first loaded, or
|
||||
* immediately if already loaded. Times out after 5s so callers don't hang
|
||||
* forever if contacts never arrive.
|
||||
*
|
||||
* @param {number} [timeout=5000] Timeout in milliseconds
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
whenContactsLoaded(timeout = 5000) {
|
||||
if (this._contactPubkeys) return Promise.resolve();
|
||||
if (!this._contactsPromise) return Promise.resolve();
|
||||
return Promise.race([
|
||||
this._contactsPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, timeout)),
|
||||
]);
|
||||
}
|
||||
|
||||
get userDisplayName() {
|
||||
if (this.profile) {
|
||||
if (this.profile.nip05) {
|
||||
@@ -896,6 +1247,10 @@ export default class NostrDataService extends Service {
|
||||
this._incomingZapsNetworkSub.unsubscribe();
|
||||
this._incomingZapsNetworkSub = null;
|
||||
}
|
||||
if (this._activityPhotosNetworkSub) {
|
||||
this._activityPhotosNetworkSub.unsubscribe();
|
||||
this._activityPhotosNetworkSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
|
||||
@@ -56,11 +56,9 @@ export default class PlaceNameResolverService extends Service {
|
||||
*/
|
||||
async resolveInBackground(entries) {
|
||||
const pending = entries.filter((e) => e.placeNameLoading);
|
||||
if (pending.length === 0) {
|
||||
this._maybeBatchFetchNames(entries);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
@@ -71,20 +69,6 @@ export default class PlaceNameResolverService extends Service {
|
||||
})
|
||||
);
|
||||
|
||||
// Trigger re-render via caller, then kick off the network batch for
|
||||
// entries that are still loading.
|
||||
this._maybeBatchFetchNames(entries);
|
||||
}
|
||||
|
||||
_isUnresolvable(entry) {
|
||||
return this._unresolvable.has(entry.placeIdentifier);
|
||||
}
|
||||
|
||||
_maybeBatchFetchNames(entries) {
|
||||
const unresolved = entries.filter(
|
||||
(e) => e.placeNameLoading && !this._isUnresolvable(e)
|
||||
);
|
||||
|
||||
// Apply fallbacks for items already marked unresolvable in this session
|
||||
for (const entry of entries) {
|
||||
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
|
||||
@@ -93,52 +77,67 @@ export default class PlaceNameResolverService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolved.length === 0) {
|
||||
return;
|
||||
}
|
||||
const unresolved = entries.filter(
|
||||
(e) => e.placeNameLoading && !this._isUnresolvable(e)
|
||||
);
|
||||
if (unresolved.length === 0) return;
|
||||
|
||||
// Build a stable signature so we only fire one batch request per unique set
|
||||
// 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._lastBatchSignature = signature;
|
||||
|
||||
this._pendingBatchPromise = this._batchResolveNames(unresolved)
|
||||
.then((nameMap) => {
|
||||
// Merge resolved names back into the entries
|
||||
for (const entry of entries) {
|
||||
if (!entry.placeNameLoading) continue;
|
||||
if (nameMap.has(entry.placeIdentifier)) {
|
||||
const name = nameMap.get(entry.placeIdentifier);
|
||||
entry.placeName = name;
|
||||
entry.placeNameLoading = false;
|
||||
} else {
|
||||
// Could not be resolved (deleted object, network error for this item).
|
||||
// Mark as unresolvable for this session and use a fallback.
|
||||
this._unresolvable.add(entry.placeIdentifier);
|
||||
entry.placeName = this._fallbackName(entry);
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[place-name-resolver] Batch name resolution failed', e);
|
||||
// On a total failure, mark all as unresolvable and apply fallbacks
|
||||
for (const entry of entries) {
|
||||
if (entry.placeNameLoading) {
|
||||
this._unresolvable.add(entry.placeIdentifier);
|
||||
entry.placeName = this._fallbackName(entry);
|
||||
entry.placeNameLoading = false;
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
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) {
|
||||
|
||||
+91
-16
@@ -2417,6 +2417,38 @@ 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;
|
||||
@@ -2661,14 +2693,27 @@ button.create-place {
|
||||
}
|
||||
}
|
||||
|
||||
/* Activity Timeline — zap activity list rendered in the sidebar */
|
||||
/* Activity Timeline — activity list rendered in the sidebar */
|
||||
.activity-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: -1rem -1rem 0;
|
||||
}
|
||||
|
||||
.activity-zap-item {
|
||||
.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;
|
||||
@@ -2687,7 +2732,7 @@ button.create-place {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
& .zap-sender-avatar {
|
||||
& .sender-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -2696,7 +2741,7 @@ button.create-place {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
& .zap-sender-avatar-placeholder {
|
||||
& .sender-avatar-placeholder {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -2708,39 +2753,42 @@ button.create-place {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
& .zap-header {
|
||||
& .header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
& .zap-sender-line {
|
||||
& .sender-line {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
|
||||
& .zap-sender-name {
|
||||
& .sender-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .zap-action {
|
||||
& .action {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-amount {
|
||||
& .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;
|
||||
}
|
||||
|
||||
& .zap-message {
|
||||
& .message {
|
||||
color: var(--body-text-color);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
@@ -2762,7 +2810,7 @@ button.create-place {
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-context {
|
||||
& .context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -2770,19 +2818,20 @@ button.create-place {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 8px;
|
||||
|
||||
& .zap-context-images {
|
||||
& .context-images {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
& .zap-context-thumb {
|
||||
& .context-thumb {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
position: relative;
|
||||
|
||||
& img {
|
||||
width: 100%;
|
||||
@@ -2790,27 +2839,53 @@ button.create-place {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-context-text {
|
||||
& .context-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
& .zap-place-name {
|
||||
& .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;
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-date {
|
||||
& .activity-date {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes place-name-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ import ActivityTimeline from '#components/activity-timeline';
|
||||
{{#if @controller.mapUi.isSidebarVisible}}
|
||||
<ActivityTimeline
|
||||
@items={{@controller.items}}
|
||||
@isLoading={{@controller.loadTask.isRunning}}
|
||||
@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}}
|
||||
|
||||
+114
-1
@@ -14,7 +14,7 @@ import {
|
||||
getZapEventPointer,
|
||||
getZapRequest,
|
||||
} from 'applesauce-common/helpers';
|
||||
import { parsePhotoFromEvent } from './contributions';
|
||||
import { parsePhotoFromEvent, applyDeletions } from './contributions';
|
||||
|
||||
/**
|
||||
* A single activity timeline entry.
|
||||
@@ -27,6 +27,7 @@ export class ActivityEntry {
|
||||
type = 'zap';
|
||||
photoEventId;
|
||||
photo;
|
||||
photos = [];
|
||||
placeIdentifier;
|
||||
osmType;
|
||||
osmId;
|
||||
@@ -43,7 +44,10 @@ export class ActivityEntry {
|
||||
constructor({
|
||||
photoEventId,
|
||||
photo,
|
||||
photos,
|
||||
placeIdentifier,
|
||||
osmType,
|
||||
osmId,
|
||||
senderPubkey,
|
||||
amountSats,
|
||||
message,
|
||||
@@ -51,7 +55,10 @@ export class ActivityEntry {
|
||||
}) {
|
||||
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;
|
||||
@@ -140,3 +147,109 @@ export function enrichWithPhoto(entry, store, userPubkey) {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function parsePhotoFromEvent(event) {
|
||||
* @param {Array} events Mixed kind 360 and kind 5 events
|
||||
* @returns {Array} Surviving kind 360 events
|
||||
*/
|
||||
function applyDeletions(events) {
|
||||
export function applyDeletions(events) {
|
||||
const deletedIds = new Set();
|
||||
for (const event of events) {
|
||||
if (event.kind === 5) {
|
||||
|
||||
@@ -160,6 +160,7 @@ export const POI_ICON_RULES = [
|
||||
{ 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' },
|
||||
|
||||
@@ -55,7 +55,10 @@ export const POI_CATEGORIES = [
|
||||
id: 'accommodation',
|
||||
label: 'Hotels',
|
||||
icon: 'person-sleeping-in-bed',
|
||||
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'],
|
||||
filter: [
|
||||
'["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]',
|
||||
'["leisure"="resort"]',
|
||||
],
|
||||
types: ['node', 'way', 'relation'],
|
||||
},
|
||||
];
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -42,8 +42,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-qw72UzgO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CEhcTO-R.css">
|
||||
<script type="module" crossorigin src="/assets/main-D9MIfkGZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CZWBbnnc.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="modal-portal"></div>
|
||||
|
||||
@@ -23,9 +23,16 @@ class MockActivityService extends Service {
|
||||
senderProfileLoading: false,
|
||||
},
|
||||
];
|
||||
@tracked sourceMode = 'home';
|
||||
@tracked isLoadingMore = false;
|
||||
@tracked isLoading = false;
|
||||
|
||||
async load() {}
|
||||
|
||||
async setSourceMode() {}
|
||||
|
||||
loadMore() {}
|
||||
|
||||
stop() {
|
||||
this.items = [];
|
||||
}
|
||||
@@ -97,12 +104,12 @@ module('Acceptance | activity', function (hooks) {
|
||||
test('activity items are rendered as zap rows', async function (assert) {
|
||||
await visit('/activity');
|
||||
|
||||
await waitFor('.activity-zap-item');
|
||||
assert.dom('.activity-zap-item').exists({ count: 1 });
|
||||
assert.dom('.zap-sender-name').hasText('Alice');
|
||||
assert.dom('.zap-action').includesText('zapped your photo');
|
||||
assert.dom('.zap-amount').includesText('21 ⚡');
|
||||
assert.dom('.zap-message').includesText('Great photo!');
|
||||
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) {
|
||||
@@ -124,9 +131,9 @@ module('Acceptance | activity', function (hooks) {
|
||||
const mapUi = this.owner.lookup('service:map-ui');
|
||||
|
||||
await visit('/activity');
|
||||
await waitFor('.activity-zap-item');
|
||||
await waitFor('.activity-item');
|
||||
|
||||
await click('.activity-zap-item');
|
||||
await click('.activity-item');
|
||||
|
||||
assert.ok(
|
||||
currentURL().includes('/place/osm:node:123'),
|
||||
|
||||
@@ -13,6 +13,10 @@ module('Integration | Component | activity-timeline', function (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) {
|
||||
@@ -22,10 +26,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
||||
@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>
|
||||
);
|
||||
@@ -41,10 +49,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
||||
@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>
|
||||
);
|
||||
@@ -60,10 +72,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
||||
@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>
|
||||
);
|
||||
@@ -82,10 +98,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
||||
@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>
|
||||
);
|
||||
@@ -135,15 +155,19 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
||||
@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-zap-item').exists({ count: 2 });
|
||||
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');
|
||||
@@ -162,10 +186,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
||||
@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>
|
||||
);
|
||||
@@ -173,4 +201,214 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,15 +36,15 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.zap-sender-name').hasText('Alice');
|
||||
assert.dom('.zap-action').includesText('zapped your photo');
|
||||
assert.dom('.zap-amount').hasText('21 ⚡');
|
||||
assert.dom('.zap-message').includesText('Great shot!');
|
||||
assert.dom('.sender-name').hasText('Alice');
|
||||
assert.dom('.action').includesText('zapped your photo');
|
||||
assert.dom('.amount').hasText('21 ⚡');
|
||||
assert.dom('.message').includesText('Great shot!');
|
||||
assert
|
||||
.dom('.zap-sender-avatar')
|
||||
.dom('.sender-avatar')
|
||||
.hasAttribute('src', 'https://x.com/avatar.jpg');
|
||||
assert
|
||||
.dom('.zap-context-thumb img')
|
||||
.dom('.context-thumb img')
|
||||
.hasAttribute('src', 'https://x.com/thumb.jpg');
|
||||
});
|
||||
|
||||
@@ -68,8 +68,8 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.zap-sender-avatar-placeholder').exists();
|
||||
assert.dom('.zap-sender-avatar').doesNotExist();
|
||||
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) {
|
||||
@@ -91,7 +91,7 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.zap-message').doesNotExist();
|
||||
assert.dom('.message').doesNotExist();
|
||||
});
|
||||
|
||||
test('it omits the context thumbnail when there is no photo', async function (assert) {
|
||||
@@ -113,8 +113,8 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.zap-context-thumb').doesNotExist();
|
||||
assert.dom('.zap-context-text').exists();
|
||||
assert.dom('.context-thumb').doesNotExist();
|
||||
assert.dom('.context-text').exists();
|
||||
});
|
||||
|
||||
test('clicking the item fires @onSelect with the item', async function (assert) {
|
||||
@@ -141,7 +141,7 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
</template>
|
||||
);
|
||||
|
||||
await click('.activity-zap-item');
|
||||
await click('.activity-item');
|
||||
|
||||
assert.strictEqual(selected, this.item);
|
||||
});
|
||||
@@ -168,14 +168,14 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.zap-context-text')
|
||||
.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 "Loading…" when placeNameLoading is true', async function (assert) {
|
||||
test('it shows no text when placeNameLoading is true', async function (assert) {
|
||||
this.item = new ActivityEntry({
|
||||
photoEventId: 'photo-1',
|
||||
photo: null,
|
||||
@@ -197,8 +197,11 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.zap-context-text .contribution-name-loading')
|
||||
.hasText('Loading…', 'Loading state is displayed');
|
||||
.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) {
|
||||
@@ -223,11 +226,11 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.zap-context-text .contribution-name-loading')
|
||||
.dom('.context-text .place-name-text')
|
||||
.hasText('Unnamed place', 'Fallback name is displayed');
|
||||
});
|
||||
|
||||
test('it displays place name in zap-place-name and date in zap-date', async function (assert) {
|
||||
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,
|
||||
@@ -248,14 +251,14 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
</template>
|
||||
);
|
||||
|
||||
const placeNameEl = this.element.querySelector('.zap-place-name');
|
||||
const dateEl = this.element.querySelector('.zap-date');
|
||||
const placeNameEl = this.element.querySelector('.activity-place-name');
|
||||
const dateEl = this.element.querySelector('.activity-date');
|
||||
|
||||
assert.ok(placeNameEl, 'zap-place-name element exists');
|
||||
assert.ok(dateEl, 'zap-date element exists');
|
||||
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 zap-place-name'
|
||||
'Place name is in activity-place-name'
|
||||
);
|
||||
assert.ok(dateEl.textContent.includes('hr ago'), 'Date contains hr ago');
|
||||
assert.notOk(
|
||||
@@ -290,7 +293,9 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.zap-place-name').exists('zap-place-name element exists');
|
||||
assert.dom('.zap-date').hasText('1 hr ago', 'Date is still visible');
|
||||
assert
|
||||
.dom('.activity-place-name')
|
||||
.exists('activity-place-name element exists');
|
||||
assert.dom('.activity-date').hasText('1 hr ago', 'Date is still visible');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,6 +394,7 @@ 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 });
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import TabNav from 'marco/components/tab-nav';
|
||||
|
||||
module('Integration | Component | tab-nav', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders all tab buttons with labels', async function (assert) {
|
||||
this.tabs = [
|
||||
{ label: 'Home', value: 'home' },
|
||||
{ label: 'Explore', value: 'explore' },
|
||||
];
|
||||
this.active = 'home';
|
||||
this.onChange = () => {};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<TabNav
|
||||
@tabs={{this.tabs}}
|
||||
@active={{this.active}}
|
||||
@onChange={{this.onChange}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.tab-nav-button').exists({ count: 2 });
|
||||
const buttons = this.element.querySelectorAll('.tab-nav-button');
|
||||
assert.strictEqual(buttons[0].textContent.trim(), 'Home');
|
||||
assert.strictEqual(buttons[1].textContent.trim(), 'Explore');
|
||||
});
|
||||
|
||||
test('it applies is-active class to the active tab', async function (assert) {
|
||||
this.tabs = [
|
||||
{ label: 'Home', value: 'home' },
|
||||
{ label: 'Explore', value: 'explore' },
|
||||
];
|
||||
this.active = 'explore';
|
||||
this.onChange = () => {};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<TabNav
|
||||
@tabs={{this.tabs}}
|
||||
@active={{this.active}}
|
||||
@onChange={{this.onChange}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
const buttons = this.element.querySelectorAll('.tab-nav-button');
|
||||
assert.false(
|
||||
buttons[0].classList.contains('is-active'),
|
||||
'first tab is not active'
|
||||
);
|
||||
assert.true(
|
||||
buttons[1].classList.contains('is-active'),
|
||||
'second tab is active'
|
||||
);
|
||||
});
|
||||
|
||||
test('clicking a tab fires onChange with the tab value', async function (assert) {
|
||||
this.tabs = [
|
||||
{ label: 'Home', value: 'home' },
|
||||
{ label: 'Explore', value: 'explore' },
|
||||
];
|
||||
this.active = 'home';
|
||||
this.handleChange = (value) => {
|
||||
this.active = value;
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<TabNav
|
||||
@tabs={{this.tabs}}
|
||||
@active={{this.active}}
|
||||
@onChange={{this.handleChange}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
const buttons = this.element.querySelectorAll('.tab-nav-button');
|
||||
await click(buttons[1]);
|
||||
|
||||
assert.strictEqual(this.active, 'explore', 'onChange called with explore');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,16 @@ import {
|
||||
|
||||
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);
|
||||
|
||||
@@ -64,7 +74,7 @@ function makePhotoEvent(opts = {}) {
|
||||
id: opts.id || PHOTO_EVENT_ID_1,
|
||||
pubkey: opts.author || USER_PUBKEY,
|
||||
kind: 360,
|
||||
created_at: 5000,
|
||||
created_at: opts.created_at || 5000,
|
||||
tags: [
|
||||
['i', opts.placeIdentifier || 'osm:node:123'],
|
||||
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
|
||||
@@ -76,6 +86,7 @@ function makePhotoEvent(opts = {}) {
|
||||
|
||||
class MockNostrDataService extends Service {
|
||||
@tracked profiles = {};
|
||||
_contactPubkeys = new Set();
|
||||
|
||||
store = {
|
||||
events: new Map(),
|
||||
@@ -112,6 +123,20 @@ class MockNostrDataService extends Service {
|
||||
};
|
||||
|
||||
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];
|
||||
@@ -125,9 +150,10 @@ module('Unit | Service | activity', function (hooks) {
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.owner.register('service:nostrData', MockNostrDataService);
|
||||
this.owner.register('service:placeNameResolver', MockPlaceNameResolver);
|
||||
});
|
||||
|
||||
test('_updateItems parses receipts and enriches with photos from the store', function (assert) {
|
||||
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,
|
||||
@@ -140,7 +166,7 @@ module('Unit | Service | activity', function (hooks) {
|
||||
message: 'Love it!',
|
||||
});
|
||||
|
||||
service._updateItems([receipt], USER_PUBKEY);
|
||||
service._updateZapItems([receipt], USER_PUBKEY);
|
||||
|
||||
assert.strictEqual(service.items.length, 1);
|
||||
assert.strictEqual(service.items[0].type, 'zap');
|
||||
@@ -151,7 +177,7 @@ module('Unit | Service | activity', function (hooks) {
|
||||
assert.ok(service.items[0].photo, 'photo is populated');
|
||||
});
|
||||
|
||||
test('_updateItems filters out zaps for non-photo events', function (assert) {
|
||||
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,
|
||||
@@ -163,12 +189,12 @@ module('Unit | Service | activity', function (hooks) {
|
||||
|
||||
const receipt = makeZapReceiptEvent({ zappedEventId: TEXT_EVENT_ID });
|
||||
|
||||
service._updateItems([receipt], USER_PUBKEY);
|
||||
service._updateZapItems([receipt], USER_PUBKEY);
|
||||
|
||||
assert.strictEqual(service.items.length, 0, 'non-photo zap filtered out');
|
||||
});
|
||||
|
||||
test('_updateItems filters out zaps for photos not authored by the user', function (assert) {
|
||||
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,
|
||||
@@ -180,7 +206,7 @@ module('Unit | Service | activity', function (hooks) {
|
||||
zappedEventId: PHOTO_EVENT_ID_OTHER,
|
||||
});
|
||||
|
||||
service._updateItems([receipt], USER_PUBKEY);
|
||||
service._updateZapItems([receipt], USER_PUBKEY);
|
||||
|
||||
assert.strictEqual(
|
||||
service.items.length,
|
||||
@@ -189,7 +215,7 @@ module('Unit | Service | activity', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('_updateItems filters out zaps not directed at the user', function (assert) {
|
||||
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);
|
||||
@@ -199,7 +225,7 @@ module('Unit | Service | activity', function (hooks) {
|
||||
zappedEventId: PHOTO_EVENT_ID_1,
|
||||
});
|
||||
|
||||
service._updateItems([receipt], USER_PUBKEY);
|
||||
service._updateZapItems([receipt], USER_PUBKEY);
|
||||
|
||||
assert.strictEqual(
|
||||
service.items.length,
|
||||
@@ -208,7 +234,7 @@ module('Unit | Service | activity', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('_updateItems sorts entries newest-first', function (assert) {
|
||||
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 }));
|
||||
@@ -224,7 +250,7 @@ module('Unit | Service | activity', function (hooks) {
|
||||
created_at: 9000,
|
||||
});
|
||||
|
||||
service._updateItems([oldReceipt, newReceipt], USER_PUBKEY);
|
||||
service._updateZapItems([oldReceipt, newReceipt], USER_PUBKEY);
|
||||
|
||||
assert.strictEqual(service.items.length, 2);
|
||||
assert.strictEqual(service.items[0].createdAt, 9000, 'newest first');
|
||||
@@ -234,7 +260,7 @@ module('Unit | Service | activity', function (hooks) {
|
||||
test('stop clears items and resets state', function (assert) {
|
||||
const service = this.owner.lookup('service:activity');
|
||||
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 }));
|
||||
service._updateItems(
|
||||
service._updateZapItems(
|
||||
[makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID_1 })],
|
||||
USER_PUBKEY
|
||||
);
|
||||
@@ -247,13 +273,17 @@ module('Unit | Service | activity', function (hooks) {
|
||||
assert.strictEqual(service._userPubkey, null);
|
||||
});
|
||||
|
||||
test('load with no pubkey clears items', async function (assert) {
|
||||
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, 0);
|
||||
assert.strictEqual(
|
||||
service.items.length,
|
||||
1,
|
||||
'items preserved when already loaded'
|
||||
);
|
||||
});
|
||||
|
||||
test('_resolveSender applies profile when available', function (assert) {
|
||||
@@ -270,4 +300,503 @@ module('Unit | Service | activity', function (hooks) {
|
||||
assert.strictEqual(entry.senderAvatar, 'https://x.com/avatar.jpg');
|
||||
assert.false(entry.senderProfileLoading);
|
||||
});
|
||||
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
import { Subject, EMPTY } from 'rxjs';
|
||||
import { Subject, EMPTY, of } from 'rxjs';
|
||||
import Service from '@ember/service';
|
||||
import NostrDataService from 'marco/services/nostr-data';
|
||||
import { getGeohashPrefixesInBbox } from 'marco/utils/geohash-coverage';
|
||||
@@ -205,6 +205,58 @@ module('Unit | Service | nostr-data | contacts', function (hooks) {
|
||||
);
|
||||
});
|
||||
|
||||
test('whenContactsLoaded resolves after contacts are loaded', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const pubkey = makePubkey(1);
|
||||
const contactA = makePubkey(2);
|
||||
|
||||
// Start loadProfile (sets up the deferred + subscriptions)
|
||||
await service.loadProfile(pubkey);
|
||||
|
||||
// Contacts not loaded yet — whenContactsLoaded should not resolve yet
|
||||
// (it will resolve after timeout, but we'll add the event first)
|
||||
|
||||
// Add a contacts event to the store — the ContactsModel subscription fires
|
||||
service.store.add(makeContactsEvent(pubkey, [contactA]));
|
||||
|
||||
// Give the model a tick to process
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
await service.whenContactsLoaded();
|
||||
|
||||
assert.ok(service._contactPubkeys, 'contacts loaded');
|
||||
assert.true(
|
||||
service._contactPubkeys.has(contactA),
|
||||
'contact pubkey present'
|
||||
);
|
||||
});
|
||||
|
||||
test('whenContactsLoaded resolves immediately when contacts already loaded', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const pubkey = makePubkey(1);
|
||||
const contactA = makePubkey(2);
|
||||
|
||||
service.store.add(makeContactsEvent(pubkey, [contactA]));
|
||||
await service.loadProfile(pubkey);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Contacts are already loaded — whenContactsLoaded should resolve immediately
|
||||
await service.whenContactsLoaded();
|
||||
|
||||
assert.true(service._contactPubkeys.has(contactA), 'contacts available');
|
||||
});
|
||||
|
||||
test('whenContactsLoaded resolves immediately when no profile loaded', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
// No loadProfile called — should resolve immediately
|
||||
await service.whenContactsLoaded();
|
||||
|
||||
assert.true(true, 'resolved without hanging');
|
||||
});
|
||||
|
||||
test('kind 3 events are persisted to IDB cache', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
@@ -856,6 +908,331 @@ module('Unit | Service | nostr-data | my contributions', function (hooks) {
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | activity photos', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('loadActivityPhotos defers when contacts are not loaded yet', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
// _contactPubkeys is null initially (no loadProfile called)
|
||||
assert.strictEqual(service._contactPubkeys, null);
|
||||
|
||||
await service.loadActivityPhotos(1000, 'home');
|
||||
|
||||
// No network request should have been made
|
||||
const photoFilters = this.requestedFilters.filter(
|
||||
(f) => f.kinds?.includes(360) && f.authors
|
||||
);
|
||||
assert.strictEqual(photoFilters.length, 0, 'no request without contacts');
|
||||
});
|
||||
|
||||
test('loadActivityPhotos batches >100 pubkeys into multiple filters', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
// Create 250 contact pubkeys
|
||||
const contactPubkeys = Array.from({ length: 250 }, (_, i) =>
|
||||
makePubkey(100 + i)
|
||||
);
|
||||
|
||||
// Load contacts so _contactPubkeys is populated
|
||||
service.store.add(makeContactsEvent(userPubkey, contactPubkeys));
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
await service.loadActivityPhotos(1000, 'home');
|
||||
|
||||
const photoFilters = this.requestedFilters.filter(
|
||||
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
|
||||
);
|
||||
assert.strictEqual(
|
||||
photoFilters.length,
|
||||
3,
|
||||
'250 pubkeys → 3 filters (100+100+50)'
|
||||
);
|
||||
assert.strictEqual(photoFilters[0].authors.length, 100);
|
||||
assert.strictEqual(photoFilters[1].authors.length, 100);
|
||||
assert.strictEqual(photoFilters[2].authors.length, 50);
|
||||
});
|
||||
|
||||
test('loadActivityPhotos includes since in filter', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const contactPubkey = makePubkey(2);
|
||||
|
||||
service.store.add(makeContactsEvent(userPubkey, [contactPubkey]));
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
const since = 12345;
|
||||
await service.loadActivityPhotos(since, 'home');
|
||||
|
||||
const photoFilter = this.requestedFilters.find(
|
||||
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
|
||||
);
|
||||
assert.ok(photoFilter, 'photo filter with since found');
|
||||
assert.strictEqual(photoFilter.since, since, 'since value matches');
|
||||
assert.deepEqual(photoFilter.kinds, [360], 'requests kind 360');
|
||||
});
|
||||
|
||||
test('loadActivityPhotos re-triggers when contacts arrive', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const contactPubkey = makePubkey(2);
|
||||
|
||||
// Call loadActivityPhotos before contacts are loaded
|
||||
await service.loadActivityPhotos(1000, 'home');
|
||||
|
||||
const beforeCount = this.requestedFilters.filter(
|
||||
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
|
||||
).length;
|
||||
assert.strictEqual(beforeCount, 0, 'no request before contacts');
|
||||
|
||||
// Now load contacts — the ContactsModel callback should re-trigger
|
||||
service.store.add(makeContactsEvent(userPubkey, [contactPubkey]));
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Give the callback a tick to propagate
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const afterCount = this.requestedFilters.filter(
|
||||
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
|
||||
).length;
|
||||
assert.ok(afterCount > 0, 'request made after contacts arrived');
|
||||
});
|
||||
|
||||
test("loadActivityPhotos in 'explore' mode fetches all photos without authors filter", async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const since = 9999;
|
||||
await service.loadActivityPhotos(since, 'explore');
|
||||
|
||||
// Should make a request immediately (no contacts dependency)
|
||||
const photoFilter = this.requestedFilters.find(
|
||||
(f) => f.kinds?.includes(360) && f.since !== undefined && !f.authors
|
||||
);
|
||||
assert.ok(photoFilter, 'photo filter without authors found');
|
||||
assert.strictEqual(photoFilter.since, since, 'since value matches');
|
||||
assert.deepEqual(photoFilter.kinds, [360], 'requests kind 360');
|
||||
assert.notOk(photoFilter.authors, 'no authors filter in explore mode');
|
||||
});
|
||||
|
||||
test('_batchAuthorFilters produces correct batches', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const pubkeys = Array.from({ length: 250 }, (_, i) => makePubkey(i));
|
||||
const filters = service._batchAuthorFilters(pubkeys, [360], 1000);
|
||||
|
||||
assert.strictEqual(filters.length, 3, '250 → 3 filters');
|
||||
assert.strictEqual(filters[0].authors.length, 100);
|
||||
assert.strictEqual(filters[1].authors.length, 100);
|
||||
assert.strictEqual(filters[2].authors.length, 50);
|
||||
assert.deepEqual(filters[0].kinds, [360]);
|
||||
assert.strictEqual(filters[0].since, 1000);
|
||||
assert.notOk(filters[0].until, 'no until when not provided');
|
||||
});
|
||||
|
||||
test('_batchAuthorFilters includes until when provided', function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const pubkeys = Array.from({ length: 5 }, (_, i) => makePubkey(i));
|
||||
const filters = service._batchAuthorFilters(pubkeys, [360], 1000, 2000);
|
||||
|
||||
assert.strictEqual(filters.length, 1);
|
||||
assert.strictEqual(filters[0].since, 1000);
|
||||
assert.strictEqual(filters[0].until, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | fetchActivityPhotos', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
test('fetchActivityPhotos in home mode returns events from network', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const contactPubkey = makePubkey(2);
|
||||
const photo = makePhotoEvent(contactPubkey, 'osm:node:100', {
|
||||
id: makeEventId(100),
|
||||
});
|
||||
|
||||
service.store.add(makeContactsEvent(userPubkey, [contactPubkey]));
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
service.nostrRelay.pool.req = (_relays, filters) => {
|
||||
this.requestedFilters.push(...filters);
|
||||
return of({
|
||||
type: 'EVENT',
|
||||
event: photo,
|
||||
from: 'wss://relay.example',
|
||||
});
|
||||
};
|
||||
|
||||
const result = await service.fetchActivityPhotos(1000, undefined, 'home');
|
||||
const networkEvents = await result.networkEvents;
|
||||
|
||||
assert.ok(
|
||||
networkEvents.some((e) => e.id === photo.id),
|
||||
'photo event from network returned'
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchActivityPhotos includes until in filters', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const contactPubkey = makePubkey(2);
|
||||
|
||||
service.store.add(makeContactsEvent(userPubkey, [contactPubkey]));
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
service.nostrRelay.pool.req = (_relays, filters) => {
|
||||
this.requestedFilters.push(...filters);
|
||||
return EMPTY;
|
||||
};
|
||||
|
||||
await service.fetchActivityPhotos(1000, 2000, 'home');
|
||||
|
||||
const photoFilter = this.requestedFilters.find(
|
||||
(f) => f.kinds?.includes(360) && f.authors
|
||||
);
|
||||
assert.ok(photoFilter, 'photo filter found');
|
||||
assert.strictEqual(photoFilter.since, 1000, 'since value matches');
|
||||
assert.strictEqual(photoFilter.until, 2000, 'until value matches');
|
||||
});
|
||||
|
||||
test('fetchActivityPhotos in explore mode does not include authors filter', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.nostrRelay.pool.req = (_relays, filters) => {
|
||||
this.requestedFilters.push(...filters);
|
||||
return EMPTY;
|
||||
};
|
||||
|
||||
await service.fetchActivityPhotos(9999, undefined, 'explore');
|
||||
|
||||
const photoFilter = this.requestedFilters.find(
|
||||
(f) => f.kinds?.includes(360) && !f.authors
|
||||
);
|
||||
assert.ok(photoFilter, 'photo filter without authors found');
|
||||
assert.strictEqual(photoFilter.since, 9999);
|
||||
assert.notOk(photoFilter.until, 'no until when not provided');
|
||||
});
|
||||
|
||||
test('fetchActivityPhotos in explore mode includes until', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.nostrRelay.pool.req = (_relays, filters) => {
|
||||
this.requestedFilters.push(...filters);
|
||||
return EMPTY;
|
||||
};
|
||||
|
||||
await service.fetchActivityPhotos(1000, 2000, 'explore');
|
||||
|
||||
const photoFilter = this.requestedFilters.find(
|
||||
(f) => f.kinds?.includes(360) && !f.authors
|
||||
);
|
||||
assert.ok(photoFilter, 'photo filter found');
|
||||
assert.strictEqual(photoFilter.since, 1000);
|
||||
assert.strictEqual(photoFilter.until, 2000);
|
||||
});
|
||||
|
||||
test('fetchActivityPhotos returns empty array when no contacts in home mode', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.nostrRelay.pool.req = () => EMPTY;
|
||||
|
||||
const result = await service.fetchActivityPhotos(1000, undefined, 'home');
|
||||
|
||||
assert.strictEqual(
|
||||
result.cacheEvents.length,
|
||||
0,
|
||||
'empty cache events when no contacts'
|
||||
);
|
||||
const networkEvents = await result.networkEvents;
|
||||
assert.strictEqual(
|
||||
networkEvents.length,
|
||||
0,
|
||||
'empty network events when no contacts'
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchActivityPhotos deduplicates cache and network events', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
const userPubkey = makePubkey(1);
|
||||
const contactPubkey = makePubkey(2);
|
||||
const photo = makePhotoEvent(contactPubkey, 'osm:node:100', {
|
||||
id: makeEventId(100),
|
||||
});
|
||||
|
||||
service.store.add(makeContactsEvent(userPubkey, [contactPubkey]));
|
||||
await service.loadProfile(userPubkey);
|
||||
|
||||
// Add to IDB cache
|
||||
await service.cache.add(photo);
|
||||
|
||||
service.nostrRelay.pool.req = (_relays, filters) => {
|
||||
this.requestedFilters.push(...filters);
|
||||
return of({
|
||||
type: 'EVENT',
|
||||
event: photo,
|
||||
from: 'wss://relay.example',
|
||||
});
|
||||
};
|
||||
|
||||
const result = await service.fetchActivityPhotos(1000, undefined, 'home');
|
||||
const networkEvents = await result.networkEvents;
|
||||
|
||||
assert.strictEqual(
|
||||
result.cacheEvents.filter((e) => e.id === photo.id).length,
|
||||
1,
|
||||
'photo appears once in cache events'
|
||||
);
|
||||
assert.strictEqual(
|
||||
networkEvents.filter((e) => e.id === photo.id).length,
|
||||
0,
|
||||
'photo not in network events (deduplicated against cache)'
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchIncomingZaps includes since and until in filter', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.nostrRelay.pool.req = (_relays, filters) => {
|
||||
this.requestedFilters.push(...filters);
|
||||
return EMPTY;
|
||||
};
|
||||
|
||||
await service.fetchIncomingZaps(makePubkey(1), 1000, 2000);
|
||||
|
||||
const zapFilter = this.requestedFilters.find((f) =>
|
||||
f.kinds?.includes(9735)
|
||||
);
|
||||
assert.ok(zapFilter, 'zap filter found');
|
||||
assert.strictEqual(zapFilter.since, 1000, 'since value matches');
|
||||
assert.strictEqual(zapFilter.until, 2000, 'until value matches');
|
||||
});
|
||||
|
||||
test('fetchIncomingZaps without since/until does not include them', async function (assert) {
|
||||
const service = this.owner.lookup('service:nostr-data');
|
||||
|
||||
service.nostrRelay.pool.req = (_relays, filters) => {
|
||||
this.requestedFilters.push(...filters);
|
||||
return EMPTY;
|
||||
};
|
||||
|
||||
await service.fetchIncomingZaps(makePubkey(1));
|
||||
|
||||
const zapFilter = this.requestedFilters.find((f) =>
|
||||
f.kinds?.includes(9735)
|
||||
);
|
||||
assert.ok(zapFilter, 'zap filter found');
|
||||
assert.notOk('since' in zapFilter, 'no since when not provided');
|
||||
assert.notOk('until' in zapFilter, 'no until when not provided');
|
||||
});
|
||||
});
|
||||
|
||||
module('Unit | Service | nostr-data | zap receipts', function (hooks) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
|
||||
@@ -207,4 +207,70 @@ module('Unit | Service | place-name-resolver', function (hooks) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ActivityEntry,
|
||||
parseZapReceipt,
|
||||
enrichWithPhoto,
|
||||
groupSocialPhotos,
|
||||
} from 'marco/utils/activity';
|
||||
import {
|
||||
setVerifyWrappedEventMethod,
|
||||
@@ -209,3 +210,252 @@ module('Unit | Utility | activity', function () {
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,11 @@ 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);
|
||||
|
||||
@@ -35,4 +35,11 @@ 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