Compare commits

...
Author SHA1 Message Date
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
14 changed files with 1114 additions and 93 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>
}
+3
View File
@@ -4,6 +4,7 @@ import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import Icon from './icon'; import Icon from './icon';
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';
@@ -69,6 +70,8 @@ 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}}
</ul> </ul>
+17 -15
View File
@@ -50,40 +50,40 @@ 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}} {{this.placeName}}
{{else if this.placeNameLoading}} {{else if this.placeNameLoading}}
@@ -92,12 +92,14 @@ export default class ActivityZapItem extends Component {
<span class="contribution-name-loading">Unnamed place</span> <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>
+137 -16
View File
@@ -2,7 +2,13 @@ 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
/** /**
* Orchestrates loading the user's incoming social activity (zaps received on * Orchestrates loading the user's incoming social activity (zaps received on
@@ -27,11 +33,18 @@ export default class ActivityService extends Service {
@tracked items = []; @tracked items = [];
_sub = null; _sub = null;
_socialSub = null;
_profileSubs = new Map(); _profileSubs = new Map();
_userPubkey = null; _userPubkey = null;
_zapItems = [];
_socialItems = [];
_lastSocialEvents = [];
_since = null;
_sourceMode = 'social';
/** /**
* 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 * @param {string} pubkey The user's Nostr pubkey
*/ */
@@ -42,20 +55,33 @@ export default class ActivityService extends Service {
} }
this._userPubkey = pubkey; this._userPubkey = pubkey;
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
const filters = [{ kinds: [9735], '#p': [pubkey] }]; const zapFilters = [{ kinds: [9735], '#p': [pubkey] }];
console.debug('[activity] Subscribing to zap receipts', { console.debug('[activity] Subscribing to zap receipts', {
filters, filters: zapFilters,
pubkey, pubkey,
activeReadRelays: this.nostrData.activeReadRelays, activeReadRelays: this.nostrData.activeReadRelays,
}); });
// Subscribe to the store timeline so we get live updates as receipts // Subscribe to zap receipts (kind 9735 where #p = user)
// arrive and are added to the store. this._sub = this.nostrData.store
this._sub = this.nostrData.store.timeline(filters).subscribe((events) => { .timeline(zapFilters)
this._updateItems(events, pubkey); .subscribe((events) => {
}); this._updateZapItems(events, 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);
});
// Ensure the user's kind 360 photo events are in the store first so // Ensure the user's kind 360 photo events are in the store first so
// enrichWithPhoto can look them up when zap receipts arrive. // enrichWithPhoto can look them up when zap receipts arrive.
@@ -64,6 +90,33 @@ export default class ActivityService extends Service {
// Then load zap receipts — adding them to the store triggers the // Then load zap receipts — adding them to the store triggers the
// timeline subscription, and by now the photo events are available. // timeline subscription, and by now the photo events are available.
await this.nostrData.loadIncomingZaps(pubkey); await this.nostrData.loadIncomingZaps(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);
}
/**
* Switches the activity source mode (e.g. 'social' for followed contacts,
* 'trusted-relays' for content 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;
// 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._userPubkey && this._since !== null) {
this.nostrData.loadActivityPhotos(this._since, mode);
}
} }
/** /**
@@ -75,9 +128,17 @@ export default class ActivityService extends Service {
this._sub.unsubscribe(); this._sub.unsubscribe();
this._sub = null; this._sub = null;
} }
if (this._socialSub) {
this._socialSub.unsubscribe();
this._socialSub = 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.placeNameResolver.reset(); this.placeNameResolver.reset();
} }
@@ -86,7 +147,7 @@ export default class ActivityService extends Service {
super.willDestroy(...arguments); super.willDestroy(...arguments);
} }
_updateItems(receipts, pubkey) { _updateZapItems(receipts, pubkey) {
const entries = []; const entries = [];
for (const receipt of receipts) { for (const receipt of receipts) {
@@ -113,8 +174,8 @@ export default class ActivityService extends Service {
pubkey, pubkey,
}); });
// 2. Bookmark lookup is synchronous — resolve those immediately so the // Synchronous bookmark lookup — resolve those immediately so the
// first render shows bookmarked place names without a "Loading…" flicker. // 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,15 +188,75 @@ 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 // Async-resolve remaining entries from the IndexedDB name cache and OSM
// cache, then fall through to the network batch for the rest. // 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) {
// 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._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.
*
* - `'social'`: only events from followed contacts (excluding own photos)
* - Future modes (e.g. `'trusted-relays'`) can be added here
*/
_matchesSourceMode(event) {
if (this._sourceMode === 'social') {
return (
this._userPubkey &&
event.pubkey !== this._userPubkey &&
this.nostrData._contactPubkeys?.has(event.pubkey)
);
}
return false;
}
_resolveSender(entry) { _resolveSender(entry) {
const pubkey = entry.senderPubkey; const pubkey = entry.senderPubkey;
if (!pubkey) return; if (!pubkey) return;
+119
View File
@@ -97,6 +97,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 = 'social';
_activityPhotosNetworkSub = null;
_requestSub = null; _requestSub = null;
_cachePromise = null; _cachePromise = null;
_currentPlaceEntityId = null; _currentPlaceEntityId = null;
@@ -600,6 +606,106 @@ 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.
*
* - `'social'` mode: fetches photos authored by the user's followed contacts
* (kind 3). 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.
*
* Future modes (e.g. `'trusted-relays'`) can be added by extending the
* switch below.
*
* @param {number} since Unix timestamp (seconds) for the start of the window
* @param {string} [mode='social'] Source mode
*/
async loadActivityPhotos(since, mode = 'social') {
this._activityPhotosSince = since;
this._activityPhotosMode = mode;
if (this._activityPhotosNetworkSub) {
this._activityPhotosNetworkSub.unsubscribe();
this._activityPhotosNetworkSub = null;
}
if (mode === 'social') {
this._loadSocialCirclePhotos(since);
}
// Future modes (e.g. 'trusted-relays') can be added here.
}
/**
* Fetches kind 360 photos from the user's followed contacts, batched by
* author pubkey (≤100 per filter to stay under relay REQ limits).
*/
_loadSocialCirclePhotos(since) {
const pubkeys = this._contactPubkeys
? Array.from(this._contactPubkeys)
: [];
if (pubkeys.length === 0) {
console.debug(
'[nostr-data] No contacts loaded yet, deferring social photo load'
);
return;
}
const filters = this._batchAuthorFilters(pubkeys, [360], since);
console.debug('[nostr-data] Loading social circle 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 social 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 social circle 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) { loadProfiles(pubkeys) {
const newPubkeys = pubkeys.filter( const newPubkeys = pubkeys.filter(
(pk) => pk && !this._profileModelSubs.has(pk) (pk) => pk && !this._profileModelSubs.has(pk)
@@ -659,6 +765,15 @@ export default class NostrDataService extends Service {
this.contacts = contacts; this.contacts = contacts;
this._contactPubkeys = new Set(contacts.map((c) => c.pubkey)); this._contactPubkeys = new Set(contacts.map((c) => c.pubkey));
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
@@ -896,6 +1011,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() {
+32 -16
View File
@@ -2661,14 +2661,14 @@ 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-item {
width: 100%; width: 100%;
text-align: left; text-align: left;
border: none; border: none;
@@ -2687,7 +2687,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 +2696,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 +2708,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 +2765,7 @@ button.create-place {
} }
} }
& .zap-context { & .context {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -2770,19 +2773,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,16 +2794,28 @@ 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;
@@ -2807,7 +2823,7 @@ button.create-place {
min-width: 0; min-width: 0;
} }
& .zap-date { & .activity-date {
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
} }
+114 -1
View File
@@ -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 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 * @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) {
+8 -8
View File
@@ -97,12 +97,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 +124,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'),
@@ -143,7 +143,7 @@ module('Integration | Component | activity-timeline', function (hooks) {
</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');
@@ -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,7 +168,7 @@ 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')
@@ -197,7 +197,7 @@ module('Integration | Component | activity-zap-item', function (hooks) {
); );
assert assert
.dom('.zap-context-text .contribution-name-loading') .dom('.context-text .contribution-name-loading')
.hasText('Loading…', 'Loading state is displayed'); .hasText('Loading…', 'Loading state is displayed');
}); });
@@ -223,11 +223,11 @@ module('Integration | Component | activity-zap-item', function (hooks) {
); );
assert assert
.dom('.zap-context-text .contribution-name-loading') .dom('.context-text .contribution-name-loading')
.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 +248,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 +290,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');
}); });
}); });
+165 -12
View File
@@ -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,7 @@ class MockNostrDataService extends Service {
}; };
loadProfiles() {} loadProfiles() {}
loadActivityPhotos() {}
getProfile(pubkey) { getProfile(pubkey) {
return this.profiles[pubkey]; return this.profiles[pubkey];
@@ -125,9 +137,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 +153,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 +164,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 +176,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 +193,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 +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 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 +212,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 +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'); 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 +237,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 +247,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 +283,144 @@ 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 social mode', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'social';
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('_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('setSourceMode re-filters existing events', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'trusted-relays';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const followedPhoto = makePhotoEvent({
id: 'fp1'.padEnd(64, '0'),
author: SENDER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 1000,
});
service._lastSocialEvents = [followedPhoto];
// In 'trusted-relays' mode, _matchesSourceMode returns false for all events
service._updateSocialItems([followedPhoto]);
assert.strictEqual(
service.items.length,
0,
'no social items in trusted-relays mode'
);
// Switch to 'social' mode — now the followed photo should appear
service.setSourceMode('social');
assert.strictEqual(
service.items.length,
1,
're-filtered shows followed contact in social 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');
});
}); });
+108
View File
@@ -856,6 +856,114 @@ 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, 'social');
// 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, 'social');
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, 'social');
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, 'social');
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('_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) { module('Unit | Service | nostr-data | zap receipts', function (hooks) {
setupNostrDataService(hooks); setupNostrDataService(hooks);
+250
View File
@@ -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'
);
});
});