diff --git a/app/utils/contributions.js b/app/utils/contributions.js index 40595cc..3b5f60a 100644 --- a/app/utils/contributions.js +++ b/app/utils/contributions.js @@ -6,8 +6,46 @@ * succession). Entries are ordered newest-first. */ +import { tracked } from '@glimmer/tracking'; + const HOUR_IN_SECONDS = 60 * 60; +/** + * A single contribution timeline entry. + * + * `placeName` and `placeNameLoading` are tracked so that mutating them after + * the entry has been rendered (e.g. when the background OSM batch fetch + * resolves a place name) re-renders the consuming component. The remaining + * fields are static data and do not need to be tracked. + */ +export class ContributionEntry { + type = 'photo'; + placeIdentifier; + osmType; + osmId; + photos; + createdAt; + eventCount; + @tracked placeName = null; + @tracked placeNameLoading = true; + + constructor({ + placeIdentifier, + osmType, + osmId, + photos, + createdAt, + eventCount, + }) { + this.placeIdentifier = placeIdentifier; + this.osmType = osmType; + this.osmId = osmId; + this.photos = photos; + this.createdAt = createdAt; + this.eventCount = eventCount; + } +} + /** * Parses a single kind 360 (Place Photo) event's `imeta` tag into a photo object. * Reuses the same field shape as `parsePlacePhotos` in `utils/nostr.js` but operates @@ -180,15 +218,12 @@ function buildEntry(placeIdentifier, events) { const [, osmType, osmId] = placeIdentifier.split(':'); - return { - type: 'photo', + return new ContributionEntry({ placeIdentifier, osmType, osmId, - placeName: null, - placeNameLoading: true, photos, createdAt, eventCount: events.length, - }; + }); } diff --git a/tests/integration/components/contribution-photo-test.gjs b/tests/integration/components/contribution-photo-test.gjs index 94482fe..7f7c8b2 100644 --- a/tests/integration/components/contribution-photo-test.gjs +++ b/tests/integration/components/contribution-photo-test.gjs @@ -1,7 +1,8 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'marco/tests/helpers'; -import { render, click } from '@ember/test-helpers'; +import { render, click, settled } from '@ember/test-helpers'; import ContributionPhoto from 'marco/components/contribution-photo'; +import { ContributionEntry } from 'marco/utils/contributions'; function noop() {} @@ -145,4 +146,40 @@ module('Integration | Component | contribution-photo', function (hooks) { assert.strictEqual(selected, this.item); }); + + test('it updates the place name when a tracked entry resolves after render', async function (assert) { + // Regression: when a place name is resolved by the background batch fetch + // after the entry has already been rendered as "Loading…", the component + // must re-render with the resolved name. This requires the entry's + // placeName/placeNameLoading to be tracked. + this.item = new ContributionEntry({ + placeIdentifier: 'osm:node:12345', + osmType: 'node', + osmId: '12345', + createdAt: 1000, + photos: [ + { + url: 'https://x.com/1.jpg', + thumbUrl: 'https://x.com/t1.jpg', + tags: [], + }, + ], + }); + + await render( + + ); + + assert.dom('.contribution-name-loading').hasText('Loading…'); + + // Simulate the contributions service resolving the name after the batch fetch + this.item.placeName = 'Resolved Café'; + this.item.placeNameLoading = false; + await settled(); + + assert.dom('.contribution-place').hasText('Resolved Café'); + assert.dom('.contribution-name-loading').doesNotExist(); + }); });