Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e5240af2
|
||
|
|
35aa3c950f
|
||
|
|
a4a8d123d3
|
||
|
|
92c6190d28
|
||
|
|
36989e1dc2
|
@@ -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 { tracked } from '@glimmer/tracking';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
import TabNav from './tab-nav';
|
||||||
import ActivityZapItem from './activity-zap-item';
|
import ActivityZapItem from './activity-zap-item';
|
||||||
|
import ActivityPhotoItem from './activity-photo-item';
|
||||||
import Modal from './modal';
|
import Modal from './modal';
|
||||||
import NostrConnect from './nostr-connect';
|
import NostrConnect from './nostr-connect';
|
||||||
import not from 'ember-truth-helpers/helpers/not';
|
import not from 'ember-truth-helpers/helpers/not';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
import and from 'ember-truth-helpers/helpers/and';
|
||||||
import restoreScroll from '../modifiers/restore-scroll';
|
import restoreScroll from '../modifiers/restore-scroll';
|
||||||
|
import observeIntersection from '../modifiers/observe-intersection';
|
||||||
|
|
||||||
export default class ActivityTimelineComponent extends Component {
|
export default class ActivityTimelineComponent extends Component {
|
||||||
@tracked isNostrConnectModalOpen = false;
|
@tracked isNostrConnectModalOpen = false;
|
||||||
|
|
||||||
|
tabs = [
|
||||||
|
{ label: 'Home', value: 'home' },
|
||||||
|
{ label: 'Explore', value: 'explore' },
|
||||||
|
];
|
||||||
|
|
||||||
@action
|
@action
|
||||||
openNostrConnectModal(event) {
|
openNostrConnectModal(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -47,12 +56,18 @@ export default class ActivityTimelineComponent extends Component {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TabNav
|
||||||
|
@tabs={{this.tabs}}
|
||||||
|
@active={{@sourceMode}}
|
||||||
|
@onChange={{@onSetSourceMode}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
|
||||||
{{#if @isLoading}}
|
{{#if @isLoading}}
|
||||||
<div class="sidebar-loading">
|
<div class="sidebar-loading">
|
||||||
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
|
||||||
</div>
|
</div>
|
||||||
{{else if (not @isConnected)}}
|
{{else if (and (not @isConnected) (eq @sourceMode "home"))}}
|
||||||
<p class="empty-state">
|
<p class="empty-state">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@@ -69,8 +84,17 @@ export default class ActivityTimelineComponent extends Component {
|
|||||||
{{#each @items as |item|}}
|
{{#each @items as |item|}}
|
||||||
{{#if (eq item.type "zap")}}
|
{{#if (eq item.type "zap")}}
|
||||||
<ActivityZapItem @item={{item}} @onSelect={{@onSelect}} />
|
<ActivityZapItem @item={{item}} @onSelect={{@onSelect}} />
|
||||||
|
{{else if (eq item.type "photo")}}
|
||||||
|
<ActivityPhotoItem @item={{item}} @onSelect={{@onSelect}} />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/each}}
|
{{/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>
|
</ul>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,54 +50,54 @@ export default class ActivityZapItem extends Component {
|
|||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="activity-zap-item"
|
class="activity-item"
|
||||||
{{on "click" (fn @onSelect @item)}}
|
{{on "click" (fn @onSelect @item)}}
|
||||||
>
|
>
|
||||||
<div class="zap-header">
|
<div class="header">
|
||||||
<span class="zap-sender-line">
|
<span class="sender-line">
|
||||||
<span class="zap-sender-name">{{this.senderDisplayName}}</span>
|
<span class="sender-name">{{this.senderDisplayName}}</span>
|
||||||
{{! template-lint-disable no-whitespace-for-layout }}
|
{{! template-lint-disable no-whitespace-for-layout }}
|
||||||
<span class="zap-action"> zapped your photo</span>
|
<span class="action"> zapped your photo</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="zap-amount">{{this.item.amountSats}} ⚡</span>
|
<span class="amount">{{this.item.amountSats}} ⚡</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="zap-context">
|
<div class="context">
|
||||||
<div class="zap-context-images">
|
<div class="context-images">
|
||||||
{{#if this.senderAvatar}}
|
{{#if this.senderAvatar}}
|
||||||
<img
|
<img
|
||||||
class="zap-sender-avatar"
|
class="sender-avatar"
|
||||||
src={{this.senderAvatar}}
|
src={{this.senderAvatar}}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="zap-sender-avatar-placeholder">
|
<div class="sender-avatar-placeholder">
|
||||||
<Icon @name="user" @size={{16}} @color="#999" />
|
<Icon @name="user" @size={{16}} @color="#999" />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{#if this.hasPhoto}}
|
{{#if this.hasPhoto}}
|
||||||
<div class="zap-context-thumb">
|
<div class="context-thumb">
|
||||||
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
|
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
<div class="zap-context-text">
|
<div class="context-text">
|
||||||
<span class="zap-place-name">
|
<span class="activity-place-name">
|
||||||
{{#if this.placeName}}
|
{{#if this.placeName}}
|
||||||
{{this.placeName}}
|
<span class="place-name-text">{{this.placeName}}</span>
|
||||||
{{else if this.placeNameLoading}}
|
{{else if this.placeNameLoading}}{{else}}
|
||||||
<span class="contribution-name-loading">Loading…</span>
|
<span class="place-name-text">Unnamed place</span>
|
||||||
{{else}}
|
|
||||||
<span class="contribution-name-loading">Unnamed place</span>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</span>
|
</span>
|
||||||
<span class="zap-date">{{formatRelativeDate @item.createdAt}}</span>
|
<span class="activity-date">{{formatRelativeDate
|
||||||
|
@item.createdAt
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.item.message}}
|
{{#if this.item.message}}
|
||||||
<div class="zap-message">{{this.item.message}}</div>
|
<div class="message">{{this.item.message}}</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default class ContributionsTimelineComponent extends Component {
|
|||||||
</button>
|
</button>
|
||||||
<h2 class="sidebar-header-text-centered">
|
<h2 class="sidebar-header-text-centered">
|
||||||
<span class="sidebar-header-icon-wrapper">
|
<span class="sidebar-header-icon-wrapper">
|
||||||
<Icon @name="activity" @size={{20}} @color="#898989" />
|
<Icon @name="user-check" @size={{20}} @color="#898989" />
|
||||||
</span>
|
</span>
|
||||||
My Contributions
|
My Contributions
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -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;
|
@service activity;
|
||||||
|
|
||||||
loadTask = task({ restartable: true }, async (pubkey) => {
|
loadTask = task({ restartable: true }, async (pubkey) => {
|
||||||
if (!pubkey) return;
|
|
||||||
await this.activity.load(pubkey);
|
await this.activity.load(pubkey);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,10 +21,32 @@ export default class ActivityController extends Controller {
|
|||||||
return this.activity.items;
|
return this.activity.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isLoading() {
|
||||||
|
return this.activity.isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
get isConnected() {
|
get isConnected() {
|
||||||
return this.nostrAuth.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
|
@action
|
||||||
selectItem(item) {
|
selectItem(item) {
|
||||||
if (!item || !item.placeIdentifier) return;
|
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() {
|
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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+299
-59
@@ -2,7 +2,15 @@ import Service, { service } from '@ember/service';
|
|||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||||
import { getProfileContent } from 'applesauce-core/helpers/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
|
* Orchestrates loading the user's incoming social activity (zaps received on
|
||||||
@@ -10,8 +18,7 @@ import { parseZapReceipt, enrichWithPhoto } from '../utils/activity';
|
|||||||
* timeline.
|
* timeline.
|
||||||
*
|
*
|
||||||
* The flow is:
|
* The flow is:
|
||||||
* 1. Subscribe to `nostrData.store.timeline(...)` for kind 9735 zap receipts
|
* 1. Fetch kind 9735 zap receipts where the user is the recipient.
|
||||||
* where the user is the recipient (`#p` filter).
|
|
||||||
* 2. Parse each receipt into an `ActivityEntry`, filtering to zaps on the
|
* 2. Parse each receipt into an `ActivityEntry`, filtering to zaps on the
|
||||||
* user's own kind 360 photos.
|
* user's own kind 360 photos.
|
||||||
* 3. Resolve sender profiles asynchronously via a per-sender `ProfileModel`
|
* 3. Resolve sender profiles asynchronously via a per-sender `ProfileModel`
|
||||||
@@ -25,45 +32,108 @@ export default class ActivityService extends Service {
|
|||||||
@service placeNameResolver;
|
@service placeNameResolver;
|
||||||
|
|
||||||
@tracked items = [];
|
@tracked items = [];
|
||||||
|
@tracked sourceMode = 'home';
|
||||||
|
@tracked isLoading = false;
|
||||||
|
@tracked isLoadingMore = false;
|
||||||
|
|
||||||
_sub = null;
|
|
||||||
_profileSubs = new Map();
|
_profileSubs = new Map();
|
||||||
_userPubkey = null;
|
_userPubkey = null;
|
||||||
|
_zapItems = [];
|
||||||
|
_socialItems = [];
|
||||||
|
_lastSocialEvents = [];
|
||||||
|
_since = null;
|
||||||
|
_sourceMode = 'home';
|
||||||
|
_isLoadingMore = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
async load(pubkey) {
|
||||||
if (!pubkey) {
|
if (this.items.length > 0 && pubkey) return;
|
||||||
this.items = [];
|
this._userPubkey = pubkey || null;
|
||||||
return;
|
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
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._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', {
|
this.isLoading = true;
|
||||||
filters,
|
|
||||||
pubkey,
|
|
||||||
activeReadRelays: this.nostrData.activeReadRelays,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subscribe to the store timeline so we get live updates as receipts
|
try {
|
||||||
// arrive and are added to the store.
|
const now = Math.floor(Date.now() / 1000);
|
||||||
this._sub = this.nostrData.store.timeline(filters).subscribe((events) => {
|
await this._fetchWithBackoff(this._since, now);
|
||||||
this._updateItems(events, pubkey);
|
} finally {
|
||||||
});
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the user's kind 360 photo events are in the store first so
|
/**
|
||||||
// enrichWithPhoto can look them up when zap receipts arrive.
|
* Loads older events by extending the time window further back.
|
||||||
await this.nostrData.loadMyContributions(pubkey);
|
* 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
|
try {
|
||||||
// timeline subscription, and by now the photo events are available.
|
const startSince = this._since - SINCE_WINDOW;
|
||||||
await this.nostrData.loadIncomingZaps(pubkey);
|
await this._fetchWithBackoff(startSince, this._since);
|
||||||
|
} finally {
|
||||||
|
this._isLoadingMore = false;
|
||||||
|
this.isLoadingMore = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,13 +141,16 @@ export default class ActivityService extends Service {
|
|||||||
* activity route.
|
* activity route.
|
||||||
*/
|
*/
|
||||||
stop() {
|
stop() {
|
||||||
if (this._sub) {
|
|
||||||
this._sub.unsubscribe();
|
|
||||||
this._sub = null;
|
|
||||||
}
|
|
||||||
this._cleanupProfileSubs();
|
this._cleanupProfileSubs();
|
||||||
|
this._zapItems = [];
|
||||||
|
this._socialItems = [];
|
||||||
|
this._lastSocialEvents = [];
|
||||||
this.items = [];
|
this.items = [];
|
||||||
this._userPubkey = null;
|
this._userPubkey = null;
|
||||||
|
this._since = null;
|
||||||
|
this._isLoadingMore = false;
|
||||||
|
this.isLoadingMore = false;
|
||||||
|
this.isLoading = false;
|
||||||
this.placeNameResolver.reset();
|
this.placeNameResolver.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,35 +159,141 @@ export default class ActivityService extends Service {
|
|||||||
super.willDestroy(...arguments);
|
super.willDestroy(...arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateItems(receipts, pubkey) {
|
/**
|
||||||
|
* Fetches photos and zaps for a single time window, processes them, and
|
||||||
|
* appends to the respective item arrays.
|
||||||
|
*
|
||||||
|
* @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 newEvents = await this.nostrData.fetchActivityPhotos(
|
||||||
|
batchSince,
|
||||||
|
batchUntil,
|
||||||
|
this._sourceMode
|
||||||
|
);
|
||||||
|
|
||||||
|
let newZapEvents = [];
|
||||||
|
if (this._sourceMode === 'home' && this._userPubkey) {
|
||||||
|
newZapEvents = await this.nostrData.fetchIncomingZaps(
|
||||||
|
this._userPubkey,
|
||||||
|
batchSince,
|
||||||
|
batchUntil
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEvents.length === 0 && newZapEvents.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let added = false;
|
||||||
|
|
||||||
|
if (newEvents.length > 0) {
|
||||||
|
this._lastSocialEvents = [...this._lastSocialEvents, ...newEvents];
|
||||||
|
|
||||||
|
const filtered = newEvents.filter((e) => this._matchesSourceMode(e));
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
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());
|
||||||
|
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newZapEvents.length > 0) {
|
||||||
|
const newZapEntries = this._processZapEvents(
|
||||||
|
newZapEvents,
|
||||||
|
this._userPubkey
|
||||||
|
);
|
||||||
|
if (newZapEntries.length > 0) {
|
||||||
|
this._zapItems = [...this._zapItems, ...newZapEntries];
|
||||||
|
this._mergeItems();
|
||||||
|
|
||||||
|
void this.placeNameResolver
|
||||||
|
.resolveInBackground(newZapEntries)
|
||||||
|
.then(() => this._mergeItems());
|
||||||
|
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [];
|
const entries = [];
|
||||||
|
|
||||||
for (const receipt of receipts) {
|
for (const receipt of receipts) {
|
||||||
const entry = parseZapReceipt(receipt, pubkey);
|
const entry = parseZapReceipt(receipt, pubkey);
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
|
|
||||||
// Only keep zaps for the user's own kind 360 photos
|
|
||||||
if (!enrichWithPhoto(entry, this.nostrData.store, pubkey)) {
|
if (!enrichWithPhoto(entry, this.nostrData.store, pubkey)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve sender profile (async, fire-and-forget)
|
|
||||||
this._resolveSender(entry);
|
this._resolveSender(entry);
|
||||||
|
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort newest-first by created_at
|
|
||||||
entries.sort((a, b) => b.createdAt - a.createdAt);
|
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) {
|
for (const entry of entries) {
|
||||||
if (entry.osmId) {
|
if (entry.osmId) {
|
||||||
const bookmarkName = this.placeNameResolver.resolveBookmark(
|
const bookmarkName = this.placeNameResolver.resolveBookmark(
|
||||||
@@ -127,23 +306,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(() => {
|
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) {
|
_resolveSender(entry) {
|
||||||
const pubkey = entry.senderPubkey;
|
const pubkey = entry.senderPubkey;
|
||||||
if (!pubkey) return;
|
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)) {
|
if (!this._profileSubs.has(pubkey)) {
|
||||||
const sub = this.nostrData.store
|
const sub = this.nostrData.store
|
||||||
.model(ProfileModel, pubkey)
|
.model(ProfileModel, pubkey)
|
||||||
@@ -153,17 +400,12 @@ export default class ActivityService extends Service {
|
|||||||
this._profileSubs.set(pubkey, sub);
|
this._profileSubs.set(pubkey, sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read immediately in case the profile is already cached
|
|
||||||
this._applySenderProfile(entry, pubkey);
|
this._applySenderProfile(entry, pubkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
_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);
|
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) {
|
if (!profile) {
|
||||||
const event = this.nostrData.store.getReplaceable(0, pubkey);
|
const event = this.nostrData.store.getReplaceable(0, pubkey);
|
||||||
if (event) {
|
if (event) {
|
||||||
@@ -179,7 +421,6 @@ export default class ActivityService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_applyProfileToPubkey(pubkey, profileContent) {
|
_applyProfileToPubkey(pubkey, profileContent) {
|
||||||
// Update all entries matching this sender
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const entry of this.items) {
|
for (const entry of this.items) {
|
||||||
if (entry.senderPubkey === pubkey) {
|
if (entry.senderPubkey === pubkey) {
|
||||||
@@ -192,7 +433,6 @@ export default class ActivityService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
// Trigger re-render
|
|
||||||
this.items = [...this.items];
|
this.items = [...this.items];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export default class NostrDataService extends Service {
|
|||||||
// trust lookups. Rebuilt whenever contacts change.
|
// trust lookups. Rebuilt whenever contacts change.
|
||||||
_contactPubkeys = null;
|
_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.
|
// Session-only reveal toggle for untrusted content. Not persisted.
|
||||||
@tracked showUntrustedContent = false;
|
@tracked showUntrustedContent = false;
|
||||||
// Count of currently-hidden (untrusted) place photos for the selected place.
|
// Count of currently-hidden (untrusted) place photos for the selected place.
|
||||||
@@ -97,6 +102,12 @@ export default class NostrDataService extends Service {
|
|||||||
_lastPhotoIds = new Set();
|
_lastPhotoIds = new Set();
|
||||||
_incomingZapsNetworkSub = null;
|
_incomingZapsNetworkSub = null;
|
||||||
|
|
||||||
|
// Activity photos state: tracks the current time window and source mode
|
||||||
|
// so that the contacts callback can re-trigger when contacts arrive.
|
||||||
|
_activityPhotosSince = null;
|
||||||
|
_activityPhotosMode = 'home';
|
||||||
|
_activityPhotosNetworkSub = null;
|
||||||
|
|
||||||
_requestSub = null;
|
_requestSub = null;
|
||||||
_cachePromise = null;
|
_cachePromise = null;
|
||||||
_currentPlaceEntityId = null;
|
_currentPlaceEntityId = null;
|
||||||
@@ -600,6 +611,305 @@ 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 as a Promise
|
||||||
|
* that resolves on EOSE. Loads from the IDB cache first (instant), then from
|
||||||
|
* the network with provenance tracking for trust filtering.
|
||||||
|
*
|
||||||
|
* - `'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<object[]>} Deduplicated events
|
||||||
|
*/
|
||||||
|
async fetchActivityPhotos(since, until, mode = 'home') {
|
||||||
|
let filters;
|
||||||
|
if (mode === 'home') {
|
||||||
|
const pubkeys = this._contactPubkeys
|
||||||
|
? Array.from(this._contactPubkeys)
|
||||||
|
: [];
|
||||||
|
if (pubkeys.length === 0) return [];
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Populate the store from the local Nostr IDB cache (instant)
|
||||||
|
const cacheEvents = await this._cachePromise
|
||||||
|
.then(() => this.cache.query(filters))
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const all = [];
|
||||||
|
|
||||||
|
if (cacheEvents) {
|
||||||
|
for (const event of cacheEvents) {
|
||||||
|
if (!seen.has(event.id)) {
|
||||||
|
seen.add(event.id);
|
||||||
|
all.push(event);
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Request fresh events from the network (resolves on EOSE)
|
||||||
|
const networkEvents = await this._fetchEventsWithProvenance(
|
||||||
|
filters,
|
||||||
|
'[nostr-data] Error fetching activity photos:'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const event of networkEvents) {
|
||||||
|
if (!seen.has(event.id)) {
|
||||||
|
seen.add(event.id);
|
||||||
|
all.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches incoming zap receipts (kind 9735) where the user is the recipient,
|
||||||
|
* as a Promise that resolves on EOSE. Loads from the IDB cache first, then
|
||||||
|
* from the network.
|
||||||
|
*
|
||||||
|
* @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<object[]>} Deduplicated zap receipt events
|
||||||
|
*/
|
||||||
|
async fetchIncomingZaps(pubkey, since, until) {
|
||||||
|
if (!pubkey) return [];
|
||||||
|
|
||||||
|
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 all = [];
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
all.push(event);
|
||||||
|
this.store.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Network (resolves on EOSE)
|
||||||
|
const networkEvents = await this._fetchEventsWithProvenance(
|
||||||
|
filters,
|
||||||
|
'[nostr-data] Error fetching incoming zap receipts:'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const event of networkEvents) {
|
||||||
|
if (!seen.has(event.id)) {
|
||||||
|
seen.add(event.id);
|
||||||
|
all.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an array of Nostr filters, each with ≤ `BATCH_SIZE` author pubkeys.
|
||||||
|
*
|
||||||
|
* @param {string[]} pubkeys Author pubkeys to batch
|
||||||
|
* @param {number[]} kinds Event kinds to request
|
||||||
|
* @param {number} since Unix timestamp (seconds) for the start of the window
|
||||||
|
* @returns {object[]} Array of filter objects
|
||||||
|
*/
|
||||||
|
_batchAuthorFilters(pubkeys, kinds, since, until) {
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
const filters = [];
|
||||||
|
for (let i = 0; i < pubkeys.length; i += BATCH_SIZE) {
|
||||||
|
const filter = {
|
||||||
|
kinds,
|
||||||
|
authors: pubkeys.slice(i, i + BATCH_SIZE),
|
||||||
|
since,
|
||||||
|
};
|
||||||
|
if (until !== undefined) filter.until = until;
|
||||||
|
filters.push(filter);
|
||||||
|
}
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
loadProfiles(pubkeys) {
|
loadProfiles(pubkeys) {
|
||||||
const newPubkeys = pubkeys.filter(
|
const newPubkeys = pubkeys.filter(
|
||||||
(pk) => pk && !this._profileModelSubs.has(pk)
|
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||||
@@ -637,6 +947,11 @@ export default class NostrDataService extends Service {
|
|||||||
this._contactPubkeys = null;
|
this._contactPubkeys = null;
|
||||||
this.blossomServers = [];
|
this.blossomServers = [];
|
||||||
|
|
||||||
|
// Create a deferred that resolves when contacts are first loaded
|
||||||
|
this._contactsPromise = new Promise((resolve) => {
|
||||||
|
this._contactsResolver = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
this._cleanupSubscriptions();
|
this._cleanupSubscriptions();
|
||||||
|
|
||||||
// Setup models to track state reactively FIRST
|
// Setup models to track state reactively FIRST
|
||||||
@@ -658,7 +973,19 @@ export default class NostrDataService extends Service {
|
|||||||
.subscribe((contacts) => {
|
.subscribe((contacts) => {
|
||||||
this.contacts = contacts;
|
this.contacts = contacts;
|
||||||
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
|
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
|
||||||
|
// Resolve the deferred so callers awaiting whenContactsLoaded proceed.
|
||||||
|
this._contactsResolver?.();
|
||||||
|
this._contactsResolver = null;
|
||||||
this._updatePlacePhotos();
|
this._updatePlacePhotos();
|
||||||
|
// Re-trigger activity photo load now that contacts are available.
|
||||||
|
// If no activity load has been requested yet, _activityPhotosSince
|
||||||
|
// is null and the call is a no-op.
|
||||||
|
if (this._activityPhotosSince !== null) {
|
||||||
|
this.loadActivityPhotos(
|
||||||
|
this._activityPhotosSince,
|
||||||
|
this._activityPhotosMode
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this._blossomSub = this.store
|
this._blossomSub = this.store
|
||||||
@@ -724,6 +1051,23 @@ export default class NostrDataService extends Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a promise that resolves when contacts are first loaded, or
|
||||||
|
* immediately if already loaded. Times out after 5s so callers don't hang
|
||||||
|
* forever if contacts never arrive.
|
||||||
|
*
|
||||||
|
* @param {number} [timeout=5000] Timeout in milliseconds
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
whenContactsLoaded(timeout = 5000) {
|
||||||
|
if (this._contactPubkeys) return Promise.resolve();
|
||||||
|
if (!this._contactsPromise) return Promise.resolve();
|
||||||
|
return Promise.race([
|
||||||
|
this._contactsPromise,
|
||||||
|
new Promise((resolve) => setTimeout(resolve, timeout)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
get userDisplayName() {
|
get userDisplayName() {
|
||||||
if (this.profile) {
|
if (this.profile) {
|
||||||
if (this.profile.nip05) {
|
if (this.profile.nip05) {
|
||||||
@@ -896,6 +1240,10 @@ export default class NostrDataService extends Service {
|
|||||||
this._incomingZapsNetworkSub.unsubscribe();
|
this._incomingZapsNetworkSub.unsubscribe();
|
||||||
this._incomingZapsNetworkSub = null;
|
this._incomingZapsNetworkSub = null;
|
||||||
}
|
}
|
||||||
|
if (this._activityPhotosNetworkSub) {
|
||||||
|
this._activityPhotosNetworkSub.unsubscribe();
|
||||||
|
this._activityPhotosNetworkSub = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
willDestroy() {
|
willDestroy() {
|
||||||
|
|||||||
@@ -56,11 +56,9 @@ export default class PlaceNameResolverService extends Service {
|
|||||||
*/
|
*/
|
||||||
async resolveInBackground(entries) {
|
async resolveInBackground(entries) {
|
||||||
const pending = entries.filter((e) => e.placeNameLoading);
|
const pending = entries.filter((e) => e.placeNameLoading);
|
||||||
if (pending.length === 0) {
|
if (pending.length === 0) return;
|
||||||
this._maybeBatchFetchNames(entries);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Phase 1: Cache lookup (localForage → OSM IDB cache)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pending.map(async (entry) => {
|
pending.map(async (entry) => {
|
||||||
const name = await this._resolveCachedName(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
|
// Apply fallbacks for items already marked unresolvable in this session
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
|
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
|
||||||
@@ -93,52 +77,67 @@ export default class PlaceNameResolverService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unresolved.length === 0) {
|
const unresolved = entries.filter(
|
||||||
return;
|
(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
|
const signature = unresolved
|
||||||
.map((e) => e.placeIdentifier)
|
.map((e) => e.placeIdentifier)
|
||||||
.sort()
|
.sort()
|
||||||
.join('|');
|
.join('|');
|
||||||
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._lastBatchSignature = signature;
|
|
||||||
|
|
||||||
this._pendingBatchPromise = this._batchResolveNames(unresolved)
|
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
|
||||||
.then((nameMap) => {
|
return this._pendingBatchPromise;
|
||||||
// Merge resolved names back into the entries
|
}
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.placeNameLoading) continue;
|
this._lastBatchSignature = signature;
|
||||||
if (nameMap.has(entry.placeIdentifier)) {
|
this._pendingBatchPromise = this._batchResolveNames(unresolved).finally(
|
||||||
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(() => {
|
|
||||||
this._pendingBatchPromise = null;
|
this._pendingBatchPromise = null;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return this._pendingBatchPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
_fallbackName(entry) {
|
_fallbackName(entry) {
|
||||||
|
|||||||
+91
-16
@@ -2417,6 +2417,38 @@ button.create-place {
|
|||||||
padding: 4rem 1rem;
|
padding: 4rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab Navigation */
|
||||||
|
.tab-nav {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-nav-button {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
font-family: inherit;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-nav-button:hover {
|
||||||
|
color: var(--body-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-nav-button.is-active {
|
||||||
|
border-bottom-color: var(--link-color);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--body-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* Contributions Timeline */
|
/* Contributions Timeline */
|
||||||
.contributions-list {
|
.contributions-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -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 {
|
.activity-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: -1rem -1rem 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%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -2687,7 +2732,7 @@ button.create-place {
|
|||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .zap-sender-avatar {
|
& .sender-avatar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -2696,7 +2741,7 @@ button.create-place {
|
|||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .zap-sender-avatar-placeholder {
|
& .sender-avatar-placeholder {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -2708,39 +2753,42 @@ button.create-place {
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .zap-header {
|
& .header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .zap-sender-line {
|
& .sender-line {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|
||||||
& .zap-sender-name {
|
& .sender-name {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .zap-action {
|
& .action {
|
||||||
color: #666;
|
color: #666;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& .zap-amount {
|
& .amount {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--body-text-color);
|
color: var(--body-text-color);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
align-self: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .zap-message {
|
& .message {
|
||||||
color: var(--body-text-color);
|
color: var(--body-text-color);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -2762,7 +2810,7 @@ button.create-place {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& .zap-context {
|
& .context {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -2770,19 +2818,20 @@ button.create-place {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|
||||||
& .zap-context-images {
|
& .context-images {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .zap-context-thumb {
|
& .context-thumb {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
& img {
|
& img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2790,27 +2839,53 @@ button.create-place {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
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;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
& .zap-place-name {
|
& .activity-place-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
& .place-name-text {
|
||||||
|
animation: place-name-fade-in 0.15s ease-out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& .zap-date {
|
& .activity-date {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
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}}
|
{{#if @controller.mapUi.isSidebarVisible}}
|
||||||
<ActivityTimeline
|
<ActivityTimeline
|
||||||
@items={{@controller.items}}
|
@items={{@controller.items}}
|
||||||
@isLoading={{@controller.loadTask.isRunning}}
|
@isLoading={{@controller.isLoading}}
|
||||||
@isConnected={{@controller.isConnected}}
|
@isConnected={{@controller.isConnected}}
|
||||||
|
@sourceMode={{@controller.sourceMode}}
|
||||||
|
@onSetSourceMode={{@controller.setSourceMode}}
|
||||||
|
@isLoadingMore={{@controller.isLoadingMore}}
|
||||||
|
@onLoadMore={{@controller.loadMore}}
|
||||||
@scrollTop={{@controller.scrollTop}}
|
@scrollTop={{@controller.scrollTop}}
|
||||||
@onSelect={{@controller.selectItem}}
|
@onSelect={{@controller.selectItem}}
|
||||||
@onBack={{@controller.backToMenu}}
|
@onBack={{@controller.backToMenu}}
|
||||||
|
|||||||
+114
-1
@@ -14,7 +14,7 @@ import {
|
|||||||
getZapEventPointer,
|
getZapEventPointer,
|
||||||
getZapRequest,
|
getZapRequest,
|
||||||
} from 'applesauce-common/helpers';
|
} from 'applesauce-common/helpers';
|
||||||
import { parsePhotoFromEvent } from './contributions';
|
import { parsePhotoFromEvent, applyDeletions } from './contributions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single activity timeline entry.
|
* A single activity timeline entry.
|
||||||
@@ -27,6 +27,7 @@ export class ActivityEntry {
|
|||||||
type = 'zap';
|
type = 'zap';
|
||||||
photoEventId;
|
photoEventId;
|
||||||
photo;
|
photo;
|
||||||
|
photos = [];
|
||||||
placeIdentifier;
|
placeIdentifier;
|
||||||
osmType;
|
osmType;
|
||||||
osmId;
|
osmId;
|
||||||
@@ -43,7 +44,10 @@ export class ActivityEntry {
|
|||||||
constructor({
|
constructor({
|
||||||
photoEventId,
|
photoEventId,
|
||||||
photo,
|
photo,
|
||||||
|
photos,
|
||||||
placeIdentifier,
|
placeIdentifier,
|
||||||
|
osmType,
|
||||||
|
osmId,
|
||||||
senderPubkey,
|
senderPubkey,
|
||||||
amountSats,
|
amountSats,
|
||||||
message,
|
message,
|
||||||
@@ -51,7 +55,10 @@ export class ActivityEntry {
|
|||||||
}) {
|
}) {
|
||||||
this.photoEventId = photoEventId;
|
this.photoEventId = photoEventId;
|
||||||
this.photo = photo;
|
this.photo = photo;
|
||||||
|
this.photos = photos ?? [];
|
||||||
this.placeIdentifier = placeIdentifier;
|
this.placeIdentifier = placeIdentifier;
|
||||||
|
this.osmType = osmType;
|
||||||
|
this.osmId = osmId;
|
||||||
this.senderPubkey = senderPubkey;
|
this.senderPubkey = senderPubkey;
|
||||||
this.amountSats = amountSats;
|
this.amountSats = amountSats;
|
||||||
this.message = message;
|
this.message = message;
|
||||||
@@ -140,3 +147,109 @@ export function enrichWithPhoto(entry, store, userPubkey) {
|
|||||||
|
|
||||||
return true;
|
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
|
* @param {Array} events Mixed kind 360 and kind 5 events
|
||||||
* @returns {Array} Surviving kind 360 events
|
* @returns {Array} Surviving kind 360 events
|
||||||
*/
|
*/
|
||||||
function applyDeletions(events) {
|
export function applyDeletions(events) {
|
||||||
const deletedIds = new Set();
|
const deletedIds = new Set();
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.kind === 5) {
|
if (event.kind === 5) {
|
||||||
|
|||||||
@@ -23,9 +23,16 @@ class MockActivityService extends Service {
|
|||||||
senderProfileLoading: false,
|
senderProfileLoading: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@tracked sourceMode = 'home';
|
||||||
|
@tracked isLoadingMore = false;
|
||||||
|
@tracked isLoading = false;
|
||||||
|
|
||||||
async load() {}
|
async load() {}
|
||||||
|
|
||||||
|
async setSourceMode() {}
|
||||||
|
|
||||||
|
loadMore() {}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.items = [];
|
this.items = [];
|
||||||
}
|
}
|
||||||
@@ -97,12 +104,12 @@ module('Acceptance | activity', function (hooks) {
|
|||||||
test('activity items are rendered as zap rows', async function (assert) {
|
test('activity items are rendered as zap rows', async function (assert) {
|
||||||
await visit('/activity');
|
await visit('/activity');
|
||||||
|
|
||||||
await waitFor('.activity-zap-item');
|
await waitFor('.activity-item');
|
||||||
assert.dom('.activity-zap-item').exists({ count: 1 });
|
assert.dom('.activity-item').exists({ count: 1 });
|
||||||
assert.dom('.zap-sender-name').hasText('Alice');
|
assert.dom('.sender-name').hasText('Alice');
|
||||||
assert.dom('.zap-action').includesText('zapped your photo');
|
assert.dom('.action').includesText('zapped your photo');
|
||||||
assert.dom('.zap-amount').includesText('21 ⚡');
|
assert.dom('.amount').includesText('21 ⚡');
|
||||||
assert.dom('.zap-message').includesText('Great photo!');
|
assert.dom('.message').includesText('Great photo!');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closing the sidebar returns to index', async function (assert) {
|
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');
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
await visit('/activity');
|
await visit('/activity');
|
||||||
await waitFor('.activity-zap-item');
|
await waitFor('.activity-item');
|
||||||
|
|
||||||
await click('.activity-zap-item');
|
await click('.activity-item');
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
currentURL().includes('/place/osm:node:123'),
|
currentURL().includes('/place/osm:node:123'),
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
|||||||
hooks.beforeEach(function () {
|
hooks.beforeEach(function () {
|
||||||
this.noop = noop;
|
this.noop = noop;
|
||||||
this.emptyItems = [];
|
this.emptyItems = [];
|
||||||
|
this.sourceMode = 'home';
|
||||||
|
this.onSetSourceMode = () => {};
|
||||||
|
this.isLoadingMore = false;
|
||||||
|
this.onLoadMore = () => {};
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it renders a loading state', async function (assert) {
|
test('it renders a loading state', async function (assert) {
|
||||||
@@ -22,10 +26,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
|||||||
@items={{this.emptyItems}}
|
@items={{this.emptyItems}}
|
||||||
@isLoading={{true}}
|
@isLoading={{true}}
|
||||||
@isConnected={{true}}
|
@isConnected={{true}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
@onSelect={{this.noop}}
|
@onSelect={{this.noop}}
|
||||||
@onBack={{this.noop}}
|
@onBack={{this.noop}}
|
||||||
@onClose={{this.noop}}
|
@onClose={{this.noop}}
|
||||||
@onNostrConnected={{this.noop}}
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
@@ -41,10 +49,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
|||||||
@items={{this.emptyItems}}
|
@items={{this.emptyItems}}
|
||||||
@isLoading={{false}}
|
@isLoading={{false}}
|
||||||
@isConnected={{false}}
|
@isConnected={{false}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
@onSelect={{this.noop}}
|
@onSelect={{this.noop}}
|
||||||
@onBack={{this.noop}}
|
@onBack={{this.noop}}
|
||||||
@onClose={{this.noop}}
|
@onClose={{this.noop}}
|
||||||
@onNostrConnected={{this.noop}}
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
@@ -60,10 +72,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
|||||||
@items={{this.emptyItems}}
|
@items={{this.emptyItems}}
|
||||||
@isLoading={{false}}
|
@isLoading={{false}}
|
||||||
@isConnected={{false}}
|
@isConnected={{false}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
@onSelect={{this.noop}}
|
@onSelect={{this.noop}}
|
||||||
@onBack={{this.noop}}
|
@onBack={{this.noop}}
|
||||||
@onClose={{this.noop}}
|
@onClose={{this.noop}}
|
||||||
@onNostrConnected={{this.noop}}
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
@@ -82,10 +98,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
|||||||
@items={{this.emptyItems}}
|
@items={{this.emptyItems}}
|
||||||
@isLoading={{false}}
|
@isLoading={{false}}
|
||||||
@isConnected={{true}}
|
@isConnected={{true}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
@onSelect={{this.noop}}
|
@onSelect={{this.noop}}
|
||||||
@onBack={{this.noop}}
|
@onBack={{this.noop}}
|
||||||
@onClose={{this.noop}}
|
@onClose={{this.noop}}
|
||||||
@onNostrConnected={{this.noop}}
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
@@ -135,15 +155,19 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
|||||||
@items={{this.items}}
|
@items={{this.items}}
|
||||||
@isLoading={{false}}
|
@isLoading={{false}}
|
||||||
@isConnected={{true}}
|
@isConnected={{true}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
@onSelect={{this.noop}}
|
@onSelect={{this.noop}}
|
||||||
@onBack={{this.noop}}
|
@onBack={{this.noop}}
|
||||||
@onClose={{this.noop}}
|
@onClose={{this.noop}}
|
||||||
@onNostrConnected={{this.noop}}
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
/>
|
/>
|
||||||
</template>
|
</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('Alice');
|
||||||
assert.dom(this.element).includesText('21 ⚡');
|
assert.dom(this.element).includesText('21 ⚡');
|
||||||
assert.dom(this.element).includesText('Bob');
|
assert.dom(this.element).includesText('Bob');
|
||||||
@@ -162,10 +186,14 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
|||||||
@items={{this.emptyItems}}
|
@items={{this.emptyItems}}
|
||||||
@isLoading={{false}}
|
@isLoading={{false}}
|
||||||
@isConnected={{true}}
|
@isConnected={{true}}
|
||||||
|
@sourceMode={{this.sourceMode}}
|
||||||
|
@onSetSourceMode={{this.onSetSourceMode}}
|
||||||
@onSelect={{this.noop}}
|
@onSelect={{this.noop}}
|
||||||
@onBack={{this.handleBack}}
|
@onBack={{this.handleBack}}
|
||||||
@onClose={{this.noop}}
|
@onClose={{this.noop}}
|
||||||
@onNostrConnected={{this.noop}}
|
@onNostrConnected={{this.noop}}
|
||||||
|
@isLoadingMore={{this.isLoadingMore}}
|
||||||
|
@onLoadMore={{this.onLoadMore}}
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
@@ -173,4 +201,214 @@ module('Integration | Component | activity-timeline', function (hooks) {
|
|||||||
await click('.sidebar-header .back-btn');
|
await click('.sidebar-header .back-btn');
|
||||||
assert.true(backClicked);
|
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>
|
</template>
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.dom('.zap-sender-name').hasText('Alice');
|
assert.dom('.sender-name').hasText('Alice');
|
||||||
assert.dom('.zap-action').includesText('zapped your photo');
|
assert.dom('.action').includesText('zapped your photo');
|
||||||
assert.dom('.zap-amount').hasText('21 ⚡');
|
assert.dom('.amount').hasText('21 ⚡');
|
||||||
assert.dom('.zap-message').includesText('Great shot!');
|
assert.dom('.message').includesText('Great shot!');
|
||||||
assert
|
assert
|
||||||
.dom('.zap-sender-avatar')
|
.dom('.sender-avatar')
|
||||||
.hasAttribute('src', 'https://x.com/avatar.jpg');
|
.hasAttribute('src', 'https://x.com/avatar.jpg');
|
||||||
assert
|
assert
|
||||||
.dom('.zap-context-thumb img')
|
.dom('.context-thumb img')
|
||||||
.hasAttribute('src', 'https://x.com/thumb.jpg');
|
.hasAttribute('src', 'https://x.com/thumb.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,8 +68,8 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
|||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.dom('.zap-sender-avatar-placeholder').exists();
|
assert.dom('.sender-avatar-placeholder').exists();
|
||||||
assert.dom('.zap-sender-avatar').doesNotExist();
|
assert.dom('.sender-avatar').doesNotExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it omits the message line when there is no message', async function (assert) {
|
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>
|
</template>
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.dom('.zap-message').doesNotExist();
|
assert.dom('.message').doesNotExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it omits the context thumbnail when there is no photo', async function (assert) {
|
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>
|
</template>
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.dom('.zap-context-thumb').doesNotExist();
|
assert.dom('.context-thumb').doesNotExist();
|
||||||
assert.dom('.zap-context-text').exists();
|
assert.dom('.context-text').exists();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking the item fires @onSelect with the item', async function (assert) {
|
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>
|
</template>
|
||||||
);
|
);
|
||||||
|
|
||||||
await click('.activity-zap-item');
|
await click('.activity-item');
|
||||||
|
|
||||||
assert.strictEqual(selected, this.item);
|
assert.strictEqual(selected, this.item);
|
||||||
});
|
});
|
||||||
@@ -168,14 +168,14 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert
|
assert
|
||||||
.dom('.zap-context-text')
|
.dom('.context-text')
|
||||||
.includesText('Café Central', 'Resolved place name is displayed');
|
.includesText('Café Central', 'Resolved place name is displayed');
|
||||||
assert
|
assert
|
||||||
.dom('.contribution-name-loading')
|
.dom('.contribution-name-loading')
|
||||||
.doesNotExist('No loading/fallback text shown');
|
.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({
|
this.item = new ActivityEntry({
|
||||||
photoEventId: 'photo-1',
|
photoEventId: 'photo-1',
|
||||||
photo: null,
|
photo: null,
|
||||||
@@ -197,8 +197,11 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert
|
assert
|
||||||
.dom('.zap-context-text .contribution-name-loading')
|
.dom('.context-text .activity-place-name')
|
||||||
.hasText('Loading…', 'Loading state is displayed');
|
.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) {
|
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
|
assert
|
||||||
.dom('.zap-context-text .contribution-name-loading')
|
.dom('.context-text .place-name-text')
|
||||||
.hasText('Unnamed place', 'Fallback name is displayed');
|
.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({
|
this.item = new ActivityEntry({
|
||||||
photoEventId: 'photo-1',
|
photoEventId: 'photo-1',
|
||||||
photo: null,
|
photo: null,
|
||||||
@@ -248,14 +251,14 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
|||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
|
|
||||||
const placeNameEl = this.element.querySelector('.zap-place-name');
|
const placeNameEl = this.element.querySelector('.activity-place-name');
|
||||||
const dateEl = this.element.querySelector('.zap-date');
|
const dateEl = this.element.querySelector('.activity-date');
|
||||||
|
|
||||||
assert.ok(placeNameEl, 'zap-place-name element exists');
|
assert.ok(placeNameEl, 'activity-place-name element exists');
|
||||||
assert.ok(dateEl, 'zap-date element exists');
|
assert.ok(dateEl, 'activity-date element exists');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
placeNameEl.textContent.includes('Café Central'),
|
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.ok(dateEl.textContent.includes('hr ago'), 'Date contains hr ago');
|
||||||
assert.notOk(
|
assert.notOk(
|
||||||
@@ -290,7 +293,9 @@ module('Integration | Component | activity-zap-item', function (hooks) {
|
|||||||
</template>
|
</template>
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.dom('.zap-place-name').exists('zap-place-name element exists');
|
assert
|
||||||
assert.dom('.zap-date').hasText('1 hr ago', 'Date is still visible');
|
.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);
|
setVerifyWrappedEventMethod(fakeVerifyEvent);
|
||||||
|
|
||||||
|
class MockPlaceNameResolver extends Service {
|
||||||
|
resolveBookmark() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
resolveInBackground() {
|
||||||
|
return new Promise(() => {});
|
||||||
|
}
|
||||||
|
reset() {}
|
||||||
|
}
|
||||||
|
|
||||||
const USER_PUBKEY = 'a'.repeat(64);
|
const USER_PUBKEY = 'a'.repeat(64);
|
||||||
const SENDER_PUBKEY = 'b'.repeat(64);
|
const SENDER_PUBKEY = 'b'.repeat(64);
|
||||||
|
|
||||||
@@ -64,7 +74,7 @@ function makePhotoEvent(opts = {}) {
|
|||||||
id: opts.id || PHOTO_EVENT_ID_1,
|
id: opts.id || PHOTO_EVENT_ID_1,
|
||||||
pubkey: opts.author || USER_PUBKEY,
|
pubkey: opts.author || USER_PUBKEY,
|
||||||
kind: 360,
|
kind: 360,
|
||||||
created_at: 5000,
|
created_at: opts.created_at || 5000,
|
||||||
tags: [
|
tags: [
|
||||||
['i', opts.placeIdentifier || 'osm:node:123'],
|
['i', opts.placeIdentifier || 'osm:node:123'],
|
||||||
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
|
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
|
||||||
@@ -76,6 +86,7 @@ function makePhotoEvent(opts = {}) {
|
|||||||
|
|
||||||
class MockNostrDataService extends Service {
|
class MockNostrDataService extends Service {
|
||||||
@tracked profiles = {};
|
@tracked profiles = {};
|
||||||
|
_contactPubkeys = new Set();
|
||||||
|
|
||||||
store = {
|
store = {
|
||||||
events: new Map(),
|
events: new Map(),
|
||||||
@@ -112,6 +123,20 @@ class MockNostrDataService extends Service {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadProfiles() {}
|
loadProfiles() {}
|
||||||
|
loadActivityPhotos() {}
|
||||||
|
loadMyContributions() {}
|
||||||
|
loadProfile() {}
|
||||||
|
whenContactsLoaded() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchActivityPhotos() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchIncomingZaps() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
getProfile(pubkey) {
|
getProfile(pubkey) {
|
||||||
return this.profiles[pubkey];
|
return this.profiles[pubkey];
|
||||||
@@ -125,9 +150,10 @@ module('Unit | Service | activity', function (hooks) {
|
|||||||
|
|
||||||
hooks.beforeEach(function () {
|
hooks.beforeEach(function () {
|
||||||
this.owner.register('service:nostrData', MockNostrDataService);
|
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 service = this.owner.lookup('service:activity');
|
||||||
const photoEvent = makePhotoEvent({
|
const photoEvent = makePhotoEvent({
|
||||||
id: PHOTO_EVENT_ID_1,
|
id: PHOTO_EVENT_ID_1,
|
||||||
@@ -140,7 +166,7 @@ module('Unit | Service | activity', function (hooks) {
|
|||||||
message: 'Love it!',
|
message: 'Love it!',
|
||||||
});
|
});
|
||||||
|
|
||||||
service._updateItems([receipt], USER_PUBKEY);
|
service._updateZapItems([receipt], USER_PUBKEY);
|
||||||
|
|
||||||
assert.strictEqual(service.items.length, 1);
|
assert.strictEqual(service.items.length, 1);
|
||||||
assert.strictEqual(service.items[0].type, 'zap');
|
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');
|
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');
|
const service = this.owner.lookup('service:activity');
|
||||||
service.nostrData.store.add({
|
service.nostrData.store.add({
|
||||||
id: TEXT_EVENT_ID,
|
id: TEXT_EVENT_ID,
|
||||||
@@ -163,12 +189,12 @@ module('Unit | Service | activity', function (hooks) {
|
|||||||
|
|
||||||
const receipt = makeZapReceiptEvent({ zappedEventId: TEXT_EVENT_ID });
|
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');
|
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 service = this.owner.lookup('service:activity');
|
||||||
const otherUserPhoto = makePhotoEvent({
|
const otherUserPhoto = makePhotoEvent({
|
||||||
id: PHOTO_EVENT_ID_OTHER,
|
id: PHOTO_EVENT_ID_OTHER,
|
||||||
@@ -180,7 +206,7 @@ module('Unit | Service | activity', function (hooks) {
|
|||||||
zappedEventId: PHOTO_EVENT_ID_OTHER,
|
zappedEventId: PHOTO_EVENT_ID_OTHER,
|
||||||
});
|
});
|
||||||
|
|
||||||
service._updateItems([receipt], USER_PUBKEY);
|
service._updateZapItems([receipt], USER_PUBKEY);
|
||||||
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
service.items.length,
|
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 service = this.owner.lookup('service:activity');
|
||||||
const photoEvent = makePhotoEvent({ id: PHOTO_EVENT_ID_1 });
|
const photoEvent = makePhotoEvent({ id: PHOTO_EVENT_ID_1 });
|
||||||
service.nostrData.store.add(photoEvent);
|
service.nostrData.store.add(photoEvent);
|
||||||
@@ -199,7 +225,7 @@ module('Unit | Service | activity', function (hooks) {
|
|||||||
zappedEventId: PHOTO_EVENT_ID_1,
|
zappedEventId: PHOTO_EVENT_ID_1,
|
||||||
});
|
});
|
||||||
|
|
||||||
service._updateItems([receipt], USER_PUBKEY);
|
service._updateZapItems([receipt], USER_PUBKEY);
|
||||||
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
service.items.length,
|
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');
|
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_1 }));
|
||||||
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_2 }));
|
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_2 }));
|
||||||
@@ -224,7 +250,7 @@ module('Unit | Service | activity', function (hooks) {
|
|||||||
created_at: 9000,
|
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.length, 2);
|
||||||
assert.strictEqual(service.items[0].createdAt, 9000, 'newest first');
|
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) {
|
test('stop clears items and resets state', function (assert) {
|
||||||
const service = this.owner.lookup('service:activity');
|
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_1 }));
|
||||||
service._updateItems(
|
service._updateZapItems(
|
||||||
[makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID_1 })],
|
[makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID_1 })],
|
||||||
USER_PUBKEY
|
USER_PUBKEY
|
||||||
);
|
);
|
||||||
@@ -270,4 +296,483 @@ module('Unit | Service | activity', function (hooks) {
|
|||||||
assert.strictEqual(entry.senderAvatar, 'https://x.com/avatar.jpg');
|
assert.strictEqual(entry.senderAvatar, 'https://x.com/avatar.jpg');
|
||||||
assert.false(entry.senderProfileLoading);
|
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 [followedPhoto];
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [oldPhoto];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 () => [];
|
||||||
|
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 [oldZapReceipt];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
return [
|
||||||
|
makePhotoEvent({
|
||||||
|
id: 'old2'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:300',
|
||||||
|
created_at: NOW - 100000,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [
|
||||||
|
makePhotoEvent({
|
||||||
|
id: 'old3'.padEnd(64, '0'),
|
||||||
|
author: SENDER_PUBKEY,
|
||||||
|
placeIdentifier: 'osm:node:200',
|
||||||
|
created_at: NOW - 5000,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 { module, test } from 'qunit';
|
||||||
import { setupTest } from 'marco/tests/helpers';
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
import { Subject, EMPTY } from 'rxjs';
|
import { Subject, EMPTY, of } from 'rxjs';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
import NostrDataService from 'marco/services/nostr-data';
|
import NostrDataService from 'marco/services/nostr-data';
|
||||||
import { getGeohashPrefixesInBbox } from 'marco/utils/geohash-coverage';
|
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) {
|
test('kind 3 events are persisted to IDB cache', async function (assert) {
|
||||||
const service = this.owner.lookup('service:nostr-data');
|
const service = this.owner.lookup('service:nostr-data');
|
||||||
|
|
||||||
@@ -856,6 +908,314 @@ 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 events = await service.fetchActivityPhotos(1000, undefined, 'home');
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
events.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 events = await service.fetchActivityPhotos(1000, undefined, 'home');
|
||||||
|
|
||||||
|
assert.strictEqual(events.length, 0, 'empty array 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 events = await service.fetchActivityPhotos(1000, undefined, 'home');
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
events.filter((e) => e.id === photo.id).length,
|
||||||
|
1,
|
||||||
|
'photo appears only once despite being in both cache and network'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
module('Unit | Service | nostr-data | zap receipts', function (hooks) {
|
||||||
setupNostrDataService(hooks);
|
setupNostrDataService(hooks);
|
||||||
|
|
||||||
|
|||||||
@@ -207,4 +207,70 @@ module('Unit | Service | place-name-resolver', function (hooks) {
|
|||||||
assert.strictEqual(resolver._lastBatchSignature, '');
|
assert.strictEqual(resolver._lastBatchSignature, '');
|
||||||
assert.strictEqual(resolver._pendingBatchPromise, null);
|
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,
|
ActivityEntry,
|
||||||
parseZapReceipt,
|
parseZapReceipt,
|
||||||
enrichWithPhoto,
|
enrichWithPhoto,
|
||||||
|
groupSocialPhotos,
|
||||||
} from 'marco/utils/activity';
|
} from 'marco/utils/activity';
|
||||||
import {
|
import {
|
||||||
setVerifyWrappedEventMethod,
|
setVerifyWrappedEventMethod,
|
||||||
@@ -209,3 +210,252 @@ module('Unit | Utility | activity', function () {
|
|||||||
assert.false(result, 'event not in store');
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user