Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4a8d123d3
|
||
|
|
92c6190d28
|
||
|
|
36989e1dc2
|
||
|
|
256659fbfb
|
||
|
|
ad762b138f
|
@@ -0,0 +1,134 @@
|
||||
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"> added {{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}}
|
||||
{{this.placeName}}
|
||||
{{else if this.placeNameLoading}}
|
||||
<span class="contribution-name-loading">Loading…</span>
|
||||
{{else}}
|
||||
<span class="contribution-name-loading">Unnamed place</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
<span class="activity-date">{{formatRelativeDate
|
||||
@item.createdAt
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
}
|
||||
@@ -3,16 +3,24 @@ 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';
|
||||
|
||||
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 +55,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,6 +83,8 @@ 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}}
|
||||
</ul>
|
||||
|
||||
@@ -50,40 +50,40 @@ 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}}
|
||||
@@ -92,12 +92,14 @@ export default class ActivityZapItem extends Component {
|
||||
<span class="contribution-name-loading">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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -26,6 +25,15 @@ export default class ActivityController extends Controller {
|
||||
return this.nostrAuth.isConnected;
|
||||
}
|
||||
|
||||
get sourceMode() {
|
||||
return this.activity.sourceMode;
|
||||
}
|
||||
|
||||
@action
|
||||
setSourceMode(mode) {
|
||||
this.activity.setSourceMode(mode);
|
||||
}
|
||||
|
||||
@action
|
||||
selectItem(item) {
|
||||
if (!item || !item.placeIdentifier) return;
|
||||
|
||||
+173
-31
@@ -2,7 +2,13 @@ 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
|
||||
|
||||
/**
|
||||
* Orchestrates loading the user's incoming social activity (zaps received on
|
||||
@@ -25,45 +31,98 @@ export default class ActivityService extends Service {
|
||||
@service placeNameResolver;
|
||||
|
||||
@tracked items = [];
|
||||
@tracked sourceMode = 'home';
|
||||
|
||||
_sub = null;
|
||||
_socialSub = null;
|
||||
_profileSubs = new Map();
|
||||
_userPubkey = null;
|
||||
_zapItems = [];
|
||||
_socialItems = [];
|
||||
_lastSocialEvents = [];
|
||||
_since = null;
|
||||
_sourceMode = 'home';
|
||||
|
||||
/**
|
||||
* Loads the user's incoming zap receipts and subscribes to live updates.
|
||||
* Loads the user's incoming zap receipts and social photo activity, then
|
||||
* subscribes to live updates.
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* @param {string|null} pubkey The user's Nostr pubkey, or null
|
||||
*/
|
||||
async load(pubkey) {
|
||||
if (!pubkey) {
|
||||
this.items = [];
|
||||
return;
|
||||
this._userPubkey = pubkey || null;
|
||||
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
|
||||
|
||||
if (pubkey) {
|
||||
const zapFilters = [{ kinds: [9735], '#p': [pubkey] }];
|
||||
|
||||
console.debug('[activity] Subscribing to zap receipts', {
|
||||
filters: zapFilters,
|
||||
pubkey,
|
||||
activeReadRelays: this.nostrData.activeReadRelays,
|
||||
});
|
||||
|
||||
// Subscribe to zap receipts (kind 9735 where #p = user)
|
||||
this._sub = this.nostrData.store
|
||||
.timeline(zapFilters)
|
||||
.subscribe((events) => {
|
||||
this._updateZapItems(events, pubkey);
|
||||
});
|
||||
}
|
||||
|
||||
this._userPubkey = pubkey;
|
||||
// Subscribe to all kind 360 photos in the time window. The callback
|
||||
// filters by source mode (e.g. followed contacts only) so we only
|
||||
// show photos from the relevant source.
|
||||
const photoFilters = [{ kinds: [360], since: this._since }];
|
||||
this._socialSub = this.nostrData.store
|
||||
.timeline(photoFilters)
|
||||
.subscribe((events) => {
|
||||
this._lastSocialEvents = events;
|
||||
this._updateSocialItems(events);
|
||||
});
|
||||
|
||||
const filters = [{ kinds: [9735], '#p': [pubkey] }];
|
||||
if (pubkey) {
|
||||
// Ensure the user's kind 360 photo events are in the store first so
|
||||
// enrichWithPhoto can look them up when zap receipts arrive.
|
||||
await this.nostrData.loadMyContributions(pubkey);
|
||||
|
||||
console.debug('[activity] Subscribing to zap receipts', {
|
||||
filters,
|
||||
pubkey,
|
||||
activeReadRelays: this.nostrData.activeReadRelays,
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
// Load social photos (contacts dependency handled internally — if
|
||||
// contacts haven't loaded yet, loadActivityPhotos returns early and
|
||||
// the ContactsModel callback re-triggers it when they arrive).
|
||||
await this.nostrData.loadActivityPhotos(this._since, this._sourceMode);
|
||||
}
|
||||
|
||||
// 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);
|
||||
/**
|
||||
* Switches the activity source mode (e.g. 'home' for followee photos,
|
||||
* 'explore' for all photos from trusted relays). Re-filters existing
|
||||
* events and re-fetches with the new mode.
|
||||
*
|
||||
* @param {string} mode The new source mode
|
||||
*/
|
||||
setSourceMode(mode) {
|
||||
if (mode === this._sourceMode) return;
|
||||
this._sourceMode = mode;
|
||||
this.sourceMode = mode;
|
||||
|
||||
// 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);
|
||||
// Re-filter existing events with the new mode
|
||||
if (this._lastSocialEvents.length > 0) {
|
||||
this._updateSocialItems(this._lastSocialEvents);
|
||||
}
|
||||
|
||||
// Re-fetch with the new mode
|
||||
if (this._since !== null) {
|
||||
this.nostrData.loadActivityPhotos(this._since, mode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,9 +134,17 @@ export default class ActivityService extends Service {
|
||||
this._sub.unsubscribe();
|
||||
this._sub = null;
|
||||
}
|
||||
if (this._socialSub) {
|
||||
this._socialSub.unsubscribe();
|
||||
this._socialSub = null;
|
||||
}
|
||||
this._cleanupProfileSubs();
|
||||
this._zapItems = [];
|
||||
this._socialItems = [];
|
||||
this._lastSocialEvents = [];
|
||||
this.items = [];
|
||||
this._userPubkey = null;
|
||||
this._since = null;
|
||||
this.placeNameResolver.reset();
|
||||
}
|
||||
|
||||
@@ -86,7 +153,7 @@ export default class ActivityService extends Service {
|
||||
super.willDestroy(...arguments);
|
||||
}
|
||||
|
||||
_updateItems(receipts, pubkey) {
|
||||
_updateZapItems(receipts, pubkey) {
|
||||
const entries = [];
|
||||
|
||||
for (const receipt of receipts) {
|
||||
@@ -113,8 +180,8 @@ export default class ActivityService extends Service {
|
||||
pubkey,
|
||||
});
|
||||
|
||||
// 2. Bookmark lookup is synchronous — resolve those immediately so the
|
||||
// first render shows bookmarked place names without a "Loading…" flicker.
|
||||
// Synchronous bookmark lookup — 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,15 +194,90 @@ export default class ActivityService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
this.items = entries;
|
||||
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.
|
||||
// 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) {
|
||||
// Filter by source mode (e.g. only followed contacts, excluding own photos)
|
||||
const filtered = events.filter((e) => this._matchesSourceMode(e));
|
||||
const entries = groupSocialPhotos(filtered);
|
||||
|
||||
// Resolve sender profiles (async, fire-and-forget per sender)
|
||||
for (const entry of entries) {
|
||||
this._resolveSender(entry);
|
||||
}
|
||||
|
||||
// Synchronous bookmark lookup for instant first render
|
||||
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();
|
||||
|
||||
// Async-resolve remaining place names from caches → network batch
|
||||
void this.placeNameResolver.resolveInBackground(entries).then(() => {
|
||||
this._mergeItems();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges `_zapItems` and `_socialItems` into `items`, sorted newest-first.
|
||||
*/
|
||||
_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;
|
||||
|
||||
@@ -97,6 +97,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 +606,143 @@ 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:'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const BATCH_SIZE = 100;
|
||||
const filters = [];
|
||||
for (let i = 0; i < pubkeys.length; i += BATCH_SIZE) {
|
||||
filters.push({
|
||||
kinds,
|
||||
authors: pubkeys.slice(i, i + BATCH_SIZE),
|
||||
since,
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
loadProfiles(pubkeys) {
|
||||
const newPubkeys = pubkeys.filter(
|
||||
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||
@@ -659,6 +802,15 @@ export default class NostrDataService extends Service {
|
||||
this.contacts = contacts;
|
||||
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
|
||||
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
|
||||
@@ -896,6 +1048,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) {
|
||||
|
||||
+64
-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,14 @@ 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-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
@@ -2687,7 +2719,7 @@ button.create-place {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
& .zap-sender-avatar {
|
||||
& .sender-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -2696,7 +2728,7 @@ button.create-place {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
& .zap-sender-avatar-placeholder {
|
||||
& .sender-avatar-placeholder {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -2708,39 +2740,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 +2797,7 @@ button.create-place {
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-context {
|
||||
& .context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -2770,19 +2805,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,16 +2826,28 @@ 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;
|
||||
@@ -2807,7 +2855,7 @@ button.create-place {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
& .zap-date {
|
||||
& .activity-date {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import ActivityTimeline from '#components/activity-timeline';
|
||||
@items={{@controller.items}}
|
||||
@isLoading={{@controller.loadTask.isRunning}}
|
||||
@isConnected={{@controller.isConnected}}
|
||||
@sourceMode={{@controller.sourceMode}}
|
||||
@onSetSourceMode={{@controller.setSourceMode}}
|
||||
@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 added 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'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -23,9 +23,12 @@ class MockActivityService extends Service {
|
||||
senderProfileLoading: false,
|
||||
},
|
||||
];
|
||||
@tracked sourceMode = 'home';
|
||||
|
||||
async load() {}
|
||||
|
||||
setSourceMode() {}
|
||||
|
||||
stop() {
|
||||
this.items = [];
|
||||
}
|
||||
@@ -97,12 +100,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 +127,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,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
this.noop = noop;
|
||||
this.emptyItems = [];
|
||||
this.sourceMode = 'home';
|
||||
this.onSetSourceMode = () => {};
|
||||
});
|
||||
|
||||
test('it renders a loading state', async function (assert) {
|
||||
@@ -22,6 +24,8 @@ 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}}
|
||||
@@ -41,6 +45,8 @@ 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}}
|
||||
@@ -60,6 +66,8 @@ 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}}
|
||||
@@ -82,6 +90,8 @@ 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}}
|
||||
@@ -135,6 +145,8 @@ 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}}
|
||||
@@ -143,7 +155,7 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
||||
</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,6 +174,8 @@ 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}}
|
||||
@@ -173,4 +187,123 @@ 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}}
|
||||
/>
|
||||
</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}}
|
||||
/>
|
||||
</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}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.activity-list').exists('items render in explore mode');
|
||||
assert.dom('.activity-list li').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}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
assert.dom('.empty-state').includesText('No activity yet.');
|
||||
assert.dom('.empty-state').doesNotIncludeText('Connect');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,7 +168,7 @@ 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')
|
||||
@@ -197,7 +197,7 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.zap-context-text .contribution-name-loading')
|
||||
.dom('.context-text .contribution-name-loading')
|
||||
.hasText('Loading…', 'Loading state is displayed');
|
||||
});
|
||||
|
||||
@@ -223,11 +223,11 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('.zap-context-text .contribution-name-loading')
|
||||
.dom('.context-text .contribution-name-loading')
|
||||
.hasText('Unnamed place', 'Fallback name is displayed');
|
||||
});
|
||||
|
||||
test('it displays place name in zap-place-name and date in zap-date', async function (assert) {
|
||||
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 +248,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 +290,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,7 @@ class MockNostrDataService extends Service {
|
||||
};
|
||||
|
||||
loadProfiles() {}
|
||||
loadActivityPhotos() {}
|
||||
|
||||
getProfile(pubkey) {
|
||||
return this.profiles[pubkey];
|
||||
@@ -125,9 +137,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 +153,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 +164,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 +176,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 +193,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 +202,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 +212,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 +221,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 +237,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 +247,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
|
||||
);
|
||||
@@ -270,4 +283,255 @@ 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 re-filters existing events', function (assert) {
|
||||
const service = this.owner.lookup('service:activity');
|
||||
service._userPubkey = USER_PUBKEY;
|
||||
service._sourceMode = 'explore';
|
||||
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
|
||||
// In explore mode, isTrustedEvent returns false (no provenance in mock)
|
||||
service.nostrData.isTrustedEvent = () => false;
|
||||
|
||||
const followedPhoto = makePhotoEvent({
|
||||
id: 'fp1'.padEnd(64, '0'),
|
||||
author: SENDER_PUBKEY,
|
||||
placeIdentifier: 'osm:node:100',
|
||||
created_at: 1000,
|
||||
});
|
||||
service._lastSocialEvents = [followedPhoto];
|
||||
|
||||
// In 'explore' mode, _matchesSourceMode returns false (isTrustedEvent is false)
|
||||
service._updateSocialItems([followedPhoto]);
|
||||
assert.strictEqual(
|
||||
service.items.length,
|
||||
0,
|
||||
'no social items in explore mode with untrusted events'
|
||||
);
|
||||
|
||||
// Switch to 'home' mode — now the followed photo should appear
|
||||
service.setSourceMode('home');
|
||||
assert.strictEqual(
|
||||
service.items.length,
|
||||
1,
|
||||
're-filtered shows followed contact in 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -856,6 +856,130 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
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