WIP Add activity photo item

Can show one or more photo additions in the activity timeline
This commit is contained in:
2026-09-02 10:22:15 -06:00
parent 256659fbfb
commit 36989e1dc2
8 changed files with 293 additions and 63 deletions
+134
View File
@@ -0,0 +1,134 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import formatRelativeDate from '../helpers/format-relative-date';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import Icon from './icon';
export default class ActivityPhotoItem extends Component {
get item() {
return this.args.item;
}
get senderName() {
return this.item?.senderName;
}
get senderDisplayName() {
const name = this.senderName;
if (name) return name;
const pubkey = this.item?.senderPubkey;
if (!pubkey) return 'Someone';
try {
return `${npubEncode(pubkey).slice(0, 12)}…`;
} catch {
return `${pubkey.slice(0, 12)}…`;
}
}
get senderAvatar() {
return this.item?.senderAvatar;
}
get hasPhoto() {
return this.photos.length > 0;
}
get photos() {
const photos = this.item?.photos;
if (photos && photos.length > 0) return photos;
const single = this.item?.photo;
return single ? [single] : [];
}
get primaryPhoto() {
return this.photos[0];
}
get extraPhotoCount() {
return Math.max(0, this.photos.length - 1);
}
get photoCountText() {
const count = this.photos.length;
return count === 1 ? 'a photo' : `${count} photos`;
}
get photoThumbUrl() {
return this.primaryPhoto?.thumbUrl || this.primaryPhoto?.url;
}
get placeName() {
return this.item?.placeName;
}
get placeNameLoading() {
return this.item?.placeNameLoading;
}
<template>
<li>
<button
type="button"
class="activity-item"
{{on "click" (fn @onSelect @item)}}
>
<div class="header">
<span class="sender-line">
<span class="sender-name">{{this.senderDisplayName}}</span>
{{! template-lint-disable no-whitespace-for-layout }}
<span class="action"> added {{this.photoCountText}}</span>
</span>
<span class="amount">
<Icon
@name="feather-camera"
@size={{16}}
@color="var(--secondary-text-color)"
/>
</span>
</div>
<div class="context">
<div class="context-images">
{{#if this.senderAvatar}}
<img
class="sender-avatar"
src={{this.senderAvatar}}
alt=""
loading="lazy"
/>
{{else}}
<div class="sender-avatar-placeholder">
<Icon @name="user" @size={{16}} @color="#999" />
</div>
{{/if}}
{{#if this.hasPhoto}}
<div class="context-thumb">
<img src={{this.photoThumbUrl}} alt="" loading="lazy" />
{{#if (gt this.extraPhotoCount 0)}}
<span
class="context-thumb-badge"
>+{{this.extraPhotoCount}}</span>
{{/if}}
</div>
{{/if}}
</div>
<div class="context-text">
<span class="activity-place-name">
{{#if this.placeName}}
{{this.placeName}}
{{else if this.placeNameLoading}}
<span class="contribution-name-loading">Loading…</span>
{{else}}
<span class="contribution-name-loading">Unnamed place</span>
{{/if}}
</span>
<span class="activity-date">{{formatRelativeDate
@item.createdAt
}}</span>
</div>
</div>
</button>
</li>
</template>
}
+73
View File
@@ -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")}}
<ActivityZapItem @item={{item}} @onSelect={{@onSelect}} />
{{else if (eq item.type "photo")}}
<ActivityPhotoItem @item={{item}} @onSelect={{@onSelect}} />
{{/if}}
{{/each}}
<ActivityPhotoItem
@item={{this.dummyPhotoEntry}}
@onSelect={{@onSelect}}
/>
<ActivityPhotoItem
@item={{this.dummyMultiPhotoEntry}}
@onSelect={{@onSelect}}
/>
</ul>
{{/if}}
</div>
+17 -15
View File
@@ -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>
+32 -16
View File
@@ -2661,14 +2661,14 @@ button.create-place {
}
}
/* Activity Timeline — zap activity list rendered in the sidebar */
/* Activity Timeline — activity list rendered in the sidebar */
.activity-list {
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;
}
+3
View File
@@ -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;
+8 -8
View File
@@ -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'),
@@ -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: 4 });
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');
});
});