Compare commits

..
Author SHA1 Message Date
raucao a4a8d123d3 Split Activity timeline in Home and Explore
Adds a new Explore timeline with contributions from anyone on trusted
relays.
2026-09-02 15:06:10 -06:00
raucao 92c6190d28 Add followee's photo uploads to Activity timeline 2026-09-02 11:23:34 -06:00
raucao 36989e1dc2 WIP Add activity photo item
Can show one or more photo additions in the activity timeline
2026-09-02 10:22:15 -06:00
raucao 256659fbfb Merge pull request 'Add resorts to accommodation category, add icon' (#95) from ui/icons into master
CI / Lint (push) Successful in 1m2s
CI / Test (push) Successful in 1m19s
Reviewed-on: #95
2026-08-31 23:32:44 +00:00
raucao ad762b138f Add resorts to accommodation category, add icon
CI / Lint (pull_request) Successful in 1m7s
CI / Test (pull_request) Successful in 1m18s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-08-31 17:21:32 -06:00
raucao 2d3808a097 1.32.1
CI / Lint (push) Successful in 1m7s
CI / Test (push) Successful in 1m22s
2026-08-30 09:10:10 -06:00
raucao 6aa13d7243 Merge pull request 'Fix photo dropdown not closing after deletion' (#92) from bug/photo_menu into master
CI / Lint (push) Successful in 1m10s
CI / Test (push) Successful in 1m28s
Reviewed-on: #92
2026-08-30 15:05:12 +00:00
raucao 6804e80528 Fix photo dropdown not closing after deletion
CI / Lint (pull_request) Successful in 1m12s
CI / Test (pull_request) Successful in 1m27s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
fixes #90
2026-08-30 09:02:07 -06:00
31 changed files with 1835 additions and 185 deletions
+134
View File
@@ -0,0 +1,134 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import formatRelativeDate from '../helpers/format-relative-date';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import Icon from './icon';
export default class ActivityPhotoItem extends Component {
get item() {
return this.args.item;
}
get senderName() {
return this.item?.senderName;
}
get senderDisplayName() {
const name = this.senderName;
if (name) return name;
const pubkey = this.item?.senderPubkey;
if (!pubkey) return 'Someone';
try {
return `${npubEncode(pubkey).slice(0, 12)}…`;
} catch {
return `${pubkey.slice(0, 12)}…`;
}
}
get senderAvatar() {
return this.item?.senderAvatar;
}
get hasPhoto() {
return this.photos.length > 0;
}
get photos() {
const photos = this.item?.photos;
if (photos && photos.length > 0) return photos;
const single = this.item?.photo;
return single ? [single] : [];
}
get primaryPhoto() {
return this.photos[0];
}
get extraPhotoCount() {
return Math.max(0, this.photos.length - 1);
}
get photoCountText() {
const count = this.photos.length;
return count === 1 ? 'a photo' : `${count} photos`;
}
get photoThumbUrl() {
return this.primaryPhoto?.thumbUrl || this.primaryPhoto?.url;
}
get placeName() {
return this.item?.placeName;
}
get placeNameLoading() {
return this.item?.placeNameLoading;
}
<template>
<li>
<button
type="button"
class="activity-item"
{{on "click" (fn @onSelect @item)}}
>
<div class="header">
<span class="sender-line">
<span class="sender-name">{{this.senderDisplayName}}</span>
{{! template-lint-disable no-whitespace-for-layout }}
<span class="action"> added {{this.photoCountText}}</span>
</span>
<span class="amount">
<Icon
@name="feather-camera"
@size={{16}}
@color="var(--secondary-text-color)"
/>
</span>
</div>
<div class="context">
<div class="context-images">
{{#if this.senderAvatar}}
<img
class="sender-avatar"
src={{this.senderAvatar}}
alt=""
loading="lazy"
/>
{{else}}
<div class="sender-avatar-placeholder">
<Icon @name="user" @size={{16}} @color="#999" />
</div>
{{/if}}
{{#if this.hasPhoto}}
<div class="context-thumb">
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
{{#if (gt this.extraPhotoCount 0)}}
<span
class="context-thumb-badge"
>+{{this.extraPhotoCount}}</span>
{{/if}}
</div>
{{/if}}
</div>
<div class="context-text">
<span class="activity-place-name">
{{#if this.placeName}}
{{this.placeName}}
{{else if this.placeNameLoading}}
<span class="contribution-name-loading">Loading…</span>
{{else}}
<span class="contribution-name-loading">Unnamed place</span>
{{/if}}
</span>
<span class="activity-date">{{formatRelativeDate
@item.createdAt
}}</span>
</div>
</div>
</button>
</li>
</template>
}
+17 -1
View File
@@ -3,16 +3,24 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import Icon from './icon';
import TabNav from './tab-nav';
import ActivityZapItem from './activity-zap-item';
import ActivityPhotoItem from './activity-photo-item';
import Modal from './modal';
import NostrConnect from './nostr-connect';
import not from 'ember-truth-helpers/helpers/not';
import eq from 'ember-truth-helpers/helpers/eq';
import and from 'ember-truth-helpers/helpers/and';
import restoreScroll from '../modifiers/restore-scroll';
export default class ActivityTimelineComponent extends Component {
@tracked isNostrConnectModalOpen = false;
tabs = [
{ label: 'Home', value: 'home' },
{ label: 'Explore', value: 'explore' },
];
@action
openNostrConnectModal(event) {
event.preventDefault();
@@ -47,12 +55,18 @@ export default class ActivityTimelineComponent extends Component {
</button>
</div>
<TabNav
@tabs={{this.tabs}}
@active={{@sourceMode}}
@onChange={{@onSetSourceMode}}
/>
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
{{#if @isLoading}}
<div class="sidebar-loading">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{else if (not @isConnected)}}
{{else if (and (not @isConnected) (eq @sourceMode "home"))}}
<p class="empty-state">
<a
href="#"
@@ -69,6 +83,8 @@ export default class ActivityTimelineComponent extends Component {
{{#each @items as |item|}}
{{#if (eq item.type "zap")}}
<ActivityZapItem @item={{item}} @onSelect={{@onSelect}} />
{{else if (eq item.type "photo")}}
<ActivityPhotoItem @item={{item}} @onSelect={{@onSelect}} />
{{/if}}
{{/each}}
</ul>
+17 -15
View File
@@ -50,40 +50,40 @@ export default class ActivityZapItem extends Component {
<li>
<button
type="button"
class="activity-zap-item"
class="activity-item"
{{on "click" (fn @onSelect @item)}}
>
<div class="zap-header">
<span class="zap-sender-line">
<span class="zap-sender-name">{{this.senderDisplayName}}</span>
<div class="header">
<span class="sender-line">
<span class="sender-name">{{this.senderDisplayName}}</span>
{{! template-lint-disable no-whitespace-for-layout }}
<span class="zap-action"> zapped your photo</span>
<span class="action"> zapped your photo</span>
</span>
<span class="zap-amount">{{this.item.amountSats}} ⚡</span>
<span class="amount">{{this.item.amountSats}} ⚡</span>
</div>
<div class="zap-context">
<div class="zap-context-images">
<div class="context">
<div class="context-images">
{{#if this.senderAvatar}}
<img
class="zap-sender-avatar"
class="sender-avatar"
src={{this.senderAvatar}}
alt=""
loading="lazy"
/>
{{else}}
<div class="zap-sender-avatar-placeholder">
<div class="sender-avatar-placeholder">
<Icon @name="user" @size={{16}} @color="#999" />
</div>
{{/if}}
{{#if this.hasPhoto}}
<div class="zap-context-thumb">
<div class="context-thumb">
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
</div>
{{/if}}
</div>
<div class="zap-context-text">
<span class="zap-place-name">
<div class="context-text">
<span class="activity-place-name">
{{#if this.placeName}}
{{this.placeName}}
{{else if this.placeNameLoading}}
@@ -92,12 +92,14 @@ export default class ActivityZapItem extends Component {
<span class="contribution-name-loading">Unnamed place</span>
{{/if}}
</span>
<span class="zap-date">{{formatRelativeDate @item.createdAt}}</span>
<span class="activity-date">{{formatRelativeDate
@item.createdAt
}}</span>
</div>
</div>
{{#if this.item.message}}
<div class="zap-message">{{this.item.message}}</div>
<div class="message">{{this.item.message}}</div>
{{/if}}
</button>
</li>
+25 -11
View File
@@ -52,7 +52,7 @@ const GalleryContent = <template>
<button
class="dropdown-item text-danger"
type="button"
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
{{on "click" (fn @deletePhoto closeMenu)}}
>Delete Photo</button>
{{/if}}
</DropdownMenu>
@@ -259,21 +259,27 @@ export default class PhotoGallery extends Component {
this.zapModalOpen = false;
}
deletePhotoTask = task(async (closeMenu) => {
@action
deletePhoto(closeMenu, e) {
e?.stopPropagation();
if (closeMenu) closeMenu();
if (
!confirm(
'Are you sure you want to delete this photo? This cannot be undone.'
)
) {
if (closeMenu) closeMenu();
return;
}
this.deletePhotoTask.perform();
}
deletePhotoTask = task(async () => {
const deletedEventId = this.currentPhoto.eventId;
const deletedIndex = this.args.photos?.indexOf(this.currentPhoto) ?? -1;
try {
const eventId = this.currentPhoto.eventId;
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
const tags = [['e', eventId]];
const tags = [['e', deletedEventId]];
if (this.currentPhoto.placeIdentifier) {
tags.push(['i', this.currentPhoto.placeIdentifier]);
@@ -316,12 +322,20 @@ export default class PhotoGallery extends Component {
this.toast.show('Photo deleted successfully');
if (closeMenu) closeMenu();
this.handleClose();
// Navigate to the next remaining photo, or close the gallery if none left.
// this.args.photos may be stale until Glimmer re-renders, so filter locally.
const remaining = (this.args.photos || []).filter(
(p) => p.eventId !== deletedEventId
);
if (remaining.length === 0) {
this.handleClose();
} else {
this.currentPhoto =
remaining[Math.min(deletedIndex, remaining.length - 1)];
}
} catch (e) {
console.error('Failed to delete photo:', e);
this.toast.show('Failed to delete photo: ' + e.message);
if (closeMenu) closeMenu();
}
});
@@ -333,7 +347,7 @@ export default class PhotoGallery extends Component {
@handleKeydown={{this.handleKeydown}}
@copyEventId={{this.copyEventId}}
@canDeletePhoto={{this.canDeletePhoto}}
@deletePhotoTask={{this.deletePhotoTask}}
@deletePhoto={{this.deletePhoto}}
@handleClose={{this.handleClose}}
@photos={{@photos}}
@currentPhoto={{this.currentPhoto}}
@@ -356,7 +370,7 @@ export default class PhotoGallery extends Component {
@handleKeydown={{this.handleKeydown}}
@copyEventId={{this.copyEventId}}
@canDeletePhoto={{this.canDeletePhoto}}
@deletePhotoTask={{this.deletePhotoTask}}
@deletePhoto={{this.deletePhoto}}
@handleClose={{this.handleClose}}
@photos={{@photos}}
@currentPhoto={{this.currentPhoto}}
+17
View File
@@ -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>
+9 -1
View File
@@ -10,7 +10,6 @@ export default class ActivityController extends Controller {
@service activity;
loadTask = task({ restartable: true }, async (pubkey) => {
if (!pubkey) return;
await this.activity.load(pubkey);
});
@@ -26,6 +25,15 @@ export default class ActivityController extends Controller {
return this.nostrAuth.isConnected;
}
get sourceMode() {
return this.activity.sourceMode;
}
@action
setSourceMode(mode) {
this.activity.setSourceMode(mode);
}
@action
selectItem(item) {
if (!item || !item.placeIdentifier) return;
+173 -31
View File
@@ -2,7 +2,13 @@ import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { ProfileModel } from 'applesauce-core/models/profile';
import { getProfileContent } from 'applesauce-core/helpers/profile';
import { parseZapReceipt, enrichWithPhoto } from '../utils/activity';
import {
parseZapReceipt,
enrichWithPhoto,
groupSocialPhotos,
} from '../utils/activity';
const SINCE_WINDOW = 30 * 24 * 60 * 60; // 30 days in seconds
/**
* Orchestrates loading the user's incoming social activity (zaps received on
@@ -25,45 +31,98 @@ export default class ActivityService extends Service {
@service placeNameResolver;
@tracked items = [];
@tracked sourceMode = 'home';
_sub = null;
_socialSub = null;
_profileSubs = new Map();
_userPubkey = null;
_zapItems = [];
_socialItems = [];
_lastSocialEvents = [];
_since = null;
_sourceMode = 'home';
/**
* Loads the user's incoming zap receipts and subscribes to live updates.
* Loads the user's incoming zap receipts and social photo activity, then
* subscribes to live updates.
*
* @param {string} pubkey The user's Nostr pubkey
* When a Nostr account is connected, zaps received on the user's photos are
* loaded and the 'home' mode shows photos from followed contacts. When no
* account is connected, zaps and 'home' mode are skipped, but 'explore'
* mode still loads photos from trusted relays.
*
* @param {string|null} pubkey The user's Nostr pubkey, or null
*/
async load(pubkey) {
if (!pubkey) {
this.items = [];
return;
this._userPubkey = pubkey || null;
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
if (pubkey) {
const zapFilters = [{ kinds: [9735], '#p': [pubkey] }];
console.debug('[activity] Subscribing to zap receipts', {
filters: zapFilters,
pubkey,
activeReadRelays: this.nostrData.activeReadRelays,
});
// Subscribe to zap receipts (kind 9735 where #p = user)
this._sub = this.nostrData.store
.timeline(zapFilters)
.subscribe((events) => {
this._updateZapItems(events, pubkey);
});
}
this._userPubkey = pubkey;
// Subscribe to all kind 360 photos in the time window. The callback
// filters by source mode (e.g. followed contacts only) so we only
// show photos from the relevant source.
const photoFilters = [{ kinds: [360], since: this._since }];
this._socialSub = this.nostrData.store
.timeline(photoFilters)
.subscribe((events) => {
this._lastSocialEvents = events;
this._updateSocialItems(events);
});
const filters = [{ kinds: [9735], '#p': [pubkey] }];
if (pubkey) {
// Ensure the user's kind 360 photo events are in the store first so
// enrichWithPhoto can look them up when zap receipts arrive.
await this.nostrData.loadMyContributions(pubkey);
console.debug('[activity] Subscribing to zap receipts', {
filters,
pubkey,
activeReadRelays: this.nostrData.activeReadRelays,
});
// Then load zap receipts — adding them to the store triggers the
// timeline subscription, and by now the photo events are available.
await this.nostrData.loadIncomingZaps(pubkey);
}
// Subscribe to the store timeline so we get live updates as receipts
// arrive and are added to the store.
this._sub = this.nostrData.store.timeline(filters).subscribe((events) => {
this._updateItems(events, pubkey);
});
// Load social photos (contacts dependency handled internally — if
// contacts haven't loaded yet, loadActivityPhotos returns early and
// the ContactsModel callback re-triggers it when they arrive).
await this.nostrData.loadActivityPhotos(this._since, this._sourceMode);
}
// Ensure the user's kind 360 photo events are in the store first so
// enrichWithPhoto can look them up when zap receipts arrive.
await this.nostrData.loadMyContributions(pubkey);
/**
* Switches the activity source mode (e.g. 'home' for followee photos,
* 'explore' for all photos from trusted relays). Re-filters existing
* events and re-fetches with the new mode.
*
* @param {string} mode The new source mode
*/
setSourceMode(mode) {
if (mode === this._sourceMode) return;
this._sourceMode = mode;
this.sourceMode = mode;
// Then load zap receipts — adding them to the store triggers the
// timeline subscription, and by now the photo events are available.
await this.nostrData.loadIncomingZaps(pubkey);
// Re-filter existing events with the new mode
if (this._lastSocialEvents.length > 0) {
this._updateSocialItems(this._lastSocialEvents);
}
// Re-fetch with the new mode
if (this._since !== null) {
this.nostrData.loadActivityPhotos(this._since, mode);
}
}
/**
@@ -75,9 +134,17 @@ export default class ActivityService extends Service {
this._sub.unsubscribe();
this._sub = null;
}
if (this._socialSub) {
this._socialSub.unsubscribe();
this._socialSub = null;
}
this._cleanupProfileSubs();
this._zapItems = [];
this._socialItems = [];
this._lastSocialEvents = [];
this.items = [];
this._userPubkey = null;
this._since = null;
this.placeNameResolver.reset();
}
@@ -86,7 +153,7 @@ export default class ActivityService extends Service {
super.willDestroy(...arguments);
}
_updateItems(receipts, pubkey) {
_updateZapItems(receipts, pubkey) {
const entries = [];
for (const receipt of receipts) {
@@ -113,8 +180,8 @@ export default class ActivityService extends Service {
pubkey,
});
// 2. Bookmark lookup is synchronous — resolve those immediately so the
// first render shows bookmarked place names without a "Loading…" flicker.
// Synchronous bookmark lookup — resolve those immediately so the
// first render shows bookmarked place names without a "Loading…" flicker.
for (const entry of entries) {
if (entry.osmId) {
const bookmarkName = this.placeNameResolver.resolveBookmark(
@@ -127,15 +194,90 @@ export default class ActivityService extends Service {
}
}
this.items = entries;
this._zapItems = entries;
this._mergeItems();
// 3. Async-resolve remaining entries from the IndexedDB name cache and OSM
// cache, then fall through to the network batch for the rest.
// Async-resolve remaining entries from the IndexedDB name cache and OSM
// cache, then fall through to the network batch for the rest.
void this.placeNameResolver.resolveInBackground(entries).then(() => {
this.items = [...this.items];
this._mergeItems();
});
}
_updateSocialItems(events) {
// Filter by source mode (e.g. only followed contacts, excluding own photos)
const filtered = events.filter((e) => this._matchesSourceMode(e));
const entries = groupSocialPhotos(filtered);
// Resolve sender profiles (async, fire-and-forget per sender)
for (const entry of entries) {
this._resolveSender(entry);
}
// Synchronous bookmark lookup for instant first render
for (const entry of entries) {
if (entry.osmId) {
const bookmarkName = this.placeNameResolver.resolveBookmark(
entry.osmId
);
if (bookmarkName) {
entry.placeName = bookmarkName;
entry.placeNameLoading = false;
}
}
}
this._socialItems = entries;
this._mergeItems();
// Async-resolve remaining place names from caches → network batch
void this.placeNameResolver.resolveInBackground(entries).then(() => {
this._mergeItems();
});
}
/**
* Merges `_zapItems` and `_socialItems` into `items`, sorted newest-first.
*/
_mergeItems() {
const all =
this._sourceMode === 'explore'
? [...this._socialItems]
: [...this._zapItems, ...this._socialItems];
all.sort((a, b) => b.createdAt - a.createdAt);
this.items = all;
}
/**
* Returns true if an event should be included in the activity feed given
* the current source mode.
*
* - `'home'`: only events from followees (excluding own photos)
* - `'explore'`: events from non-followed authors trusted by relay provenance
* (excluding own photos). When no pubkey is connected, all trusted events
* pass (no own-photo or followee exclusion).
*/
_matchesSourceMode(event) {
if (this._sourceMode === 'home') {
return (
this._userPubkey &&
event.pubkey !== this._userPubkey &&
this.nostrData._contactPubkeys?.has(event.pubkey)
);
}
if (this._sourceMode === 'explore') {
if (!this._userPubkey) {
return this.nostrData.isTrustedEvent(event);
}
return (
event.pubkey !== this._userPubkey &&
!this.nostrData._contactPubkeys?.has(event.pubkey) &&
this.nostrData.isTrustedEvent(event)
);
}
return false;
}
_resolveSender(entry) {
const pubkey = entry.senderPubkey;
if (!pubkey) return;
+156
View File
@@ -97,6 +97,12 @@ export default class NostrDataService extends Service {
_lastPhotoIds = new Set();
_incomingZapsNetworkSub = null;
// Activity photos state: tracks the current time window and source mode
// so that the contacts callback can re-trigger when contacts arrive.
_activityPhotosSince = null;
_activityPhotosMode = 'home';
_activityPhotosNetworkSub = null;
_requestSub = null;
_cachePromise = null;
_currentPlaceEntityId = null;
@@ -600,6 +606,143 @@ export default class NostrDataService extends Service {
);
}
/**
* Loads kind 360 (Place Photo) events for the activity feed, scoped to the
* given time window and source mode.
*
* - `'home'` mode: fetches photos authored by the user's followed contacts
* (followees). If contacts haven't loaded yet (`_contactPubkeys` is null or
* empty), this returns early and is re-triggered automatically by the
* `ContactsModel` subscription callback when contacts arrive.
* - `'explore'` mode: fetches all kind 360 photos in the time window from
* trusted relays (no authors filter). Provenance is captured so
* `isTrustedEvent` can filter at presentation time.
*
* @param {number} since Unix timestamp (seconds) for the start of the window
* @param {string} [mode='home'] Source mode
*/
async loadActivityPhotos(since, mode = 'home') {
this._activityPhotosSince = since;
this._activityPhotosMode = mode;
if (this._activityPhotosNetworkSub) {
this._activityPhotosNetworkSub.unsubscribe();
this._activityPhotosNetworkSub = null;
}
if (mode === 'home') {
this._loadFolloweePhotos(since);
} else if (mode === 'explore') {
this._loadTrustedRelaysPhotos(since);
}
}
/**
* Fetches kind 360 photos from the user's followees (followed contacts),
* batched by author pubkey (≤100 per filter to stay under relay REQ limits).
* Defers if contacts haven't loaded yet.
*/
_loadFolloweePhotos(since) {
const pubkeys = this._contactPubkeys
? Array.from(this._contactPubkeys)
: [];
if (pubkeys.length === 0) {
console.debug(
'[nostr-data] No contacts loaded yet, deferring followee photo load'
);
return;
}
const filters = this._batchAuthorFilters(pubkeys, [360], since);
console.debug('[nostr-data] Loading followee photos', {
filterCount: filters.length,
pubkeyCount: pubkeys.length,
since,
});
// 1. Populate the store from the local Nostr IDB cache (instant)
this._cachePromise
.then(() => this.cache.query(filters))
.then((cachedEvents) => {
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
})
.catch((e) => {
console.warn(
'[nostr-data] Failed to read followee photos from local Nostr IDB cache',
e
);
});
// 2. Request fresh events from the network (captures provenance for trust)
this._activityPhotosNetworkSub = this._requestContentWithProvenance(
this.activeReadRelays,
filters,
'[nostr-data] Error fetching followee photos:'
);
}
/**
* Fetches all kind 360 photos in the time window from trusted relays (no
* authors filter). Provenance is captured so `isTrustedEvent` can filter
* at presentation time. Fires immediately — no contacts dependency.
*/
_loadTrustedRelaysPhotos(since) {
const filters = [{ kinds: [360], since }];
console.debug('[nostr-data] Loading trusted relay photos', { since });
// 1. Populate the store from the local Nostr IDB cache (instant)
this._cachePromise
.then(() => this.cache.query(filters))
.then((cachedEvents) => {
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
})
.catch((e) => {
console.warn(
'[nostr-data] Failed to read trusted relay photos from local Nostr IDB cache',
e
);
});
// 2. Request fresh events from the network (captures provenance for trust)
this._activityPhotosNetworkSub = this._requestContentWithProvenance(
this.activeReadRelays,
filters,
'[nostr-data] Error fetching trusted relay photos:'
);
}
/**
* Builds an array of Nostr filters, each with ≤ `BATCH_SIZE` author pubkeys.
*
* @param {string[]} pubkeys Author pubkeys to batch
* @param {number[]} kinds Event kinds to request
* @param {number} since Unix timestamp (seconds) for the start of the window
* @returns {object[]} Array of filter objects
*/
_batchAuthorFilters(pubkeys, kinds, since) {
const BATCH_SIZE = 100;
const filters = [];
for (let i = 0; i < pubkeys.length; i += BATCH_SIZE) {
filters.push({
kinds,
authors: pubkeys.slice(i, i + BATCH_SIZE),
since,
});
}
return filters;
}
loadProfiles(pubkeys) {
const newPubkeys = pubkeys.filter(
(pk) => pk && !this._profileModelSubs.has(pk)
@@ -659,6 +802,15 @@ export default class NostrDataService extends Service {
this.contacts = contacts;
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
this._updatePlacePhotos();
// Re-trigger activity photo load now that contacts are available.
// If no activity load has been requested yet, _activityPhotosSince
// is null and the call is a no-op.
if (this._activityPhotosSince !== null) {
this.loadActivityPhotos(
this._activityPhotosSince,
this._activityPhotosMode
);
}
});
this._blossomSub = this.store
@@ -896,6 +1048,10 @@ export default class NostrDataService extends Service {
this._incomingZapsNetworkSub.unsubscribe();
this._incomingZapsNetworkSub = null;
}
if (this._activityPhotosNetworkSub) {
this._activityPhotosNetworkSub.unsubscribe();
this._activityPhotosNetworkSub = null;
}
}
willDestroy() {
+56 -57
View File
@@ -56,11 +56,9 @@ export default class PlaceNameResolverService extends Service {
*/
async resolveInBackground(entries) {
const pending = entries.filter((e) => e.placeNameLoading);
if (pending.length === 0) {
this._maybeBatchFetchNames(entries);
return;
}
if (pending.length === 0) return;
// Phase 1: Cache lookup (localForage → OSM IDB cache)
await Promise.all(
pending.map(async (entry) => {
const name = await this._resolveCachedName(entry);
@@ -71,20 +69,6 @@ export default class PlaceNameResolverService extends Service {
})
);
// Trigger re-render via caller, then kick off the network batch for
// entries that are still loading.
this._maybeBatchFetchNames(entries);
}
_isUnresolvable(entry) {
return this._unresolvable.has(entry.placeIdentifier);
}
_maybeBatchFetchNames(entries) {
const unresolved = entries.filter(
(e) => e.placeNameLoading && !this._isUnresolvable(e)
);
// Apply fallbacks for items already marked unresolvable in this session
for (const entry of entries) {
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
@@ -93,52 +77,67 @@ export default class PlaceNameResolverService extends Service {
}
}
if (unresolved.length === 0) {
return;
}
const unresolved = entries.filter(
(e) => e.placeNameLoading && !this._isUnresolvable(e)
);
if (unresolved.length === 0) return;
// Build a stable signature so we only fire one batch request per unique set
// Phase 2: Batch OSM fetch (awaited, deduplicated by signature)
try {
const nameMap = await this._getOrCreateBatch(unresolved);
for (const entry of entries) {
if (!entry.placeNameLoading) continue;
if (nameMap.has(entry.placeIdentifier)) {
entry.placeName = nameMap.get(entry.placeIdentifier);
entry.placeNameLoading = false;
} else {
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
} catch (e) {
console.error('[place-name-resolver] Batch resolution failed', e);
for (const entry of entries) {
if (entry.placeNameLoading) {
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
}
}
_isUnresolvable(entry) {
return this._unresolvable.has(entry.placeIdentifier);
}
/**
* Returns a promise for the batch OSM fetch, deduplicating calls that have
* the same set of place identifiers. If a batch with the same signature is
* already in flight, returns that promise instead of firing a new one.
*
* @param {Array} unresolved Entries that still need a network fetch
* @returns {Promise<Map<string, string>>} Map of placeIdentifier → name
*/
_getOrCreateBatch(unresolved) {
const signature = unresolved
.map((e) => e.placeIdentifier)
.sort()
.join('|');
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
return;
}
this._lastBatchSignature = signature;
this._pendingBatchPromise = this._batchResolveNames(unresolved)
.then((nameMap) => {
// Merge resolved names back into the entries
for (const entry of entries) {
if (!entry.placeNameLoading) continue;
if (nameMap.has(entry.placeIdentifier)) {
const name = nameMap.get(entry.placeIdentifier);
entry.placeName = name;
entry.placeNameLoading = false;
} else {
// Could not be resolved (deleted object, network error for this item).
// Mark as unresolvable for this session and use a fallback.
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
})
.catch((e) => {
console.error('[place-name-resolver] Batch name resolution failed', e);
// On a total failure, mark all as unresolvable and apply fallbacks
for (const entry of entries) {
if (entry.placeNameLoading) {
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
})
.finally(() => {
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
return this._pendingBatchPromise;
}
this._lastBatchSignature = signature;
this._pendingBatchPromise = this._batchResolveNames(unresolved).finally(
() => {
this._pendingBatchPromise = null;
});
}
);
return this._pendingBatchPromise;
}
_fallbackName(entry) {
+64 -16
View File
@@ -2417,6 +2417,38 @@ button.create-place {
padding: 4rem 1rem;
}
/* Tab Navigation */
.tab-nav {
display: flex;
border-bottom: 1px solid var(--divider-color);
height: 48px;
flex-shrink: 0;
}
.tab-nav-button {
flex: 1;
border: none;
background: none;
font-size: 0.9rem;
cursor: pointer;
color: var(--secondary-text-color);
font-family: inherit;
border-bottom: 2px solid transparent;
transition:
border-color 0.15s,
color 0.15s;
}
.tab-nav-button:hover {
color: var(--body-text-color);
}
.tab-nav-button.is-active {
border-bottom-color: var(--link-color);
font-weight: 600;
color: var(--body-text-color);
}
/* Contributions Timeline */
.contributions-list {
list-style: none;
@@ -2661,14 +2693,14 @@ button.create-place {
}
}
/* Activity Timeline — zap activity list rendered in the sidebar */
/* Activity Timeline — activity list rendered in the sidebar */
.activity-list {
list-style: none;
padding: 0;
margin: -1rem -1rem 0;
}
.activity-zap-item {
.activity-item {
width: 100%;
text-align: left;
border: none;
@@ -2687,7 +2719,7 @@ button.create-place {
background: var(--hover-bg);
}
& .zap-sender-avatar {
& .sender-avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
@@ -2696,7 +2728,7 @@ button.create-place {
background: #f0f0f0;
}
& .zap-sender-avatar-placeholder {
& .sender-avatar-placeholder {
flex-shrink: 0;
width: 32px;
height: 32px;
@@ -2708,39 +2740,42 @@ button.create-place {
color: #999;
}
& .zap-header {
& .header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
& .zap-sender-line {
& .sender-line {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem;
& .zap-sender-name {
& .sender-name {
font-weight: bold;
}
& .zap-action {
& .action {
color: #666;
font-weight: normal;
}
}
& .zap-amount {
& .amount {
flex-shrink: 0;
font-weight: bold;
font-size: 0.95rem;
color: var(--body-text-color);
white-space: nowrap;
align-self: center;
display: flex;
align-items: center;
}
& .zap-message {
& .message {
color: var(--body-text-color);
font-size: 0.85rem;
font-style: italic;
@@ -2762,7 +2797,7 @@ button.create-place {
}
}
& .zap-context {
& .context {
display: flex;
align-items: center;
gap: 6px;
@@ -2770,19 +2805,20 @@ button.create-place {
font-size: 0.8rem;
margin-top: 8px;
& .zap-context-images {
& .context-images {
display: flex;
align-items: center;
gap: 0.5rem;
}
& .zap-context-thumb {
& .context-thumb {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
background: #f0f0f0;
position: relative;
& img {
width: 100%;
@@ -2790,16 +2826,28 @@ button.create-place {
object-fit: cover;
display: block;
}
& .context-thumb-badge {
position: absolute;
bottom: 2px;
right: 2px;
background: rgb(0 0 0 / 65%);
color: #fff;
font-size: 0.7rem;
font-weight: bold;
padding: 1px 4px;
border-radius: 4px;
}
}
& .zap-context-text {
& .context-text {
display: flex;
align-items: baseline;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
& .zap-place-name {
& .activity-place-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -2807,7 +2855,7 @@ button.create-place {
min-width: 0;
}
& .zap-date {
& .activity-date {
flex-shrink: 0;
white-space: nowrap;
}
+2
View File
@@ -6,6 +6,8 @@ import ActivityTimeline from '#components/activity-timeline';
@items={{@controller.items}}
@isLoading={{@controller.loadTask.isRunning}}
@isConnected={{@controller.isConnected}}
@sourceMode={{@controller.sourceMode}}
@onSetSourceMode={{@controller.setSourceMode}}
@scrollTop={{@controller.scrollTop}}
@onSelect={{@controller.selectItem}}
@onBack={{@controller.backToMenu}}
+114 -1
View File
@@ -14,7 +14,7 @@ import {
getZapEventPointer,
getZapRequest,
} from 'applesauce-common/helpers';
import { parsePhotoFromEvent } from './contributions';
import { parsePhotoFromEvent, applyDeletions } from './contributions';
/**
* A single activity timeline entry.
@@ -27,6 +27,7 @@ export class ActivityEntry {
type = 'zap';
photoEventId;
photo;
photos = [];
placeIdentifier;
osmType;
osmId;
@@ -43,7 +44,10 @@ export class ActivityEntry {
constructor({
photoEventId,
photo,
photos,
placeIdentifier,
osmType,
osmId,
senderPubkey,
amountSats,
message,
@@ -51,7 +55,10 @@ export class ActivityEntry {
}) {
this.photoEventId = photoEventId;
this.photo = photo;
this.photos = photos ?? [];
this.placeIdentifier = placeIdentifier;
this.osmType = osmType;
this.osmId = osmId;
this.senderPubkey = senderPubkey;
this.amountSats = amountSats;
this.message = message;
@@ -140,3 +147,109 @@ export function enrichWithPhoto(entry, store, userPubkey) {
return true;
}
const HOUR_IN_SECONDS = 60 * 60;
/**
* Builds a single `ActivityEntry` (type 'photo') from a group of kind 360
* events by the same author for the same OSM entity.
*
* @param {Array} events Kind 360 events in this sub-group (same author + place)
* @returns {ActivityEntry}
*/
function buildSocialEntry(events) {
const photos = events
.map(parsePhotoFromEvent)
.filter(Boolean)
.sort((a, b) => a.createdAt - b.createdAt);
const createdAt = events.reduce(
(max, e) => (e.created_at > max ? e.created_at : max),
0
);
const placeIdentifier =
events[0].tags?.find((t) => t[0] === 'i')?.[1] || null;
let osmType;
let osmId;
if (placeIdentifier) {
const parts = placeIdentifier.split(':');
osmType = parts[1];
osmId = parts[2];
}
const entry = new ActivityEntry({
photo: photos[0] || null,
photos,
placeIdentifier,
osmType,
osmId,
senderPubkey: events[0].pubkey,
createdAt,
});
entry.type = 'photo';
return entry;
}
/**
* Groups kind 360 (Place Photo) events from followed contacts into activity
* entries, keyed by (author + OSM entity + time proximity).
*
* This mirrors the time-proximity grouping logic from `groupPhotoContributions`
* in `utils/contributions.js` but adds author as a grouping dimension so that
* "Alice added 3 photos to Central Park Café" is one entry.
*
* @param {Array} events Mixed kind 360 / kind 5 events from followed contacts
* @param {number} [thresholdHours=3] Max gap in hours within a sub-group
* @returns {Array} Sorted `ActivityEntry` entries (newest first)
*/
export function groupSocialPhotos(events, thresholdHours = 3) {
if (!events || events.length === 0) return [];
const photoEvents = applyDeletions(events);
if (photoEvents.length === 0) return [];
// Group by (author pubkey + OSM entity identifier)
const byAuthorEntity = new Map();
for (const event of photoEvents) {
const entityTag = (event.tags || []).find((t) => t[0] === 'i');
const entityId = entityTag?.[1];
if (!entityId) continue;
const key = `${event.pubkey}:${entityId}`;
if (!byAuthorEntity.has(key)) byAuthorEntity.set(key, []);
byAuthorEntity.get(key).push(event);
}
const thresholdSeconds = thresholdHours * HOUR_IN_SECONDS;
const entries = [];
for (const [, groupEvents] of byAuthorEntity) {
// Sort newest-first within the group
const sorted = [...groupEvents].sort((a, b) => b.created_at - a.created_at);
// Walk newest -> oldest, starting a new sub-group when the gap to the
// previous event exceeds the threshold.
let currentGroup = [];
let prevTime = null;
for (const event of sorted) {
if (prevTime !== null && prevTime - event.created_at > thresholdSeconds) {
entries.push(buildSocialEntry(currentGroup));
currentGroup = [];
}
currentGroup.push(event);
prevTime = event.created_at;
}
if (currentGroup.length > 0) {
entries.push(buildSocialEntry(currentGroup));
}
}
// Sort entries by their newest event's created_at, descending. Entries with
// no usable photos (e.g. malformed imeta) are filtered out.
return entries
.filter((e) => e.photos.length > 0)
.sort((a, b) => b.createdAt - a.createdAt);
}
+1 -1
View File
@@ -120,7 +120,7 @@ export function parsePhotoFromEvent(event) {
* @param {Array} events Mixed kind 360 and kind 5 events
* @returns {Array} Surviving kind 360 events
*/
function applyDeletions(events) {
export function applyDeletions(events) {
const deletedIds = new Set();
for (const event of events) {
if (event.kind === 5) {
+1
View File
@@ -160,6 +160,7 @@ export const POI_ICON_RULES = [
{ tags: { tourism: 'hostel' }, icon: 'person-sleeping-in-bed' },
{ tags: { tourism: 'motel' }, icon: 'person-sleeping-in-bed' },
{ tags: { tourism: 'guest_house' }, icon: 'person-sleeping-in-bed' },
{ tags: { leisure: 'resort' }, icon: 'person-sleeping-in-bed' },
// Sports / Motorsports
{ tags: { sport: 'motor' }, icon: 'flag-checkered' },
+4 -1
View File
@@ -55,7 +55,10 @@ export const POI_CATEGORIES = [
id: 'accommodation',
label: 'Hotels',
icon: 'person-sleeping-in-bed',
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'],
filter: [
'["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]',
'["leisure"="resort"]',
],
types: ['node', 'way', 'relation'],
},
];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.32.0",
"version": "1.32.1",
"private": true,
"description": "Unhosted maps app",
"repository": {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -42,7 +42,7 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-Znuwpp9Y.js"></script>
<script type="module" crossorigin src="/assets/main-qw72UzgO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CEhcTO-R.css">
</head>
<body>
+11 -8
View File
@@ -23,9 +23,12 @@ class MockActivityService extends Service {
senderProfileLoading: false,
},
];
@tracked sourceMode = 'home';
async load() {}
setSourceMode() {}
stop() {
this.items = [];
}
@@ -97,12 +100,12 @@ module('Acceptance | activity', function (hooks) {
test('activity items are rendered as zap rows', async function (assert) {
await visit('/activity');
await waitFor('.activity-zap-item');
assert.dom('.activity-zap-item').exists({ count: 1 });
assert.dom('.zap-sender-name').hasText('Alice');
assert.dom('.zap-action').includesText('zapped your photo');
assert.dom('.zap-amount').includesText('21 ⚡');
assert.dom('.zap-message').includesText('Great photo!');
await waitFor('.activity-item');
assert.dom('.activity-item').exists({ count: 1 });
assert.dom('.sender-name').hasText('Alice');
assert.dom('.action').includesText('zapped your photo');
assert.dom('.amount').includesText('21 ⚡');
assert.dom('.message').includesText('Great photo!');
});
test('closing the sidebar returns to index', async function (assert) {
@@ -124,9 +127,9 @@ module('Acceptance | activity', function (hooks) {
const mapUi = this.owner.lookup('service:map-ui');
await visit('/activity');
await waitFor('.activity-zap-item');
await waitFor('.activity-item');
await click('.activity-zap-item');
await click('.activity-item');
assert.ok(
currentURL().includes('/place/osm:node:123'),
@@ -13,6 +13,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
hooks.beforeEach(function () {
this.noop = noop;
this.emptyItems = [];
this.sourceMode = 'home';
this.onSetSourceMode = () => {};
});
test('it renders a loading state', async function (assert) {
@@ -22,6 +24,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{true}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@@ -41,6 +45,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@@ -60,6 +66,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@@ -82,6 +90,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@@ -135,6 +145,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.items}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@@ -143,7 +155,7 @@ module('Integration | Component | activity-timeline', function (hooks) {
</template>
);
assert.dom('.activity-zap-item').exists({ count: 2 });
assert.dom('.activity-item').exists({ count: 2 });
assert.dom(this.element).includesText('Alice');
assert.dom(this.element).includesText('21 ⚡');
assert.dom(this.element).includesText('Bob');
@@ -162,6 +174,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.handleBack}}
@onClose={{this.noop}}
@@ -173,4 +187,123 @@ module('Integration | Component | activity-timeline', function (hooks) {
await click('.sidebar-header .back-btn');
assert.true(backClicked);
});
test('it renders tab nav with Home and Explore tabs', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.tab-nav').exists();
assert.dom('.tab-nav-button').exists({ count: 2 });
const buttons = this.element.querySelectorAll('.tab-nav-button');
assert.strictEqual(buttons[0].textContent.trim(), 'Home');
assert.strictEqual(buttons[1].textContent.trim(), 'Explore');
});
test('clicking a tab fires @onSetSourceMode with the tab value', async function (assert) {
let selectedMode = null;
this.handleSetSourceMode = (mode) => {
selectedMode = mode;
};
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.handleSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
const buttons = this.element.querySelectorAll('.tab-nav-button');
await click(buttons[1]);
assert.strictEqual(
selectedMode,
'explore',
'onSetSourceMode called with explore'
);
});
test('explore mode shows items when not connected', async function (assert) {
this.items = [
{
type: 'photo',
photoEventId: 'photo-1',
photo: {
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
},
placeIdentifier: 'osm:node:111',
authorPubkey: 'b'.repeat(64),
createdAt: 2000,
authorName: 'Alice',
authorAvatar: 'https://x.com/avatar.jpg',
authorProfileLoading: false,
},
];
this.sourceMode = 'explore';
await render(
<template>
<ActivityTimeline
@items={{this.items}}
@isLoading={{false}}
@isConnected={{false}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.activity-list').exists('items render in explore mode');
assert.dom('.activity-list li').exists({ count: 1 });
assert.dom('.empty-state').doesNotExist('no connect prompt in explore');
});
test('explore mode shows empty state when not connected and no items', async function (assert) {
this.sourceMode = 'explore';
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.empty-state').includesText('No activity yet.');
assert.dom('.empty-state').doesNotIncludeText('Connect');
});
});
@@ -36,15 +36,15 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
assert.dom('.zap-sender-name').hasText('Alice');
assert.dom('.zap-action').includesText('zapped your photo');
assert.dom('.zap-amount').hasText('21 ⚡');
assert.dom('.zap-message').includesText('Great shot!');
assert.dom('.sender-name').hasText('Alice');
assert.dom('.action').includesText('zapped your photo');
assert.dom('.amount').hasText('21 ⚡');
assert.dom('.message').includesText('Great shot!');
assert
.dom('.zap-sender-avatar')
.dom('.sender-avatar')
.hasAttribute('src', 'https://x.com/avatar.jpg');
assert
.dom('.zap-context-thumb img')
.dom('.context-thumb img')
.hasAttribute('src', 'https://x.com/thumb.jpg');
});
@@ -68,8 +68,8 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
assert.dom('.zap-sender-avatar-placeholder').exists();
assert.dom('.zap-sender-avatar').doesNotExist();
assert.dom('.sender-avatar-placeholder').exists();
assert.dom('.sender-avatar').doesNotExist();
});
test('it omits the message line when there is no message', async function (assert) {
@@ -91,7 +91,7 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
assert.dom('.zap-message').doesNotExist();
assert.dom('.message').doesNotExist();
});
test('it omits the context thumbnail when there is no photo', async function (assert) {
@@ -113,8 +113,8 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
assert.dom('.zap-context-thumb').doesNotExist();
assert.dom('.zap-context-text').exists();
assert.dom('.context-thumb').doesNotExist();
assert.dom('.context-text').exists();
});
test('clicking the item fires @onSelect with the item', async function (assert) {
@@ -141,7 +141,7 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
await click('.activity-zap-item');
await click('.activity-item');
assert.strictEqual(selected, this.item);
});
@@ -168,7 +168,7 @@ module('Integration | Component | activity-zap-item', function (hooks) {
);
assert
.dom('.zap-context-text')
.dom('.context-text')
.includesText('Café Central', 'Resolved place name is displayed');
assert
.dom('.contribution-name-loading')
@@ -197,7 +197,7 @@ module('Integration | Component | activity-zap-item', function (hooks) {
);
assert
.dom('.zap-context-text .contribution-name-loading')
.dom('.context-text .contribution-name-loading')
.hasText('Loading…', 'Loading state is displayed');
});
@@ -223,11 +223,11 @@ module('Integration | Component | activity-zap-item', function (hooks) {
);
assert
.dom('.zap-context-text .contribution-name-loading')
.dom('.context-text .contribution-name-loading')
.hasText('Unnamed place', 'Fallback name is displayed');
});
test('it displays place name in zap-place-name and date in zap-date', async function (assert) {
test('it displays place name in activity-place-name and date in activity-date', async function (assert) {
this.item = new ActivityEntry({
photoEventId: 'photo-1',
photo: null,
@@ -248,14 +248,14 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
const placeNameEl = this.element.querySelector('.zap-place-name');
const dateEl = this.element.querySelector('.zap-date');
const placeNameEl = this.element.querySelector('.activity-place-name');
const dateEl = this.element.querySelector('.activity-date');
assert.ok(placeNameEl, 'zap-place-name element exists');
assert.ok(dateEl, 'zap-date element exists');
assert.ok(placeNameEl, 'activity-place-name element exists');
assert.ok(dateEl, 'activity-date element exists');
assert.ok(
placeNameEl.textContent.includes('Café Central'),
'Place name is in zap-place-name'
'Place name is in activity-place-name'
);
assert.ok(dateEl.textContent.includes('hr ago'), 'Date contains hr ago');
assert.notOk(
@@ -290,7 +290,9 @@ module('Integration | Component | activity-zap-item', function (hooks) {
</template>
);
assert.dom('.zap-place-name').exists('zap-place-name element exists');
assert.dom('.zap-date').hasText('1 hr ago', 'Date is still visible');
assert
.dom('.activity-place-name')
.exists('activity-place-name element exists');
assert.dom('.activity-date').hasText('1 hr ago', 'Date is still visible');
});
});
@@ -133,6 +133,9 @@ module('Integration | Component | photo-gallery', function (hooks) {
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
assert.ok(blossomSpy.notCalled, 'blossom.delete was NOT called');
assert
.dom('.dropdown-popover')
.doesNotExist('dropdown menu was closed on cancel');
});
test('it performs full deletion flow when confirmed', async function (assert) {
@@ -226,7 +229,58 @@ module('Integration | Component | photo-gallery', function (hooks) {
toastSpy.calledWith('Photo deleted successfully'),
'success toast was shown'
);
assert.ok(closed, 'gallery was closed after deletion');
assert
.dom('.dropdown-popover')
.doesNotExist('dropdown menu was closed after deletion');
assert.notOk(closed, 'gallery was kept open after deletion');
});
test('it closes the gallery when deleting the last remaining photo', async function (assert) {
this.nostrAuth.pubkey = USER_A;
this.settings.update('experimentalEnablePhotoDeletion', true);
Object.defineProperty(this.nostrAuth, 'signer', {
configurable: true,
get: () => ({
signEvent: async (e) => ({
...e,
id: 'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
sig: 'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
pubkey: USER_A,
}),
getPublicKey: async () => USER_A,
}),
});
this.set('photos', [this.photos[0]]);
this.selectedPhoto = this.photos[0];
let closed = false;
this.handleClose = () => {
closed = true;
};
sinon.stub(window, 'confirm').returns(true);
sinon.stub(this.blossom, 'delete').resolves();
sinon.stub(this.nostrRelay, 'publish').resolves();
sinon.stub(this.nostrData.store, 'add');
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
@onClose={{this.handleClose}}
/>
</div>
</template>
);
await click('.dropdown-trigger-btn');
await click('.dropdown-item.text-danger');
assert.ok(closed, 'gallery was closed when last photo was deleted');
});
test('it copies event id to clipboard', async function (assert) {
@@ -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');
});
});
+276 -12
View File
@@ -10,6 +10,16 @@ import {
setVerifyWrappedEventMethod(fakeVerifyEvent);
class MockPlaceNameResolver extends Service {
resolveBookmark() {
return null;
}
resolveInBackground() {
return new Promise(() => {});
}
reset() {}
}
const USER_PUBKEY = 'a'.repeat(64);
const SENDER_PUBKEY = 'b'.repeat(64);
@@ -64,7 +74,7 @@ function makePhotoEvent(opts = {}) {
id: opts.id || PHOTO_EVENT_ID_1,
pubkey: opts.author || USER_PUBKEY,
kind: 360,
created_at: 5000,
created_at: opts.created_at || 5000,
tags: [
['i', opts.placeIdentifier || 'osm:node:123'],
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
@@ -76,6 +86,7 @@ function makePhotoEvent(opts = {}) {
class MockNostrDataService extends Service {
@tracked profiles = {};
_contactPubkeys = new Set();
store = {
events: new Map(),
@@ -112,6 +123,7 @@ class MockNostrDataService extends Service {
};
loadProfiles() {}
loadActivityPhotos() {}
getProfile(pubkey) {
return this.profiles[pubkey];
@@ -125,9 +137,10 @@ module('Unit | Service | activity', function (hooks) {
hooks.beforeEach(function () {
this.owner.register('service:nostrData', MockNostrDataService);
this.owner.register('service:placeNameResolver', MockPlaceNameResolver);
});
test('_updateItems parses receipts and enriches with photos from the store', function (assert) {
test('_updateZapItems parses receipts and enriches with photos from the store', function (assert) {
const service = this.owner.lookup('service:activity');
const photoEvent = makePhotoEvent({
id: PHOTO_EVENT_ID_1,
@@ -140,7 +153,7 @@ module('Unit | Service | activity', function (hooks) {
message: 'Love it!',
});
service._updateItems([receipt], USER_PUBKEY);
service._updateZapItems([receipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 1);
assert.strictEqual(service.items[0].type, 'zap');
@@ -151,7 +164,7 @@ module('Unit | Service | activity', function (hooks) {
assert.ok(service.items[0].photo, 'photo is populated');
});
test('_updateItems filters out zaps for non-photo events', function (assert) {
test('_updateZapItems filters out zaps for non-photo events', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add({
id: TEXT_EVENT_ID,
@@ -163,12 +176,12 @@ module('Unit | Service | activity', function (hooks) {
const receipt = makeZapReceiptEvent({ zappedEventId: TEXT_EVENT_ID });
service._updateItems([receipt], USER_PUBKEY);
service._updateZapItems([receipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 0, 'non-photo zap filtered out');
});
test('_updateItems filters out zaps for photos not authored by the user', function (assert) {
test('_updateZapItems filters out zaps for photos not authored by the user', function (assert) {
const service = this.owner.lookup('service:activity');
const otherUserPhoto = makePhotoEvent({
id: PHOTO_EVENT_ID_OTHER,
@@ -180,7 +193,7 @@ module('Unit | Service | activity', function (hooks) {
zappedEventId: PHOTO_EVENT_ID_OTHER,
});
service._updateItems([receipt], USER_PUBKEY);
service._updateZapItems([receipt], USER_PUBKEY);
assert.strictEqual(
service.items.length,
@@ -189,7 +202,7 @@ module('Unit | Service | activity', function (hooks) {
);
});
test('_updateItems filters out zaps not directed at the user', function (assert) {
test('_updateZapItems filters out zaps not directed at the user', function (assert) {
const service = this.owner.lookup('service:activity');
const photoEvent = makePhotoEvent({ id: PHOTO_EVENT_ID_1 });
service.nostrData.store.add(photoEvent);
@@ -199,7 +212,7 @@ module('Unit | Service | activity', function (hooks) {
zappedEventId: PHOTO_EVENT_ID_1,
});
service._updateItems([receipt], USER_PUBKEY);
service._updateZapItems([receipt], USER_PUBKEY);
assert.strictEqual(
service.items.length,
@@ -208,7 +221,7 @@ module('Unit | Service | activity', function (hooks) {
);
});
test('_updateItems sorts entries newest-first', function (assert) {
test('_updateZapItems sorts entries newest-first', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 }));
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_2 }));
@@ -224,7 +237,7 @@ module('Unit | Service | activity', function (hooks) {
created_at: 9000,
});
service._updateItems([oldReceipt, newReceipt], USER_PUBKEY);
service._updateZapItems([oldReceipt, newReceipt], USER_PUBKEY);
assert.strictEqual(service.items.length, 2);
assert.strictEqual(service.items[0].createdAt, 9000, 'newest first');
@@ -234,7 +247,7 @@ module('Unit | Service | activity', function (hooks) {
test('stop clears items and resets state', function (assert) {
const service = this.owner.lookup('service:activity');
service.nostrData.store.add(makePhotoEvent({ id: PHOTO_EVENT_ID_1 }));
service._updateItems(
service._updateZapItems(
[makeZapReceiptEvent({ zappedEventId: PHOTO_EVENT_ID_1 })],
USER_PUBKEY
);
@@ -270,4 +283,255 @@ module('Unit | Service | activity', function (hooks) {
assert.strictEqual(entry.senderAvatar, 'https://x.com/avatar.jpg');
assert.false(entry.senderProfileLoading);
});
test('_updateSocialItems groups photos by author + place', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const photo1 = makePhotoEvent({
id: 'p1'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 1000,
});
const photo2 = makePhotoEvent({
id: 'p2'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 2000,
});
service._updateSocialItems([photo1, photo2]);
assert.strictEqual(
service.items.length,
1,
'one entry for same author + place'
);
assert.strictEqual(service.items[0].type, 'photo');
assert.strictEqual(service.items[0].photos.length, 2);
assert.strictEqual(service.items[0].senderPubkey, SENDER_PUBKEY);
});
test('_matchesSourceMode filters by contact pubkeys in home mode', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'home';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const followedPhoto = makePhotoEvent({ author: SENDER_PUBKEY });
const unfollowedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
const ownPhoto = makePhotoEvent({ author: USER_PUBKEY });
assert.true(
service._matchesSourceMode(followedPhoto),
'followed contact passes'
);
assert.false(
service._matchesSourceMode(unfollowedPhoto),
'unfollowed pubkey rejected'
);
assert.false(service._matchesSourceMode(ownPhoto), 'own photo excluded');
});
test('_matchesSourceMode in explore mode includes trusted strangers, excludes own photos and followees', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const STRANGER_PUBKEY = 'c'.repeat(64);
// Mock isTrustedEvent to return true for SENDER_PUBKEY and STRANGER_PUBKEY
service.nostrData.isTrustedEvent = (event) =>
event.pubkey === SENDER_PUBKEY || event.pubkey === STRANGER_PUBKEY;
const trustedStrangerPhoto = makePhotoEvent({ author: STRANGER_PUBKEY });
const trustedFolloweePhoto = makePhotoEvent({ author: SENDER_PUBKEY });
const untrustedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
const ownPhoto = makePhotoEvent({ author: USER_PUBKEY });
assert.true(
service._matchesSourceMode(trustedStrangerPhoto),
'trusted stranger passes in explore mode'
);
assert.false(
service._matchesSourceMode(trustedFolloweePhoto),
'trusted followee excluded in explore mode'
);
assert.false(
service._matchesSourceMode(untrustedPhoto),
'untrusted event rejected in explore mode'
);
assert.false(
service._matchesSourceMode(ownPhoto),
'own photo excluded in explore mode'
);
});
test('_matchesSourceMode in explore mode with no pubkey includes all trusted events', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = null;
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = null;
const STRANGER_PUBKEY = 'c'.repeat(64);
service.nostrData.isTrustedEvent = (event) =>
event.pubkey === SENDER_PUBKEY || event.pubkey === STRANGER_PUBKEY;
const trustedPhoto = makePhotoEvent({ author: SENDER_PUBKEY });
const trustedStrangerPhoto = makePhotoEvent({ author: STRANGER_PUBKEY });
const untrustedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
assert.true(
service._matchesSourceMode(trustedPhoto),
'trusted event passes with no pubkey'
);
assert.true(
service._matchesSourceMode(trustedStrangerPhoto),
'trusted stranger passes with no pubkey'
);
assert.false(
service._matchesSourceMode(untrustedPhoto),
'untrusted event rejected with no pubkey'
);
});
test('_updateSocialItems merges with zap items sorted by createdAt', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
// Seed a zap item
service.nostrData.store.add(
makePhotoEvent({ id: PHOTO_EVENT_ID_1, placeIdentifier: 'osm:node:50' })
);
service._updateZapItems(
[
makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_1,
created_at: 5000,
}),
],
USER_PUBKEY
);
// Add a social photo that is newer
const socialPhoto = makePhotoEvent({
id: 'sp1'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 9000,
});
service._updateSocialItems([socialPhoto]);
assert.strictEqual(service.items.length, 2);
assert.strictEqual(
service.items[0].createdAt,
9000,
'newer social photo first'
);
assert.strictEqual(service.items[1].createdAt, 5000, 'older zap second');
});
test('explore mode excludes zap items from merged results', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
// Seed a zap item
service.nostrData.store.add(
makePhotoEvent({ id: PHOTO_EVENT_ID_1, placeIdentifier: 'osm:node:50' })
);
service._updateZapItems(
[
makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_1,
created_at: 5000,
}),
],
USER_PUBKEY
);
// Add a trusted stranger social photo
const STRANGER_PUBKEY = 'c'.repeat(64);
service.nostrData.isTrustedEvent = (event) =>
event.pubkey === STRANGER_PUBKEY;
const socialPhoto = makePhotoEvent({
id: 'sp1'.padEnd(64, '0'),
author: STRANGER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 9000,
});
service._updateSocialItems([socialPhoto]);
assert.strictEqual(
service.items.length,
1,
'zap excluded in explore mode, only social photo shown'
);
assert.strictEqual(
service.items[0].createdAt,
9000,
'only the social photo is present'
);
});
test('setSourceMode re-filters existing events', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
// In explore mode, isTrustedEvent returns false (no provenance in mock)
service.nostrData.isTrustedEvent = () => false;
const followedPhoto = makePhotoEvent({
id: 'fp1'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 1000,
});
service._lastSocialEvents = [followedPhoto];
// In 'explore' mode, _matchesSourceMode returns false (isTrustedEvent is false)
service._updateSocialItems([followedPhoto]);
assert.strictEqual(
service.items.length,
0,
'no social items in explore mode with untrusted events'
);
// Switch to 'home' mode — now the followed photo should appear
service.setSourceMode('home');
assert.strictEqual(
service.items.length,
1,
're-filtered shows followed contact in home mode'
);
});
test('stop clears social items and resets state', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const photo = makePhotoEvent({
id: 'sp2'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
});
service._updateSocialItems([photo]);
assert.strictEqual(service.items.length, 1);
service.stop();
assert.strictEqual(service.items.length, 0, 'items cleared');
assert.strictEqual(service._socialItems.length, 0, 'social items cleared');
assert.strictEqual(service._zapItems.length, 0, 'zap items cleared');
});
});
+124
View File
@@ -856,6 +856,130 @@ module('Unit | Service | nostr-data | my contributions', function (hooks) {
});
});
module('Unit | Service | nostr-data | activity photos', function (hooks) {
setupNostrDataService(hooks);
test('loadActivityPhotos defers when contacts are not loaded yet', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
// _contactPubkeys is null initially (no loadProfile called)
assert.strictEqual(service._contactPubkeys, null);
await service.loadActivityPhotos(1000, 'home');
// No network request should have been made
const photoFilters = this.requestedFilters.filter(
(f) => f.kinds?.includes(360) && f.authors
);
assert.strictEqual(photoFilters.length, 0, 'no request without contacts');
});
test('loadActivityPhotos batches >100 pubkeys into multiple filters', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
// Create 250 contact pubkeys
const contactPubkeys = Array.from({ length: 250 }, (_, i) =>
makePubkey(100 + i)
);
// Load contacts so _contactPubkeys is populated
service.store.add(makeContactsEvent(userPubkey, contactPubkeys));
await service.loadProfile(userPubkey);
await service.loadActivityPhotos(1000, 'home');
const photoFilters = this.requestedFilters.filter(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
);
assert.strictEqual(
photoFilters.length,
3,
'250 pubkeys → 3 filters (100+100+50)'
);
assert.strictEqual(photoFilters[0].authors.length, 100);
assert.strictEqual(photoFilters[1].authors.length, 100);
assert.strictEqual(photoFilters[2].authors.length, 50);
});
test('loadActivityPhotos includes since in filter', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
const contactPubkey = makePubkey(2);
service.store.add(makeContactsEvent(userPubkey, [contactPubkey]));
await service.loadProfile(userPubkey);
const since = 12345;
await service.loadActivityPhotos(since, 'home');
const photoFilter = this.requestedFilters.find(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
);
assert.ok(photoFilter, 'photo filter with since found');
assert.strictEqual(photoFilter.since, since, 'since value matches');
assert.deepEqual(photoFilter.kinds, [360], 'requests kind 360');
});
test('loadActivityPhotos re-triggers when contacts arrive', async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const userPubkey = makePubkey(1);
const contactPubkey = makePubkey(2);
// Call loadActivityPhotos before contacts are loaded
await service.loadActivityPhotos(1000, 'home');
const beforeCount = this.requestedFilters.filter(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
).length;
assert.strictEqual(beforeCount, 0, 'no request before contacts');
// Now load contacts — the ContactsModel callback should re-trigger
service.store.add(makeContactsEvent(userPubkey, [contactPubkey]));
await service.loadProfile(userPubkey);
// Give the callback a tick to propagate
await new Promise((r) => setTimeout(r, 50));
const afterCount = this.requestedFilters.filter(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
).length;
assert.ok(afterCount > 0, 'request made after contacts arrived');
});
test("loadActivityPhotos in 'explore' mode fetches all photos without authors filter", async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const since = 9999;
await service.loadActivityPhotos(since, 'explore');
// Should make a request immediately (no contacts dependency)
const photoFilter = this.requestedFilters.find(
(f) => f.kinds?.includes(360) && f.since !== undefined && !f.authors
);
assert.ok(photoFilter, 'photo filter without authors found');
assert.strictEqual(photoFilter.since, since, 'since value matches');
assert.deepEqual(photoFilter.kinds, [360], 'requests kind 360');
assert.notOk(photoFilter.authors, 'no authors filter in explore mode');
});
test('_batchAuthorFilters produces correct batches', function (assert) {
const service = this.owner.lookup('service:nostr-data');
const pubkeys = Array.from({ length: 250 }, (_, i) => makePubkey(i));
const filters = service._batchAuthorFilters(pubkeys, [360], 1000);
assert.strictEqual(filters.length, 3, '250 → 3 filters');
assert.strictEqual(filters[0].authors.length, 100);
assert.strictEqual(filters[1].authors.length, 100);
assert.strictEqual(filters[2].authors.length, 50);
assert.deepEqual(filters[0].kinds, [360]);
assert.strictEqual(filters[0].since, 1000);
});
});
module('Unit | Service | nostr-data | zap receipts', function (hooks) {
setupNostrDataService(hooks);
@@ -207,4 +207,70 @@ module('Unit | Service | place-name-resolver', function (hooks) {
assert.strictEqual(resolver._lastBatchSignature, '');
assert.strictEqual(resolver._pendingBatchPromise, null);
});
test('deduplicated batch resolves entries from a second call with same place identifiers', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
let fetchCount = 0;
resolver.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
const map = new Map();
map.set('node:222', { title: 'Shared Place' });
return map;
};
// First call with entry1 — triggers a batch fetch
const entry1 = makeEntry({ placeIdentifier: 'osm:node:222' });
const promise1 = resolver.resolveInBackground([entry1]);
await flushPromises();
await promise1;
// Verify the name persisted so the second call's cache check finds it
assert.strictEqual(entry1.placeName, 'Shared Place');
assert.strictEqual(fetchCount, 1, 'only one batch fetch fired');
// Simulate the real-world race: a second set of entries for the same
// place arrives before the cache is consulted. Clear the persistent cache
// to simulate the incognito scenario where the batch hasn't persisted yet.
await localForage.clear('place-name-cache');
// Second call with a fresh entry2 — should resolve from the batch result
// that was already persisted (or re-use the pending batch if still in flight)
const entry2 = makeEntry({ placeIdentifier: 'osm:node:222' });
await resolver.resolveInBackground([entry2]);
assert.strictEqual(
entry2.placeName,
'Shared Place',
'second entry resolves even when batch was from a prior call'
);
});
test('pending batch is shared when second call fires before first completes', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
let fetchCount = 0;
resolver.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
const map = new Map();
map.set('node:333', { title: 'Concurrent Place' });
return map;
};
// First call — don't await yet (simulates subscription firing)
const entry1 = makeEntry({ placeIdentifier: 'osm:node:333' });
const promise1 = resolver.resolveInBackground([entry1]);
// Second call with a different entry for the same place — should piggyback
const entry2 = makeEntry({ placeIdentifier: 'osm:node:333' });
const promise2 = resolver.resolveInBackground([entry2]);
await Promise.all([promise1, promise2]);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'only one batch fetch fired');
assert.strictEqual(entry1.placeName, 'Concurrent Place');
assert.strictEqual(entry2.placeName, 'Concurrent Place');
});
});
+250
View File
@@ -3,6 +3,7 @@ import {
ActivityEntry,
parseZapReceipt,
enrichWithPhoto,
groupSocialPhotos,
} from 'marco/utils/activity';
import {
setVerifyWrappedEventMethod,
@@ -209,3 +210,252 @@ module('Unit | Utility | activity', function () {
assert.false(result, 'event not in store');
});
});
const SOCIAL_PK_A = 'a'.repeat(64);
const SOCIAL_PK_B = 'b'.repeat(64);
function makeSocialPhotoEvent(opts = {}) {
return {
id: opts.id || `e${(opts.idx ?? 0).toString().padStart(63, '0')}`,
pubkey: opts.author || SOCIAL_PK_A,
kind: 360,
created_at: opts.created_at || 5000,
tags: [
['i', opts.placeIdentifier || 'osm:node:123'],
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
],
content: '',
sig: 'sig',
};
}
module('Unit | Utility | activity | groupSocialPhotos', function () {
test('returns empty for no events', function (assert) {
assert.deepEqual(groupSocialPhotos([]), []);
assert.deepEqual(groupSocialPhotos(null), []);
});
test('groups by author + place within time threshold', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 2000,
}),
makeSocialPhotoEvent({
idx: 3,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 3000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries.length, 1, 'one entry for same author + place');
assert.strictEqual(entries[0].type, 'photo');
assert.strictEqual(entries[0].photos.length, 3);
assert.strictEqual(entries[0].senderPubkey, SOCIAL_PK_A);
assert.strictEqual(entries[0].createdAt, 3000, 'newest created_at');
assert.strictEqual(entries[0].placeIdentifier, 'osm:node:1');
});
test('separates entries by author for the same place', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_B,
placeIdentifier: 'osm:node:1',
created_at: 2000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries.length, 2, 'two entries (one per author)');
assert.strictEqual(entries[0].senderPubkey, SOCIAL_PK_B, 'newest first');
assert.strictEqual(entries[1].senderPubkey, SOCIAL_PK_A);
});
test('separates entries by place for the same author', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:2',
created_at: 2000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries.length, 2, 'two entries (one per place)');
assert.strictEqual(
entries[0].placeIdentifier,
'osm:node:2',
'newest first'
);
assert.strictEqual(entries[1].placeIdentifier, 'osm:node:1');
});
test('splits by time proximity within same author + place', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 2000,
}),
// 5-hour gap (> 3h threshold)
makeSocialPhotoEvent({
idx: 3,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 20000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries.length, 2, 'split by time gap');
assert.strictEqual(entries[0].photos.length, 1, 'newer group has 1 photo');
assert.strictEqual(entries[1].photos.length, 2, 'older group has 2 photos');
});
test('sorts entries newest-first', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_B,
placeIdentifier: 'osm:node:2',
created_at: 5000,
}),
makeSocialPhotoEvent({
idx: 3,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:node:1',
created_at: 2000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries[0].createdAt, 5000, 'newest entry first');
assert.strictEqual(entries[1].createdAt, 2000, 'second entry');
});
test('sets osmType and osmId from placeIdentifier', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
placeIdentifier: 'osm:way:999',
created_at: 1000,
}),
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries[0].osmType, 'way');
assert.strictEqual(entries[0].osmId, '999');
});
test('filters out events without an i tag', function (assert) {
const event = makeSocialPhotoEvent({ idx: 1, author: SOCIAL_PK_A });
event.tags = [['imeta', 'url https://x.com/photo.jpg', 'dim 800x600']];
const entries = groupSocialPhotos([event]);
assert.strictEqual(entries.length, 0, 'no i tag → filtered out');
});
test('applies kind 5 deletions', function (assert) {
const photoId = 'e'.padStart(64, '0');
const events = [
{
id: photoId,
pubkey: SOCIAL_PK_A,
kind: 360,
created_at: 1000,
tags: [
['i', 'osm:node:1'],
['imeta', 'url https://x.com/photo.jpg', 'dim 800x600'],
],
content: '',
sig: 'sig',
},
{
id: 'd'.padStart(64, '0'),
pubkey: SOCIAL_PK_A,
kind: 5,
created_at: 5000,
tags: [['e', photoId]],
content: '',
sig: 'sig',
},
];
const entries = groupSocialPhotos(events);
assert.strictEqual(entries.length, 0, 'deleted photo is filtered out');
});
test('filters out entries with no usable photos (malformed imeta)', function (assert) {
const event = makeSocialPhotoEvent({ idx: 1, author: SOCIAL_PK_A });
event.tags = [
['i', 'osm:node:1'],
['imeta', 'dim 800x600'], // no url
];
const entries = groupSocialPhotos([event]);
assert.strictEqual(entries.length, 0, 'malformed imeta → filtered out');
});
test('photo is set to first photo in the group', function (assert) {
const events = [
makeSocialPhotoEvent({
idx: 1,
author: SOCIAL_PK_A,
url: 'https://x.com/a.jpg',
created_at: 1000,
}),
makeSocialPhotoEvent({
idx: 2,
author: SOCIAL_PK_A,
url: 'https://x.com/b.jpg',
created_at: 2000,
}),
];
const entries = groupSocialPhotos(events);
assert.ok(entries[0].photo, 'photo is set');
assert.strictEqual(
entries[0].photo.url,
'https://x.com/a.jpg',
'first photo (oldest) is the thumbnail'
);
});
});
+5
View File
@@ -38,6 +38,11 @@ module('Unit | Utility | osm-icons', function () {
assert.strictEqual(result, null);
});
test('it returns person-sleeping-in-bed for leisure=resort', function (assert) {
let result = getIconNameForTags({ leisure: 'resort' });
assert.strictEqual(result, 'person-sleeping-in-bed');
});
test('all icons used in POI_ICON_RULES exist in the icons utility', function (assert) {
for (let rule of POI_ICON_RULES) {
let icon = getIcon(rule.icon);
@@ -35,4 +35,11 @@ module('Unit | Utility | poi-category-matcher', function () {
assert.ok(categoryIds.includes('things-to-do'));
});
test('leisure=resort matches accommodation category', function (assert) {
const tags = { leisure: 'resort' };
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
assert.ok(categoryIds.includes('accommodation'));
});
});