Split Activity timeline in Home and Explore

Adds a new Explore timeline with contributions from anyone on trusted
relays.
This commit is contained in:
2026-09-02 15:06:10 -06:00
parent 92c6190d28
commit a4a8d123d3
14 changed files with 667 additions and 122 deletions
+14 -1
View File
@@ -3,17 +3,24 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import Icon from './icon';
import TabNav from './tab-nav';
import ActivityZapItem from './activity-zap-item';
import ActivityPhotoItem from './activity-photo-item';
import Modal from './modal';
import NostrConnect from './nostr-connect';
import not from 'ember-truth-helpers/helpers/not';
import eq from 'ember-truth-helpers/helpers/eq';
import and from 'ember-truth-helpers/helpers/and';
import restoreScroll from '../modifiers/restore-scroll';
export default class ActivityTimelineComponent extends Component {
@tracked isNostrConnectModalOpen = false;
tabs = [
{ label: 'Home', value: 'home' },
{ label: 'Explore', value: 'explore' },
];
@action
openNostrConnectModal(event) {
event.preventDefault();
@@ -48,12 +55,18 @@ export default class ActivityTimelineComponent extends Component {
</button>
</div>
<TabNav
@tabs={{this.tabs}}
@active={{@sourceMode}}
@onChange={{@onSetSourceMode}}
/>
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
{{#if @isLoading}}
<div class="sidebar-loading">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{else if (not @isConnected)}}
{{else if (and (not @isConnected) (eq @sourceMode "home"))}}
<p class="empty-state">
<a
href="#"
+17
View File
@@ -0,0 +1,17 @@
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import eq from 'ember-truth-helpers/helpers/eq';
<template>
<nav class="tab-nav">
{{#each @tabs as |tab|}}
<button
type="button"
class="tab-nav-button {{if (eq @active tab.value) 'is-active'}}"
{{on "click" (fn @onChange tab.value)}}
>
{{tab.label}}
</button>
{{/each}}
</nav>
</template>
+9 -1
View File
@@ -10,7 +10,6 @@ export default class ActivityController extends Controller {
@service activity;
loadTask = task({ restartable: true }, async (pubkey) => {
if (!pubkey) return;
await this.activity.load(pubkey);
});
@@ -26,6 +25,15 @@ export default class ActivityController extends Controller {
return this.nostrAuth.isConnected;
}
get sourceMode() {
return this.activity.sourceMode;
}
@action
setSourceMode(mode) {
this.activity.setSourceMode(mode);
}
@action
selectItem(item) {
if (!item || !item.placeIdentifier) return;
+54 -33
View File
@@ -31,6 +31,7 @@ export default class ActivityService extends Service {
@service placeNameResolver;
@tracked items = [];
@tracked sourceMode = 'home';
_sub = null;
_socialSub = null;
@@ -40,38 +41,40 @@ export default class ActivityService extends Service {
_socialItems = [];
_lastSocialEvents = [];
_since = null;
_sourceMode = 'social';
_sourceMode = 'home';
/**
* Loads the user's incoming zap receipts and social photo activity, then
* subscribes to live updates.
*
* @param {string} pubkey The user's Nostr pubkey
* When a Nostr account is connected, zaps received on the user's photos are
* loaded and the 'home' mode shows photos from followed contacts. When no
* account is connected, zaps and 'home' mode are skipped, but 'explore'
* mode still loads photos from trusted relays.
*
* @param {string|null} pubkey The user's Nostr pubkey, or null
*/
async load(pubkey) {
if (!pubkey) {
this.items = [];
return;
}
this._userPubkey = pubkey;
this._userPubkey = pubkey || null;
this._since = Math.floor(Date.now() / 1000) - SINCE_WINDOW;
const zapFilters = [{ kinds: [9735], '#p': [pubkey] }];
if (pubkey) {
const zapFilters = [{ kinds: [9735], '#p': [pubkey] }];
console.debug('[activity] Subscribing to zap receipts', {
filters: zapFilters,
pubkey,
activeReadRelays: this.nostrData.activeReadRelays,
});
// Subscribe to zap receipts (kind 9735 where #p = user)
this._sub = this.nostrData.store
.timeline(zapFilters)
.subscribe((events) => {
this._updateZapItems(events, pubkey);
console.debug('[activity] Subscribing to zap receipts', {
filters: zapFilters,
pubkey,
activeReadRelays: this.nostrData.activeReadRelays,
});
// Subscribe to zap receipts (kind 9735 where #p = user)
this._sub = this.nostrData.store
.timeline(zapFilters)
.subscribe((events) => {
this._updateZapItems(events, pubkey);
});
}
// 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.
@@ -83,13 +86,15 @@ export default class ActivityService extends Service {
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.
await this.nostrData.loadMyContributions(pubkey);
if (pubkey) {
// Ensure the user's kind 360 photo events are in the store first so
// enrichWithPhoto can look them up when zap receipts arrive.
await this.nostrData.loadMyContributions(pubkey);
// 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);
// 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
@@ -98,8 +103,8 @@ export default class ActivityService extends Service {
}
/**
* Switches the activity source mode (e.g. 'social' for followed contacts,
* 'trusted-relays' for content from trusted relays). Re-filters existing
* Switches the activity source mode (e.g. 'home' for followee photos,
* 'explore' for all photos from trusted relays). Re-filters existing
* events and re-fetches with the new mode.
*
* @param {string} mode The new source mode
@@ -107,6 +112,7 @@ export default class ActivityService extends Service {
setSourceMode(mode) {
if (mode === this._sourceMode) return;
this._sourceMode = mode;
this.sourceMode = mode;
// Re-filter existing events with the new mode
if (this._lastSocialEvents.length > 0) {
@@ -114,7 +120,7 @@ export default class ActivityService extends Service {
}
// Re-fetch with the new mode
if (this._userPubkey && this._since !== null) {
if (this._since !== null) {
this.nostrData.loadActivityPhotos(this._since, mode);
}
}
@@ -234,7 +240,10 @@ export default class ActivityService extends Service {
* Merges `_zapItems` and `_socialItems` into `items`, sorted newest-first.
*/
_mergeItems() {
const all = [...this._zapItems, ...this._socialItems];
const all =
this._sourceMode === 'explore'
? [...this._socialItems]
: [...this._zapItems, ...this._socialItems];
all.sort((a, b) => b.createdAt - a.createdAt);
this.items = all;
}
@@ -243,17 +252,29 @@ export default class ActivityService extends Service {
* 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
* - `'home'`: only events from followees (excluding own photos)
* - `'explore'`: events from non-followed authors trusted by relay provenance
* (excluding own photos). When no pubkey is connected, all trusted events
* pass (no own-photo or followee exclusion).
*/
_matchesSourceMode(event) {
if (this._sourceMode === 'social') {
if (this._sourceMode === 'home') {
return (
this._userPubkey &&
event.pubkey !== this._userPubkey &&
this.nostrData._contactPubkeys?.has(event.pubkey)
);
}
if (this._sourceMode === 'explore') {
if (!this._userPubkey) {
return this.nostrData.isTrustedEvent(event);
}
return (
event.pubkey !== this._userPubkey &&
!this.nostrData._contactPubkeys?.has(event.pubkey) &&
this.nostrData.isTrustedEvent(event)
);
}
return false;
}
+55 -18
View File
@@ -100,7 +100,7 @@ export default class NostrDataService extends Service {
// 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';
_activityPhotosMode = 'home';
_activityPhotosNetworkSub = null;
_requestSub = null;
@@ -610,18 +610,18 @@ 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
* - `'home'` mode: fetches photos authored by the user's followed contacts
* (followees). If contacts haven't loaded yet (`_contactPubkeys` is null or
* empty), this returns early and is re-triggered automatically by the
* `ContactsModel` subscription callback when contacts arrive.
*
* Future modes (e.g. `'trusted-relays'`) can be added by extending the
* switch below.
* - `'explore'` mode: fetches all kind 360 photos in the time window from
* trusted relays (no authors filter). Provenance is captured so
* `isTrustedEvent` can filter at presentation time.
*
* @param {number} since Unix timestamp (seconds) for the start of the window
* @param {string} [mode='social'] Source mode
* @param {string} [mode='home'] Source mode
*/
async loadActivityPhotos(since, mode = 'social') {
async loadActivityPhotos(since, mode = 'home') {
this._activityPhotosSince = since;
this._activityPhotosMode = mode;
@@ -630,31 +630,33 @@ export default class NostrDataService extends Service {
this._activityPhotosNetworkSub = null;
}
if (mode === 'social') {
this._loadSocialCirclePhotos(since);
if (mode === 'home') {
this._loadFolloweePhotos(since);
} else if (mode === 'explore') {
this._loadTrustedRelaysPhotos(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).
* Fetches kind 360 photos from the user's followees (followed contacts),
* batched by author pubkey (≤100 per filter to stay under relay REQ limits).
* Defers if contacts haven't loaded yet.
*/
_loadSocialCirclePhotos(since) {
_loadFolloweePhotos(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'
'[nostr-data] No contacts loaded yet, deferring followee photo load'
);
return;
}
const filters = this._batchAuthorFilters(pubkeys, [360], since);
console.debug('[nostr-data] Loading social circle photos', {
console.debug('[nostr-data] Loading followee photos', {
filterCount: filters.length,
pubkeyCount: pubkeys.length,
since,
@@ -672,7 +674,7 @@ export default class NostrDataService extends Service {
})
.catch((e) => {
console.warn(
'[nostr-data] Failed to read social photos from local Nostr IDB cache',
'[nostr-data] Failed to read followee photos from local Nostr IDB cache',
e
);
});
@@ -681,7 +683,42 @@ export default class NostrDataService extends Service {
this._activityPhotosNetworkSub = this._requestContentWithProvenance(
this.activeReadRelays,
filters,
'[nostr-data] Error fetching social circle photos:'
'[nostr-data] Error fetching followee photos:'
);
}
/**
* Fetches all kind 360 photos in the time window from trusted relays (no
* authors filter). Provenance is captured so `isTrustedEvent` can filter
* at presentation time. Fires immediately — no contacts dependency.
*/
_loadTrustedRelaysPhotos(since) {
const filters = [{ kinds: [360], since }];
console.debug('[nostr-data] Loading trusted relay photos', { since });
// 1. Populate the store from the local Nostr IDB cache (instant)
this._cachePromise
.then(() => this.cache.query(filters))
.then((cachedEvents) => {
if (cachedEvents && cachedEvents.length > 0) {
for (const event of cachedEvents) {
this.store.add(event);
}
}
})
.catch((e) => {
console.warn(
'[nostr-data] Failed to read trusted relay photos from local Nostr IDB cache',
e
);
});
// 2. Request fresh events from the network (captures provenance for trust)
this._activityPhotosNetworkSub = this._requestContentWithProvenance(
this.activeReadRelays,
filters,
'[nostr-data] Error fetching trusted relay photos:'
);
}
+56 -57
View File
@@ -56,11 +56,9 @@ export default class PlaceNameResolverService extends Service {
*/
async resolveInBackground(entries) {
const pending = entries.filter((e) => e.placeNameLoading);
if (pending.length === 0) {
this._maybeBatchFetchNames(entries);
return;
}
if (pending.length === 0) return;
// Phase 1: Cache lookup (localForage → OSM IDB cache)
await Promise.all(
pending.map(async (entry) => {
const name = await this._resolveCachedName(entry);
@@ -71,20 +69,6 @@ export default class PlaceNameResolverService extends Service {
})
);
// Trigger re-render via caller, then kick off the network batch for
// entries that are still loading.
this._maybeBatchFetchNames(entries);
}
_isUnresolvable(entry) {
return this._unresolvable.has(entry.placeIdentifier);
}
_maybeBatchFetchNames(entries) {
const unresolved = entries.filter(
(e) => e.placeNameLoading && !this._isUnresolvable(e)
);
// Apply fallbacks for items already marked unresolvable in this session
for (const entry of entries) {
if (entry.placeNameLoading && this._isUnresolvable(entry)) {
@@ -93,52 +77,67 @@ export default class PlaceNameResolverService extends Service {
}
}
if (unresolved.length === 0) {
return;
}
const unresolved = entries.filter(
(e) => e.placeNameLoading && !this._isUnresolvable(e)
);
if (unresolved.length === 0) return;
// Build a stable signature so we only fire one batch request per unique set
// Phase 2: Batch OSM fetch (awaited, deduplicated by signature)
try {
const nameMap = await this._getOrCreateBatch(unresolved);
for (const entry of entries) {
if (!entry.placeNameLoading) continue;
if (nameMap.has(entry.placeIdentifier)) {
entry.placeName = nameMap.get(entry.placeIdentifier);
entry.placeNameLoading = false;
} else {
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
} catch (e) {
console.error('[place-name-resolver] Batch resolution failed', e);
for (const entry of entries) {
if (entry.placeNameLoading) {
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
}
}
_isUnresolvable(entry) {
return this._unresolvable.has(entry.placeIdentifier);
}
/**
* Returns a promise for the batch OSM fetch, deduplicating calls that have
* the same set of place identifiers. If a batch with the same signature is
* already in flight, returns that promise instead of firing a new one.
*
* @param {Array} unresolved Entries that still need a network fetch
* @returns {Promise<Map<string, string>>} Map of placeIdentifier → name
*/
_getOrCreateBatch(unresolved) {
const signature = unresolved
.map((e) => e.placeIdentifier)
.sort()
.join('|');
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
return;
}
this._lastBatchSignature = signature;
this._pendingBatchPromise = this._batchResolveNames(unresolved)
.then((nameMap) => {
// Merge resolved names back into the entries
for (const entry of entries) {
if (!entry.placeNameLoading) continue;
if (nameMap.has(entry.placeIdentifier)) {
const name = nameMap.get(entry.placeIdentifier);
entry.placeName = name;
entry.placeNameLoading = false;
} else {
// Could not be resolved (deleted object, network error for this item).
// Mark as unresolvable for this session and use a fallback.
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
})
.catch((e) => {
console.error('[place-name-resolver] Batch name resolution failed', e);
// On a total failure, mark all as unresolvable and apply fallbacks
for (const entry of entries) {
if (entry.placeNameLoading) {
this._unresolvable.add(entry.placeIdentifier);
entry.placeName = this._fallbackName(entry);
entry.placeNameLoading = false;
}
}
})
.finally(() => {
if (signature === this._lastBatchSignature && this._pendingBatchPromise) {
return this._pendingBatchPromise;
}
this._lastBatchSignature = signature;
this._pendingBatchPromise = this._batchResolveNames(unresolved).finally(
() => {
this._pendingBatchPromise = null;
});
}
);
return this._pendingBatchPromise;
}
_fallbackName(entry) {
+32
View File
@@ -2417,6 +2417,38 @@ button.create-place {
padding: 4rem 1rem;
}
/* Tab Navigation */
.tab-nav {
display: flex;
border-bottom: 1px solid var(--divider-color);
height: 48px;
flex-shrink: 0;
}
.tab-nav-button {
flex: 1;
border: none;
background: none;
font-size: 0.9rem;
cursor: pointer;
color: var(--secondary-text-color);
font-family: inherit;
border-bottom: 2px solid transparent;
transition:
border-color 0.15s,
color 0.15s;
}
.tab-nav-button:hover {
color: var(--body-text-color);
}
.tab-nav-button.is-active {
border-bottom-color: var(--link-color);
font-weight: 600;
color: var(--body-text-color);
}
/* Contributions Timeline */
.contributions-list {
list-style: none;
+2
View File
@@ -6,6 +6,8 @@ import ActivityTimeline from '#components/activity-timeline';
@items={{@controller.items}}
@isLoading={{@controller.loadTask.isRunning}}
@isConnected={{@controller.isConnected}}
@sourceMode={{@controller.sourceMode}}
@onSetSourceMode={{@controller.setSourceMode}}
@scrollTop={{@controller.scrollTop}}
@onSelect={{@controller.selectItem}}
@onBack={{@controller.backToMenu}}
+3
View File
@@ -23,9 +23,12 @@ class MockActivityService extends Service {
senderProfileLoading: false,
},
];
@tracked sourceMode = 'home';
async load() {}
setSourceMode() {}
stop() {
this.items = [];
}
@@ -13,6 +13,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
hooks.beforeEach(function () {
this.noop = noop;
this.emptyItems = [];
this.sourceMode = 'home';
this.onSetSourceMode = () => {};
});
test('it renders a loading state', async function (assert) {
@@ -22,6 +24,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{true}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@@ -41,6 +45,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@@ -60,6 +66,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@@ -82,6 +90,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@@ -135,6 +145,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.items}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@@ -162,6 +174,8 @@ module('Integration | Component | activity-timeline', function (hooks) {
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.handleBack}}
@onClose={{this.noop}}
@@ -173,4 +187,123 @@ module('Integration | Component | activity-timeline', function (hooks) {
await click('.sidebar-header .back-btn');
assert.true(backClicked);
});
test('it renders tab nav with Home and Explore tabs', async function (assert) {
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.tab-nav').exists();
assert.dom('.tab-nav-button').exists({ count: 2 });
const buttons = this.element.querySelectorAll('.tab-nav-button');
assert.strictEqual(buttons[0].textContent.trim(), 'Home');
assert.strictEqual(buttons[1].textContent.trim(), 'Explore');
});
test('clicking a tab fires @onSetSourceMode with the tab value', async function (assert) {
let selectedMode = null;
this.handleSetSourceMode = (mode) => {
selectedMode = mode;
};
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{true}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.handleSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
const buttons = this.element.querySelectorAll('.tab-nav-button');
await click(buttons[1]);
assert.strictEqual(
selectedMode,
'explore',
'onSetSourceMode called with explore'
);
});
test('explore mode shows items when not connected', async function (assert) {
this.items = [
{
type: 'photo',
photoEventId: 'photo-1',
photo: {
url: 'https://x.com/photo.jpg',
thumbUrl: 'https://x.com/thumb.jpg',
},
placeIdentifier: 'osm:node:111',
authorPubkey: 'b'.repeat(64),
createdAt: 2000,
authorName: 'Alice',
authorAvatar: 'https://x.com/avatar.jpg',
authorProfileLoading: false,
},
];
this.sourceMode = 'explore';
await render(
<template>
<ActivityTimeline
@items={{this.items}}
@isLoading={{false}}
@isConnected={{false}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.activity-list').exists('items render in explore mode');
assert.dom('.activity-list li').exists({ count: 1 });
assert.dom('.empty-state').doesNotExist('no connect prompt in explore');
});
test('explore mode shows empty state when not connected and no items', async function (assert) {
this.sourceMode = 'explore';
await render(
<template>
<ActivityTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@sourceMode={{this.sourceMode}}
@onSetSourceMode={{this.onSetSourceMode}}
@onSelect={{this.noop}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onNostrConnected={{this.noop}}
/>
</template>
);
assert.dom('.empty-state').includesText('No activity yet.');
assert.dom('.empty-state').doesNotIncludeText('Connect');
});
});
@@ -0,0 +1,87 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import TabNav from 'marco/components/tab-nav';
module('Integration | Component | tab-nav', function (hooks) {
setupRenderingTest(hooks);
test('it renders all tab buttons with labels', async function (assert) {
this.tabs = [
{ label: 'Home', value: 'home' },
{ label: 'Explore', value: 'explore' },
];
this.active = 'home';
this.onChange = () => {};
await render(
<template>
<TabNav
@tabs={{this.tabs}}
@active={{this.active}}
@onChange={{this.onChange}}
/>
</template>
);
assert.dom('.tab-nav-button').exists({ count: 2 });
const buttons = this.element.querySelectorAll('.tab-nav-button');
assert.strictEqual(buttons[0].textContent.trim(), 'Home');
assert.strictEqual(buttons[1].textContent.trim(), 'Explore');
});
test('it applies is-active class to the active tab', async function (assert) {
this.tabs = [
{ label: 'Home', value: 'home' },
{ label: 'Explore', value: 'explore' },
];
this.active = 'explore';
this.onChange = () => {};
await render(
<template>
<TabNav
@tabs={{this.tabs}}
@active={{this.active}}
@onChange={{this.onChange}}
/>
</template>
);
const buttons = this.element.querySelectorAll('.tab-nav-button');
assert.false(
buttons[0].classList.contains('is-active'),
'first tab is not active'
);
assert.true(
buttons[1].classList.contains('is-active'),
'second tab is active'
);
});
test('clicking a tab fires onChange with the tab value', async function (assert) {
this.tabs = [
{ label: 'Home', value: 'home' },
{ label: 'Explore', value: 'explore' },
];
this.active = 'home';
this.handleChange = (value) => {
this.active = value;
};
await render(
<template>
<TabNav
@tabs={{this.tabs}}
@active={{this.active}}
@onChange={{this.handleChange}}
/>
</template>
);
const buttons = this.element.querySelectorAll('.tab-nav-button');
await click(buttons[1]);
assert.strictEqual(this.active, 'explore', 'onChange called with explore');
});
});
+119 -8
View File
@@ -314,10 +314,10 @@ module('Unit | Service | activity', function (hooks) {
assert.strictEqual(service.items[0].senderPubkey, SENDER_PUBKEY);
});
test('_matchesSourceMode filters by contact pubkeys in social mode', function (assert) {
test('_matchesSourceMode filters by contact pubkeys in home mode', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'social';
service._sourceMode = 'home';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const followedPhoto = makePhotoEvent({ author: SENDER_PUBKEY });
@@ -335,6 +335,70 @@ module('Unit | Service | activity', function (hooks) {
assert.false(service._matchesSourceMode(ownPhoto), 'own photo excluded');
});
test('_matchesSourceMode in explore mode includes trusted strangers, excludes own photos and followees', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
const STRANGER_PUBKEY = 'c'.repeat(64);
// Mock isTrustedEvent to return true for SENDER_PUBKEY and STRANGER_PUBKEY
service.nostrData.isTrustedEvent = (event) =>
event.pubkey === SENDER_PUBKEY || event.pubkey === STRANGER_PUBKEY;
const trustedStrangerPhoto = makePhotoEvent({ author: STRANGER_PUBKEY });
const trustedFolloweePhoto = makePhotoEvent({ author: SENDER_PUBKEY });
const untrustedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
const ownPhoto = makePhotoEvent({ author: USER_PUBKEY });
assert.true(
service._matchesSourceMode(trustedStrangerPhoto),
'trusted stranger passes in explore mode'
);
assert.false(
service._matchesSourceMode(trustedFolloweePhoto),
'trusted followee excluded in explore mode'
);
assert.false(
service._matchesSourceMode(untrustedPhoto),
'untrusted event rejected in explore mode'
);
assert.false(
service._matchesSourceMode(ownPhoto),
'own photo excluded in explore mode'
);
});
test('_matchesSourceMode in explore mode with no pubkey includes all trusted events', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = null;
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = null;
const STRANGER_PUBKEY = 'c'.repeat(64);
service.nostrData.isTrustedEvent = (event) =>
event.pubkey === SENDER_PUBKEY || event.pubkey === STRANGER_PUBKEY;
const trustedPhoto = makePhotoEvent({ author: SENDER_PUBKEY });
const trustedStrangerPhoto = makePhotoEvent({ author: STRANGER_PUBKEY });
const untrustedPhoto = makePhotoEvent({ author: 'z'.repeat(64) });
assert.true(
service._matchesSourceMode(trustedPhoto),
'trusted event passes with no pubkey'
);
assert.true(
service._matchesSourceMode(trustedStrangerPhoto),
'trusted stranger passes with no pubkey'
);
assert.false(
service._matchesSourceMode(untrustedPhoto),
'untrusted event rejected with no pubkey'
);
});
test('_updateSocialItems merges with zap items sorted by createdAt', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
@@ -372,11 +436,58 @@ module('Unit | Service | activity', function (hooks) {
assert.strictEqual(service.items[1].createdAt, 5000, 'older zap second');
});
test('explore mode excludes zap items from merged results', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
// Seed a zap item
service.nostrData.store.add(
makePhotoEvent({ id: PHOTO_EVENT_ID_1, placeIdentifier: 'osm:node:50' })
);
service._updateZapItems(
[
makeZapReceiptEvent({
zappedEventId: PHOTO_EVENT_ID_1,
created_at: 5000,
}),
],
USER_PUBKEY
);
// Add a trusted stranger social photo
const STRANGER_PUBKEY = 'c'.repeat(64);
service.nostrData.isTrustedEvent = (event) =>
event.pubkey === STRANGER_PUBKEY;
const socialPhoto = makePhotoEvent({
id: 'sp1'.padEnd(64, '0'),
author: STRANGER_PUBKEY,
placeIdentifier: 'osm:node:100',
created_at: 9000,
});
service._updateSocialItems([socialPhoto]);
assert.strictEqual(
service.items.length,
1,
'zap excluded in explore mode, only social photo shown'
);
assert.strictEqual(
service.items[0].createdAt,
9000,
'only the social photo is present'
);
});
test('setSourceMode re-filters existing events', function (assert) {
const service = this.owner.lookup('service:activity');
service._userPubkey = USER_PUBKEY;
service._sourceMode = 'trusted-relays';
service._sourceMode = 'explore';
service.nostrData._contactPubkeys = new Set([SENDER_PUBKEY]);
// In explore mode, isTrustedEvent returns false (no provenance in mock)
service.nostrData.isTrustedEvent = () => false;
const followedPhoto = makePhotoEvent({
id: 'fp1'.padEnd(64, '0'),
@@ -386,20 +497,20 @@ module('Unit | Service | activity', function (hooks) {
});
service._lastSocialEvents = [followedPhoto];
// In 'trusted-relays' mode, _matchesSourceMode returns false for all events
// In 'explore' mode, _matchesSourceMode returns false (isTrustedEvent is false)
service._updateSocialItems([followedPhoto]);
assert.strictEqual(
service.items.length,
0,
'no social items in trusted-relays mode'
'no social items in explore mode with untrusted events'
);
// Switch to 'social' mode — now the followed photo should appear
service.setSourceMode('social');
// Switch to 'home' mode — now the followed photo should appear
service.setSourceMode('home');
assert.strictEqual(
service.items.length,
1,
're-filtered shows followed contact in social mode'
're-filtered shows followed contact in home mode'
);
});
+20 -4
View File
@@ -865,7 +865,7 @@ module('Unit | Service | nostr-data | activity photos', function (hooks) {
// _contactPubkeys is null initially (no loadProfile called)
assert.strictEqual(service._contactPubkeys, null);
await service.loadActivityPhotos(1000, 'social');
await service.loadActivityPhotos(1000, 'home');
// No network request should have been made
const photoFilters = this.requestedFilters.filter(
@@ -887,7 +887,7 @@ module('Unit | Service | nostr-data | activity photos', function (hooks) {
service.store.add(makeContactsEvent(userPubkey, contactPubkeys));
await service.loadProfile(userPubkey);
await service.loadActivityPhotos(1000, 'social');
await service.loadActivityPhotos(1000, 'home');
const photoFilters = this.requestedFilters.filter(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
@@ -912,7 +912,7 @@ module('Unit | Service | nostr-data | activity photos', function (hooks) {
await service.loadProfile(userPubkey);
const since = 12345;
await service.loadActivityPhotos(since, 'social');
await service.loadActivityPhotos(since, 'home');
const photoFilter = this.requestedFilters.find(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
@@ -929,7 +929,7 @@ module('Unit | Service | nostr-data | activity photos', function (hooks) {
const contactPubkey = makePubkey(2);
// Call loadActivityPhotos before contacts are loaded
await service.loadActivityPhotos(1000, 'social');
await service.loadActivityPhotos(1000, 'home');
const beforeCount = this.requestedFilters.filter(
(f) => f.kinds?.includes(360) && f.authors && f.since !== undefined
@@ -949,6 +949,22 @@ module('Unit | Service | nostr-data | activity photos', function (hooks) {
assert.ok(afterCount > 0, 'request made after contacts arrived');
});
test("loadActivityPhotos in 'explore' mode fetches all photos without authors filter", async function (assert) {
const service = this.owner.lookup('service:nostr-data');
const since = 9999;
await service.loadActivityPhotos(since, 'explore');
// Should make a request immediately (no contacts dependency)
const photoFilter = this.requestedFilters.find(
(f) => f.kinds?.includes(360) && f.since !== undefined && !f.authors
);
assert.ok(photoFilter, 'photo filter without authors found');
assert.strictEqual(photoFilter.since, since, 'since value matches');
assert.deepEqual(photoFilter.kinds, [360], 'requests kind 360');
assert.notOk(photoFilter.authors, 'no authors filter in explore mode');
});
test('_batchAuthorFilters produces correct batches', function (assert) {
const service = this.owner.lookup('service:nostr-data');
@@ -207,4 +207,70 @@ module('Unit | Service | place-name-resolver', function (hooks) {
assert.strictEqual(resolver._lastBatchSignature, '');
assert.strictEqual(resolver._pendingBatchPromise, null);
});
test('deduplicated batch resolves entries from a second call with same place identifiers', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
const localForage = this.owner.lookup('service:localForage');
let fetchCount = 0;
resolver.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
const map = new Map();
map.set('node:222', { title: 'Shared Place' });
return map;
};
// First call with entry1 — triggers a batch fetch
const entry1 = makeEntry({ placeIdentifier: 'osm:node:222' });
const promise1 = resolver.resolveInBackground([entry1]);
await flushPromises();
await promise1;
// Verify the name persisted so the second call's cache check finds it
assert.strictEqual(entry1.placeName, 'Shared Place');
assert.strictEqual(fetchCount, 1, 'only one batch fetch fired');
// Simulate the real-world race: a second set of entries for the same
// place arrives before the cache is consulted. Clear the persistent cache
// to simulate the incognito scenario where the batch hasn't persisted yet.
await localForage.clear('place-name-cache');
// Second call with a fresh entry2 — should resolve from the batch result
// that was already persisted (or re-use the pending batch if still in flight)
const entry2 = makeEntry({ placeIdentifier: 'osm:node:222' });
await resolver.resolveInBackground([entry2]);
assert.strictEqual(
entry2.placeName,
'Shared Place',
'second entry resolves even when batch was from a prior call'
);
});
test('pending batch is shared when second call fires before first completes', async function (assert) {
const resolver = this.owner.lookup('service:place-name-resolver');
let fetchCount = 0;
resolver.osm.fetchOsmObjectsBatch = async () => {
fetchCount++;
const map = new Map();
map.set('node:333', { title: 'Concurrent Place' });
return map;
};
// First call — don't await yet (simulates subscription firing)
const entry1 = makeEntry({ placeIdentifier: 'osm:node:333' });
const promise1 = resolver.resolveInBackground([entry1]);
// Second call with a different entry for the same place — should piggyback
const entry2 = makeEntry({ placeIdentifier: 'osm:node:333' });
const promise2 = resolver.resolveInBackground([entry2]);
await Promise.all([promise1, promise2]);
await flushPromises();
assert.strictEqual(fetchCount, 1, 'only one batch fetch fired');
assert.strictEqual(entry1.placeName, 'Concurrent Place');
assert.strictEqual(entry2.placeName, 'Concurrent Place');
});
});