diff --git a/app/components/activity-photo-item.gjs b/app/components/activity-photo-item.gjs new file mode 100644 index 0000000..dc96ae6 --- /dev/null +++ b/app/components/activity-photo-item.gjs @@ -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; + } + + +} diff --git a/app/components/activity-timeline.gjs b/app/components/activity-timeline.gjs index 2cc6985..ebc23bc 100644 --- a/app/components/activity-timeline.gjs +++ b/app/components/activity-timeline.gjs @@ -4,15 +4,78 @@ 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'; import eq from 'ember-truth-helpers/helpers/eq'; import restoreScroll from '../modifiers/restore-scroll'; +import { ActivityEntry } from '../utils/activity'; export default class ActivityTimelineComponent extends Component { @tracked isNostrConnectModalOpen = false; + get dummyPhotoEntry() { + const entry = new ActivityEntry({ + photoEventId: 'dummy-photo-event-id', + photos: [ + { + thumbUrl: 'https://picsum.photos/seed/marco-photo-1/200/200', + url: 'https://picsum.photos/seed/marco-photo-1/800/800', + placeIdentifier: 'osm:node:123456789', + }, + ], + placeIdentifier: 'osm:node:123456789', + senderPubkey: 'dummy-sender-pubkey', + amountSats: 0, + message: null, + createdAt: Math.floor(Date.now() / 1000) - 3600, + }); + entry.type = 'photo'; + entry.senderName = 'Alice Mapper'; + entry.senderAvatar = + 'https://api.dicebear.com/7.x/avataaars/svg?seed=alice'; + entry.placeName = 'Central Park Café'; + entry.placeNameLoading = false; + entry.senderProfileLoading = false; + return entry; + } + + get dummyMultiPhotoEntry() { + const entry = new ActivityEntry({ + photoEventId: 'dummy-multi-photo-event-id', + photos: [ + { + thumbUrl: 'https://picsum.photos/seed/marco-multi-1/200/200', + url: 'https://picsum.photos/seed/marco-multi-1/800/800', + placeIdentifier: 'osm:node:987654321', + }, + { + thumbUrl: 'https://picsum.photos/seed/marco-multi-2/200/200', + url: 'https://picsum.photos/seed/marco-multi-2/800/800', + placeIdentifier: 'osm:node:987654321', + }, + { + thumbUrl: 'https://picsum.photos/seed/marco-multi-3/200/200', + url: 'https://picsum.photos/seed/marco-multi-3/800/800', + placeIdentifier: 'osm:node:987654321', + }, + ], + placeIdentifier: 'osm:node:987654321', + senderPubkey: 'dummy-multi-sender-pubkey', + amountSats: 0, + message: null, + createdAt: Math.floor(Date.now() / 1000) - 7200, + }); + entry.type = 'photo'; + entry.senderName = 'Bob Cartographer'; + entry.senderAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=bob'; + entry.placeName = 'Riverside Lookout'; + entry.placeNameLoading = false; + entry.senderProfileLoading = false; + return entry; + } + @action openNostrConnectModal(event) { event.preventDefault(); @@ -69,8 +132,18 @@ export default class ActivityTimelineComponent extends Component { {{#each @items as |item|}} {{#if (eq item.type "zap")}} + {{else if (eq item.type "photo")}} + {{/if}} {{/each}} + + {{/if}} diff --git a/app/components/activity-zap-item.gjs b/app/components/activity-zap-item.gjs index e404bf5..d58c8ae 100644 --- a/app/components/activity-zap-item.gjs +++ b/app/components/activity-zap-item.gjs @@ -50,40 +50,40 @@ export default class ActivityZapItem extends Component {
  • diff --git a/app/styles/app.css b/app/styles/app.css index 654a673..25c62de 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -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; } diff --git a/app/utils/activity.js b/app/utils/activity.js index 9f76666..07b2bc1 100644 --- a/app/utils/activity.js +++ b/app/utils/activity.js @@ -27,6 +27,7 @@ export class ActivityEntry { type = 'zap'; photoEventId; photo; + photos = []; placeIdentifier; osmType; osmId; @@ -43,6 +44,7 @@ export class ActivityEntry { constructor({ photoEventId, photo, + photos, placeIdentifier, senderPubkey, amountSats, @@ -51,6 +53,7 @@ export class ActivityEntry { }) { this.photoEventId = photoEventId; this.photo = photo; + this.photos = photos ?? []; this.placeIdentifier = placeIdentifier; this.senderPubkey = senderPubkey; this.amountSats = amountSats; diff --git a/tests/acceptance/activity-test.js b/tests/acceptance/activity-test.js index 4500822..239231f 100644 --- a/tests/acceptance/activity-test.js +++ b/tests/acceptance/activity-test.js @@ -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: 3 }); + 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'), diff --git a/tests/integration/components/activity-timeline-test.gjs b/tests/integration/components/activity-timeline-test.gjs index 3b47ed3..244f0f3 100644 --- a/tests/integration/components/activity-timeline-test.gjs +++ b/tests/integration/components/activity-timeline-test.gjs @@ -143,7 +143,7 @@ module('Integration | Component | activity-timeline', function (hooks) { ); - assert.dom('.activity-zap-item').exists({ count: 2 }); + assert.dom('.activity-item').exists({ count: 4 }); assert.dom(this.element).includesText('Alice'); assert.dom(this.element).includesText('21 ⚡'); assert.dom(this.element).includesText('Bob'); diff --git a/tests/integration/components/activity-zap-item-test.gjs b/tests/integration/components/activity-zap-item-test.gjs index bf42b0d..e5254e3 100644 --- a/tests/integration/components/activity-zap-item-test.gjs +++ b/tests/integration/components/activity-zap-item-test.gjs @@ -36,15 +36,15 @@ module('Integration | Component | activity-zap-item', function (hooks) { ); - 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) { ); - 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) { ); - 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) { ); - 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) { ); - 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) { ); - 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) { ); - 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'); }); });