Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92c6190d28
|
||
|
|
36989e1dc2
|
||
|
|
256659fbfb
|
||
|
|
ad762b138f
|
||
|
|
2d3808a097
|
||
|
|
6aa13d7243
|
||
|
|
6804e80528
|
@@ -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>
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from './icon';
|
||||
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';
|
||||
@@ -69,6 +70,8 @@ export default class ActivityTimelineComponent extends Component {
|
||||
{{#each @items as |item|}}
|
||||
{{#if (eq item.type "zap")}}
|
||||
<ActivityZapItem @item={{item}} @onSelect={{@onSelect}} />
|
||||
{{else if (eq item.type "photo")}}
|
||||
<ActivityPhotoItem @item={{item}} @onSelect={{@onSelect}} />
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
@@ -50,40 +50,40 @@ export default class ActivityZapItem extends Component {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="activity-zap-item"
|
||||
class="activity-item"
|
||||
{{on "click" (fn @onSelect @item)}}
|
||||
>
|
||||
<div class="zap-header">
|
||||
<span class="zap-sender-line">
|
||||
<span class="zap-sender-name">{{this.senderDisplayName}}</span>
|
||||
<div class="header">
|
||||
<span class="sender-line">
|
||||
<span class="sender-name">{{this.senderDisplayName}}</span>
|
||||
{{! template-lint-disable no-whitespace-for-layout }}
|
||||
<span class="zap-action"> zapped your photo</span>
|
||||
<span class="action"> zapped your photo</span>
|
||||
</span>
|
||||
<span class="zap-amount">{{this.item.amountSats}} ⚡</span>
|
||||
<span class="amount">{{this.item.amountSats}} ⚡</span>
|
||||
</div>
|
||||
|
||||
<div class="zap-context">
|
||||
<div class="zap-context-images">
|
||||
<div class="context">
|
||||
<div class="context-images">
|
||||
{{#if this.senderAvatar}}
|
||||
<img
|
||||
class="zap-sender-avatar"
|
||||
class="sender-avatar"
|
||||
src={{this.senderAvatar}}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
{{else}}
|
||||
<div class="zap-sender-avatar-placeholder">
|
||||
<div class="sender-avatar-placeholder">
|
||||
<Icon @name="user" @size={{16}} @color="#999" />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if this.hasPhoto}}
|
||||
<div class="zap-context-thumb">
|
||||
<div class="context-thumb">
|
||||
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="zap-context-text">
|
||||
<span class="zap-place-name">
|
||||
<div class="context-text">
|
||||
<span class="activity-place-name">
|
||||
{{#if this.placeName}}
|
||||
{{this.placeName}}
|
||||
{{else if this.placeNameLoading}}
|
||||
@@ -92,12 +92,14 @@ export default class ActivityZapItem extends Component {
|
||||
<span class="contribution-name-loading">Unnamed place</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
<span class="zap-date">{{formatRelativeDate @item.createdAt}}</span>
|
||||
<span class="activity-date">{{formatRelativeDate
|
||||
@item.createdAt
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if this.item.message}}
|
||||
<div class="zap-message">{{this.item.message}}</div>
|
||||
<div class="message">{{this.item.message}}</div>
|
||||
{{/if}}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
+137
-16
@@ -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
|
||||
@@ -27,11 +33,18 @@ export default class ActivityService extends Service {
|
||||
@tracked items = [];
|
||||
|
||||
_sub = null;
|
||||
_socialSub = null;
|
||||
_profileSubs = new Map();
|
||||
_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
|
||||
*/
|
||||
@@ -42,20 +55,33 @@ export default class ActivityService extends Service {
|
||||
}
|
||||
|
||||
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', {
|
||||
filters,
|
||||
filters: zapFilters,
|
||||
pubkey,
|
||||
activeReadRelays: this.nostrData.activeReadRelays,
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
// Subscribe to zap receipts (kind 9735 where #p = user)
|
||||
this._sub = this.nostrData.store
|
||||
.timeline(zapFilters)
|
||||
.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
|
||||
// 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
|
||||
// timeline subscription, and by now the photo events are available.
|
||||
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 = 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 +147,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 +174,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 +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
|
||||
// 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._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) {
|
||||
const pubkey = entry.senderPubkey;
|
||||
if (!pubkey) return;
|
||||
|
||||
@@ -97,6 +97,12 @@ export default class NostrDataService extends Service {
|
||||
_lastPhotoIds = new Set();
|
||||
_incomingZapsNetworkSub = null;
|
||||
|
||||
// Activity photos state: tracks the current time window and source mode
|
||||
// so that the contacts callback can re-trigger when contacts arrive.
|
||||
_activityPhotosSince = null;
|
||||
_activityPhotosMode = 'social';
|
||||
_activityPhotosNetworkSub = null;
|
||||
|
||||
_requestSub = null;
|
||||
_cachePromise = 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) {
|
||||
const newPubkeys = pubkeys.filter(
|
||||
(pk) => pk && !this._profileModelSubs.has(pk)
|
||||
@@ -659,6 +765,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 +1011,10 @@ export default class NostrDataService extends Service {
|
||||
this._incomingZapsNetworkSub.unsubscribe();
|
||||
this._incomingZapsNetworkSub = null;
|
||||
}
|
||||
if (this._activityPhotosNetworkSub) {
|
||||
this._activityPhotosNetworkSub.unsubscribe();
|
||||
this._activityPhotosNetworkSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
|
||||
+32
-16
@@ -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 {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: -1rem -1rem 0;
|
||||
}
|
||||
|
||||
.activity-zap-item {
|
||||
.activity-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
@@ -2687,7 +2687,7 @@ button.create-place {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
& .zap-sender-avatar {
|
||||
& .sender-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -2696,7 +2696,7 @@ button.create-place {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
& .zap-sender-avatar-placeholder {
|
||||
& .sender-avatar-placeholder {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -2708,39 +2708,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 +2765,7 @@ button.create-place {
|
||||
}
|
||||
}
|
||||
|
||||
& .zap-context {
|
||||
& .context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -2770,19 +2773,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 +2794,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 +2823,7 @@ button.create-place {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
& .zap-date {
|
||||
& .activity-date {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
+114
-1
@@ -14,7 +14,7 @@ import {
|
||||
getZapEventPointer,
|
||||
getZapRequest,
|
||||
} from 'applesauce-common/helpers';
|
||||
import { parsePhotoFromEvent } from './contributions';
|
||||
import { parsePhotoFromEvent, applyDeletions } from './contributions';
|
||||
|
||||
/**
|
||||
* A single activity timeline entry.
|
||||
@@ -27,6 +27,7 @@ export class ActivityEntry {
|
||||
type = 'zap';
|
||||
photoEventId;
|
||||
photo;
|
||||
photos = [];
|
||||
placeIdentifier;
|
||||
osmType;
|
||||
osmId;
|
||||
@@ -43,7 +44,10 @@ export class ActivityEntry {
|
||||
constructor({
|
||||
photoEventId,
|
||||
photo,
|
||||
photos,
|
||||
placeIdentifier,
|
||||
osmType,
|
||||
osmId,
|
||||
senderPubkey,
|
||||
amountSats,
|
||||
message,
|
||||
@@ -51,7 +55,10 @@ export class ActivityEntry {
|
||||
}) {
|
||||
this.photoEventId = photoEventId;
|
||||
this.photo = photo;
|
||||
this.photos = photos ?? [];
|
||||
this.placeIdentifier = placeIdentifier;
|
||||
this.osmType = osmType;
|
||||
this.osmId = osmId;
|
||||
this.senderPubkey = senderPubkey;
|
||||
this.amountSats = amountSats;
|
||||
this.message = message;
|
||||
@@ -140,3 +147,109 @@ export function enrichWithPhoto(entry, store, userPubkey) {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const HOUR_IN_SECONDS = 60 * 60;
|
||||
|
||||
/**
|
||||
* Builds a single `ActivityEntry` (type 'photo') from a group of kind 360
|
||||
* events by the same author for the same OSM entity.
|
||||
*
|
||||
* @param {Array} events Kind 360 events in this sub-group (same author + place)
|
||||
* @returns {ActivityEntry}
|
||||
*/
|
||||
function buildSocialEntry(events) {
|
||||
const photos = events
|
||||
.map(parsePhotoFromEvent)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
const createdAt = events.reduce(
|
||||
(max, e) => (e.created_at > max ? e.created_at : max),
|
||||
0
|
||||
);
|
||||
|
||||
const placeIdentifier =
|
||||
events[0].tags?.find((t) => t[0] === 'i')?.[1] || null;
|
||||
|
||||
let osmType;
|
||||
let osmId;
|
||||
if (placeIdentifier) {
|
||||
const parts = placeIdentifier.split(':');
|
||||
osmType = parts[1];
|
||||
osmId = parts[2];
|
||||
}
|
||||
|
||||
const entry = new ActivityEntry({
|
||||
photo: photos[0] || null,
|
||||
photos,
|
||||
placeIdentifier,
|
||||
osmType,
|
||||
osmId,
|
||||
senderPubkey: events[0].pubkey,
|
||||
createdAt,
|
||||
});
|
||||
entry.type = 'photo';
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups kind 360 (Place Photo) events from followed contacts into activity
|
||||
* entries, keyed by (author + OSM entity + time proximity).
|
||||
*
|
||||
* This mirrors the time-proximity grouping logic from `groupPhotoContributions`
|
||||
* in `utils/contributions.js` but adds author as a grouping dimension so that
|
||||
* "Alice added 3 photos to Central Park Café" is one entry.
|
||||
*
|
||||
* @param {Array} events Mixed kind 360 / kind 5 events from followed contacts
|
||||
* @param {number} [thresholdHours=3] Max gap in hours within a sub-group
|
||||
* @returns {Array} Sorted `ActivityEntry` entries (newest first)
|
||||
*/
|
||||
export function groupSocialPhotos(events, thresholdHours = 3) {
|
||||
if (!events || events.length === 0) return [];
|
||||
|
||||
const photoEvents = applyDeletions(events);
|
||||
if (photoEvents.length === 0) return [];
|
||||
|
||||
// Group by (author pubkey + OSM entity identifier)
|
||||
const byAuthorEntity = new Map();
|
||||
for (const event of photoEvents) {
|
||||
const entityTag = (event.tags || []).find((t) => t[0] === 'i');
|
||||
const entityId = entityTag?.[1];
|
||||
if (!entityId) continue;
|
||||
|
||||
const key = `${event.pubkey}:${entityId}`;
|
||||
if (!byAuthorEntity.has(key)) byAuthorEntity.set(key, []);
|
||||
byAuthorEntity.get(key).push(event);
|
||||
}
|
||||
|
||||
const thresholdSeconds = thresholdHours * HOUR_IN_SECONDS;
|
||||
const entries = [];
|
||||
|
||||
for (const [, groupEvents] of byAuthorEntity) {
|
||||
// Sort newest-first within the group
|
||||
const sorted = [...groupEvents].sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
// Walk newest -> oldest, starting a new sub-group when the gap to the
|
||||
// previous event exceeds the threshold.
|
||||
let currentGroup = [];
|
||||
let prevTime = null;
|
||||
|
||||
for (const event of sorted) {
|
||||
if (prevTime !== null && prevTime - event.created_at > thresholdSeconds) {
|
||||
entries.push(buildSocialEntry(currentGroup));
|
||||
currentGroup = [];
|
||||
}
|
||||
currentGroup.push(event);
|
||||
prevTime = event.created_at;
|
||||
}
|
||||
if (currentGroup.length > 0) {
|
||||
entries.push(buildSocialEntry(currentGroup));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort entries by their newest event's created_at, descending. Entries with
|
||||
// no usable photos (e.g. malformed imeta) are filtered out.
|
||||
return entries
|
||||
.filter((e) => e.photos.length > 0)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function parsePhotoFromEvent(event) {
|
||||
* @param {Array} events Mixed kind 360 and kind 5 events
|
||||
* @returns {Array} Surviving kind 360 events
|
||||
*/
|
||||
function applyDeletions(events) {
|
||||
export function applyDeletions(events) {
|
||||
const deletedIds = new Set();
|
||||
for (const event of events) {
|
||||
if (event.kind === 5) {
|
||||
|
||||
@@ -160,6 +160,7 @@ export const POI_ICON_RULES = [
|
||||
{ tags: { tourism: 'hostel' }, icon: 'person-sleeping-in-bed' },
|
||||
{ tags: { tourism: 'motel' }, icon: 'person-sleeping-in-bed' },
|
||||
{ tags: { tourism: 'guest_house' }, icon: 'person-sleeping-in-bed' },
|
||||
{ tags: { leisure: 'resort' }, icon: 'person-sleeping-in-bed' },
|
||||
|
||||
// Sports / Motorsports
|
||||
{ tags: { sport: 'motor' }, icon: 'flag-checkered' },
|
||||
|
||||
@@ -55,7 +55,10 @@ export const POI_CATEGORIES = [
|
||||
id: 'accommodation',
|
||||
label: 'Hotels',
|
||||
icon: 'person-sleeping-in-bed',
|
||||
filter: ['["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]'],
|
||||
filter: [
|
||||
'["tourism"~"^(hotel|hostel|motel|chalet|guest_house)$"]',
|
||||
'["leisure"="resort"]',
|
||||
],
|
||||
types: ['node', 'way', 'relation'],
|
||||
},
|
||||
];
|
||||
|
||||
+1
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -97,12 +97,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 +124,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'),
|
||||
|
||||
@@ -143,7 +143,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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,144 @@ 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 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
setupNostrDataService(hooks);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ActivityEntry,
|
||||
parseZapReceipt,
|
||||
enrichWithPhoto,
|
||||
groupSocialPhotos,
|
||||
} from 'marco/utils/activity';
|
||||
import {
|
||||
setVerifyWrappedEventMethod,
|
||||
@@ -209,3 +210,252 @@ module('Unit | Utility | activity', function () {
|
||||
assert.false(result, 'event not in store');
|
||||
});
|
||||
});
|
||||
|
||||
const SOCIAL_PK_A = 'a'.repeat(64);
|
||||
const SOCIAL_PK_B = 'b'.repeat(64);
|
||||
|
||||
function makeSocialPhotoEvent(opts = {}) {
|
||||
return {
|
||||
id: opts.id || `e${(opts.idx ?? 0).toString().padStart(63, '0')}`,
|
||||
pubkey: opts.author || SOCIAL_PK_A,
|
||||
kind: 360,
|
||||
created_at: opts.created_at || 5000,
|
||||
tags: [
|
||||
['i', opts.placeIdentifier || 'osm:node:123'],
|
||||
['imeta', `url ${opts.url || 'https://x.com/photo.jpg'}`, 'dim 800x600'],
|
||||
],
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
};
|
||||
}
|
||||
|
||||
module('Unit | Utility | activity | groupSocialPhotos', function () {
|
||||
test('returns empty for no events', function (assert) {
|
||||
assert.deepEqual(groupSocialPhotos([]), []);
|
||||
assert.deepEqual(groupSocialPhotos(null), []);
|
||||
});
|
||||
|
||||
test('groups by author + place within time threshold', function (assert) {
|
||||
const events = [
|
||||
makeSocialPhotoEvent({
|
||||
idx: 1,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 1000,
|
||||
}),
|
||||
makeSocialPhotoEvent({
|
||||
idx: 2,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 2000,
|
||||
}),
|
||||
makeSocialPhotoEvent({
|
||||
idx: 3,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 3000,
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupSocialPhotos(events);
|
||||
assert.strictEqual(entries.length, 1, 'one entry for same author + place');
|
||||
assert.strictEqual(entries[0].type, 'photo');
|
||||
assert.strictEqual(entries[0].photos.length, 3);
|
||||
assert.strictEqual(entries[0].senderPubkey, SOCIAL_PK_A);
|
||||
assert.strictEqual(entries[0].createdAt, 3000, 'newest created_at');
|
||||
assert.strictEqual(entries[0].placeIdentifier, 'osm:node:1');
|
||||
});
|
||||
|
||||
test('separates entries by author for the same place', function (assert) {
|
||||
const events = [
|
||||
makeSocialPhotoEvent({
|
||||
idx: 1,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 1000,
|
||||
}),
|
||||
makeSocialPhotoEvent({
|
||||
idx: 2,
|
||||
author: SOCIAL_PK_B,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 2000,
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupSocialPhotos(events);
|
||||
assert.strictEqual(entries.length, 2, 'two entries (one per author)');
|
||||
assert.strictEqual(entries[0].senderPubkey, SOCIAL_PK_B, 'newest first');
|
||||
assert.strictEqual(entries[1].senderPubkey, SOCIAL_PK_A);
|
||||
});
|
||||
|
||||
test('separates entries by place for the same author', function (assert) {
|
||||
const events = [
|
||||
makeSocialPhotoEvent({
|
||||
idx: 1,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 1000,
|
||||
}),
|
||||
makeSocialPhotoEvent({
|
||||
idx: 2,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:2',
|
||||
created_at: 2000,
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupSocialPhotos(events);
|
||||
assert.strictEqual(entries.length, 2, 'two entries (one per place)');
|
||||
assert.strictEqual(
|
||||
entries[0].placeIdentifier,
|
||||
'osm:node:2',
|
||||
'newest first'
|
||||
);
|
||||
assert.strictEqual(entries[1].placeIdentifier, 'osm:node:1');
|
||||
});
|
||||
|
||||
test('splits by time proximity within same author + place', function (assert) {
|
||||
const events = [
|
||||
makeSocialPhotoEvent({
|
||||
idx: 1,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 1000,
|
||||
}),
|
||||
makeSocialPhotoEvent({
|
||||
idx: 2,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 2000,
|
||||
}),
|
||||
// 5-hour gap (> 3h threshold)
|
||||
makeSocialPhotoEvent({
|
||||
idx: 3,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 20000,
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupSocialPhotos(events);
|
||||
assert.strictEqual(entries.length, 2, 'split by time gap');
|
||||
assert.strictEqual(entries[0].photos.length, 1, 'newer group has 1 photo');
|
||||
assert.strictEqual(entries[1].photos.length, 2, 'older group has 2 photos');
|
||||
});
|
||||
|
||||
test('sorts entries newest-first', function (assert) {
|
||||
const events = [
|
||||
makeSocialPhotoEvent({
|
||||
idx: 1,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 1000,
|
||||
}),
|
||||
makeSocialPhotoEvent({
|
||||
idx: 2,
|
||||
author: SOCIAL_PK_B,
|
||||
placeIdentifier: 'osm:node:2',
|
||||
created_at: 5000,
|
||||
}),
|
||||
makeSocialPhotoEvent({
|
||||
idx: 3,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:node:1',
|
||||
created_at: 2000,
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupSocialPhotos(events);
|
||||
assert.strictEqual(entries[0].createdAt, 5000, 'newest entry first');
|
||||
assert.strictEqual(entries[1].createdAt, 2000, 'second entry');
|
||||
});
|
||||
|
||||
test('sets osmType and osmId from placeIdentifier', function (assert) {
|
||||
const events = [
|
||||
makeSocialPhotoEvent({
|
||||
idx: 1,
|
||||
author: SOCIAL_PK_A,
|
||||
placeIdentifier: 'osm:way:999',
|
||||
created_at: 1000,
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupSocialPhotos(events);
|
||||
assert.strictEqual(entries[0].osmType, 'way');
|
||||
assert.strictEqual(entries[0].osmId, '999');
|
||||
});
|
||||
|
||||
test('filters out events without an i tag', function (assert) {
|
||||
const event = makeSocialPhotoEvent({ idx: 1, author: SOCIAL_PK_A });
|
||||
event.tags = [['imeta', 'url https://x.com/photo.jpg', 'dim 800x600']];
|
||||
|
||||
const entries = groupSocialPhotos([event]);
|
||||
assert.strictEqual(entries.length, 0, 'no i tag → filtered out');
|
||||
});
|
||||
|
||||
test('applies kind 5 deletions', function (assert) {
|
||||
const photoId = 'e'.padStart(64, '0');
|
||||
const events = [
|
||||
{
|
||||
id: photoId,
|
||||
pubkey: SOCIAL_PK_A,
|
||||
kind: 360,
|
||||
created_at: 1000,
|
||||
tags: [
|
||||
['i', 'osm:node:1'],
|
||||
['imeta', 'url https://x.com/photo.jpg', 'dim 800x600'],
|
||||
],
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
},
|
||||
{
|
||||
id: 'd'.padStart(64, '0'),
|
||||
pubkey: SOCIAL_PK_A,
|
||||
kind: 5,
|
||||
created_at: 5000,
|
||||
tags: [['e', photoId]],
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
},
|
||||
];
|
||||
|
||||
const entries = groupSocialPhotos(events);
|
||||
assert.strictEqual(entries.length, 0, 'deleted photo is filtered out');
|
||||
});
|
||||
|
||||
test('filters out entries with no usable photos (malformed imeta)', function (assert) {
|
||||
const event = makeSocialPhotoEvent({ idx: 1, author: SOCIAL_PK_A });
|
||||
event.tags = [
|
||||
['i', 'osm:node:1'],
|
||||
['imeta', 'dim 800x600'], // no url
|
||||
];
|
||||
|
||||
const entries = groupSocialPhotos([event]);
|
||||
assert.strictEqual(entries.length, 0, 'malformed imeta → filtered out');
|
||||
});
|
||||
|
||||
test('photo is set to first photo in the group', function (assert) {
|
||||
const events = [
|
||||
makeSocialPhotoEvent({
|
||||
idx: 1,
|
||||
author: SOCIAL_PK_A,
|
||||
url: 'https://x.com/a.jpg',
|
||||
created_at: 1000,
|
||||
}),
|
||||
makeSocialPhotoEvent({
|
||||
idx: 2,
|
||||
author: SOCIAL_PK_A,
|
||||
url: 'https://x.com/b.jpg',
|
||||
created_at: 2000,
|
||||
}),
|
||||
];
|
||||
|
||||
const entries = groupSocialPhotos(events);
|
||||
assert.ok(entries[0].photo, 'photo is set');
|
||||
assert.strictEqual(
|
||||
entries[0].photo.url,
|
||||
'https://x.com/a.jpg',
|
||||
'first photo (oldest) is the thumbnail'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,11 @@ module('Unit | Utility | osm-icons', function () {
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test('it returns person-sleeping-in-bed for leisure=resort', function (assert) {
|
||||
let result = getIconNameForTags({ leisure: 'resort' });
|
||||
assert.strictEqual(result, 'person-sleeping-in-bed');
|
||||
});
|
||||
|
||||
test('all icons used in POI_ICON_RULES exist in the icons utility', function (assert) {
|
||||
for (let rule of POI_ICON_RULES) {
|
||||
let icon = getIcon(rule.icon);
|
||||
|
||||
@@ -35,4 +35,11 @@ module('Unit | Utility | poi-category-matcher', function () {
|
||||
|
||||
assert.ok(categoryIds.includes('things-to-do'));
|
||||
});
|
||||
|
||||
test('leisure=resort matches accommodation category', function (assert) {
|
||||
const tags = { leisure: 'resort' };
|
||||
const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES);
|
||||
|
||||
assert.ok(categoryIds.includes('accommodation'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user