Render data and uploader name for current photo in gallery
CI / Lint (pull_request) Successful in 1m0s
CI / Test (pull_request) Successful in 1m12s
Release Drafter / Update release notes draft (pull_request) Successful in 5s

This commit is contained in:
2026-08-19 18:05:52 -06:00
parent 0f0874a07a
commit bfcf855020
8 changed files with 368 additions and 39 deletions
+62 -19
View File
@@ -7,10 +7,12 @@ import { service } from '@ember/service';
import { modifier } from 'ember-modifier';
import { task } from 'ember-concurrency';
import { EventFactory } from 'applesauce-core';
import or from 'ember-truth-helpers/helpers/or';
import config from 'marco/config/environment';
import DropdownMenu from './dropdown-menu';
import PhotoCarousel from './photo-carousel';
import Icon from './icon';
import formatRelativeDate from '../helpers/format-relative-date';
const GalleryContent = <template>
<div
@@ -25,26 +27,41 @@ const GalleryContent = <template>
class="photo-gallery-content"
data-current-event-id={{@currentPhoto.eventId}}
>
<div class="actions-btn-container">
<DropdownMenu
@iconSize={{24}}
@triggerIcon="more-horizontal"
@iconColor="white"
as |closeMenu|
>
<button
class="dropdown-item"
type="button"
{{on "click" (fn @copyEventId closeMenu)}}
>Copy Photo Event ID</button>
{{#if @canDeletePhoto}}
<div class="photo-gallery-header">
<div class="actions-btn-container">
<DropdownMenu
@iconSize={{24}}
@triggerIcon={{@triggerIcon}}
@iconColor="white"
as |closeMenu|
>
<button
class="dropdown-item text-danger"
class="dropdown-item"
type="button"
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
>Delete Photo</button>
{{/if}}
</DropdownMenu>
{{on "click" (fn @copyEventId closeMenu)}}
>Copy Photo Event ID</button>
{{#if @canDeletePhoto}}
<button
class="dropdown-item text-danger"
type="button"
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
>Delete Photo</button>
{{/if}}
</DropdownMenu>
</div>
{{#if (or @uploaderName @photoDate)}}
<div class="photo-gallery-uploader-info">
{{#if @uploaderName}}
<span class="photo-gallery-uploader-name">{{@uploaderName}}</span>
{{/if}}
{{#if @photoDate}}
<span class="photo-gallery-uploader-date">
{{formatRelativeDate @photoDate}}
</span>
{{/if}}
</div>
{{/if}}
</div>
<button
@@ -98,6 +115,13 @@ export default class PhotoGallery extends Component {
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
get triggerIcon() {
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
return 'more-vertical';
}
return 'more-horizontal';
}
get isCreator() {
return (
this.currentPhoto?.pubkey &&
@@ -112,6 +136,19 @@ export default class PhotoGallery extends Component {
);
}
get uploaderName() {
const pubkey = this.currentPhoto?.pubkey;
if (!pubkey) return null;
const profile = this.nostrData.getProfile?.(pubkey);
if (!profile) return null;
return profile.displayName || profile.display_name || profile.name || null;
}
get photoDate() {
const ts = this.currentPhoto?.publishedAt || this.currentPhoto?.createdAt;
return ts || null;
}
bindKeyboard = modifier((element, [handler]) => {
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
@@ -131,7 +168,7 @@ export default class PhotoGallery extends Component {
e.target.closest('.thumbnail-strip-container') ||
e.target.closest('.carousel-nav-btn') ||
e.target.closest('.close-btn') ||
e.target.closest('.actions-btn-container')
e.target.closest('.photo-gallery-header')
) {
return;
}
@@ -268,6 +305,9 @@ export default class PhotoGallery extends Component {
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@placeName={{@placeName}}
@selectPhoto={{this.selectPhoto}}
@uploaderName={{this.uploaderName}}
@photoDate={{this.photoDate}}
@triggerIcon={{this.triggerIcon}}
/>
{{else}}
{{#in-element this.destinationElement}}
@@ -284,6 +324,9 @@ export default class PhotoGallery extends Component {
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@placeName={{@placeName}}
@selectPhoto={{this.selectPhoto}}
@uploaderName={{this.uploaderName}}
@photoDate={{this.photoDate}}
@triggerIcon={{this.triggerIcon}}
/>
{{/in-element}}
{{/if}}
+32 -14
View File
@@ -1,6 +1,6 @@
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { from } from 'rxjs';
import { EMPTY, from } from 'rxjs';
import { EventStore } from 'applesauce-core/event-store';
import { ProfileModel } from 'applesauce-core/models/profile';
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
@@ -17,9 +17,9 @@ import {
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
const DIRECTORY_RELAYS = [
'wss://purplepag.es',
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol',
'wss://relay.damus.io',
];
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
@@ -48,11 +48,25 @@ export default class NostrDataService extends Service {
_requestSub = null;
_cachePromise = null;
_currentPlaceEntityId = null;
loadedGeohashPrefixes = new Set();
constructor() {
super(...arguments);
// Set up the event loader synchronously so that any subscription
// (e.g. loadProfiles from a route's afterModel) can auto-fetch even
// before the IndexedDB cache has finished opening. The cacheRequest
// is lazy — it returns EMPTY until `this.cache` is available, so the
// loader falls through to relay hints → lookup relays in the meantime.
createEventLoaderForStore(this.store, this.nostrRelay.pool, {
cacheRequest: (filters) => {
if (!this.cache) return EMPTY;
return from(this.cache.query(filters));
},
lookupRelays: DIRECTORY_RELAYS,
});
// Initialize the IndexedDB cache
this._cachePromise = openDB('applesauce-events').then(async (db) => {
this.cache = new NostrIDB(db, {
@@ -85,13 +99,6 @@ export default class NostrDataService extends Service {
maxBatchSize: 100,
}
);
// Set up the event loader so replaceable/event subscriptions auto-fetch
// from cache → relay hints → lookup relays with batching and dedup
createEventLoaderForStore(this.store, this.nostrRelay.pool, {
cacheRequest: (filters) => from(this.cache.query(filters)),
lookupRelays: DIRECTORY_RELAYS,
});
});
// Feed events from the relay pool into the event store
@@ -223,6 +230,18 @@ export default class NostrDataService extends Service {
}
async loadPhotosForPlace(place) {
const entityId =
place && place.osmId && place.osmType
? `osm:${place.osmType}:${place.osmId}`
: null;
// Skip the full reset if we're loading the same place again (e.g. from
// checkUpdates calling selectPlace a second time). This prevents tearing
// down timeline and profile subscriptions that are still in-flight.
if (entityId && entityId === this._currentPlaceEntityId) {
return;
}
if (this._photosSub) {
this._photosSub.unsubscribe();
this._photosSub = null;
@@ -230,13 +249,12 @@ export default class NostrDataService extends Service {
this.placePhotos = [];
this._clearProfileSubs();
this._currentPlaceEntityId = entityId;
if (!place || !place.osmId || !place.osmType) {
if (!entityId) {
return;
}
const entityId = `osm:${place.osmType}:${place.osmId}`;
// Setup reactive store query
this._photosSub = this.store
.timeline([
@@ -507,12 +525,12 @@ export default class NostrDataService extends Service {
this._contributionsSub.unsubscribe();
this._contributionsSub = null;
}
this._clearProfileSubs();
}
willDestroy() {
super.willDestroy(...arguments);
this._cleanupSubscriptions();
this._clearProfileSubs();
if (this._stopPersisting) {
this._stopPersisting();
+62 -6
View File
@@ -2151,17 +2151,73 @@ button.create-place {
background: #f0f0f0;
}
/* Actions button in photo gallery */
.photo-gallery-overlay .actions-btn-container {
/* Photo gallery header (actions button + uploader info) */
.photo-gallery-overlay .photo-gallery-header {
position: absolute;
top: 0.5rem;
left: 0.5rem;
width: 48px;
height: 48px;
left: 1rem;
z-index: 10;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.photo-gallery-overlay .photo-gallery-header .actions-btn-container {
position: relative;
z-index: 2;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
flex-shrink: 0;
}
/* Uploader info (name + date) in photo gallery */
.photo-gallery-overlay .photo-gallery-uploader-info {
color: rgb(255 255 255 / 90%);
display: flex;
flex-direction: column;
gap: 0.1rem;
pointer-events: none;
text-shadow: 0 1px 2px rgb(0 0 0 / 60%);
max-width: calc(100vw - 4rem);
overflow: hidden;
}
.photo-gallery-overlay .photo-gallery-uploader-name {
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.photo-gallery-overlay .photo-gallery-uploader-date {
font-size: 0.8rem;
color: rgb(255 255 255 / 70%);
white-space: nowrap;
}
@media (width <= 768px) {
.photo-gallery-overlay .photo-gallery-header {
flex-direction: row;
align-items: flex-start;
gap: 0.5rem;
left: 0.5rem;
}
.photo-gallery-overlay .photo-gallery-header .actions-btn-container {
width: 48px;
justify-content: center;
}
.photo-gallery-overlay .photo-gallery-uploader-info {
flex-direction: row;
align-items: center;
gap: 0.35rem;
align-self: center;
}
}
/* Snappy slide-in from left (Desktop) */
+3
View File
@@ -94,11 +94,14 @@ function parsePhotoFromEvent(event) {
if (!url) return null;
const placeIdentifier = tags.find((t) => t[0] === 'i')?.[1];
const publishedAtRaw = tags.find((t) => t[0] === 'published_at')?.[1];
const publishedAt = publishedAtRaw ? Number(publishedAtRaw) : null;
return {
eventId: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
url,
thumbUrl,
blurhash,
+5
View File
@@ -69,6 +69,10 @@ export function parsePlacePhotos(events) {
let aspectRatio = 16 / 9; // default
let altText = null;
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
const publishedAtRaw = event.tags.find(
(t) => t[0] === 'published_at'
)?.[1];
const publishedAt = publishedAtRaw ? Number(publishedAtRaw) : null;
for (const tag of imeta.slice(1)) {
if (tag.startsWith('url ')) {
@@ -98,6 +102,7 @@ export function parsePlacePhotos(events) {
eventId: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
url,
thumbUrl,
blurhash,
+5
View File
@@ -36,11 +36,16 @@ export class MockNostrDataService extends Service {
@tracked mailboxes = null;
@tracked blossomServers = [];
@tracked placePhotos = [];
@tracked profiles = {};
store = {
add: () => {},
};
getProfile(pubkey) {
return this.profiles[pubkey];
}
get activeReadRelays() {
return [];
}
@@ -339,4 +339,143 @@ module('Integration | Component | photo-gallery', function (hooks) {
await triggerKeyEvent(document, 'keydown', 'Escape');
assert.ok(closed, 'gallery was closed on escape key');
});
test('it renders uploader name and date when profile is loaded', async function (assert) {
const displayName = 'Alice';
const publishedAt = Math.floor(Date.now() / 1000) - 60 * 60 * 24; // 1 day ago
this.nostrData.profiles = {
[USER_A]: { displayName, display_name: 'ignored', name: 'ignored' },
};
this.photos = [
{
eventId: 'event1',
pubkey: USER_A,
placeIdentifier: 'osm:node:12345',
url: 'https://example.com/photo.jpg',
publishedAt,
createdAt: publishedAt + 10,
},
];
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
assert
.dom('.photo-gallery-uploader-name')
.hasText(displayName, 'uploader name is rendered');
assert
.dom('.photo-gallery-uploader-date')
.exists('date element is rendered');
});
test('it prefers published_at over created_at for the date', async function (assert) {
this.nostrData.profiles = {
[USER_A]: { displayName: 'Alice' },
};
const publishedAt = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 3; // 3 days ago
const createdAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
this.photos = [
{
eventId: 'event1',
pubkey: USER_A,
url: 'https://example.com/photo.jpg',
publishedAt,
createdAt,
},
];
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
// Should show "3 days ago" (publishedAt), not "just now" (createdAt)
assert.dom('.photo-gallery-uploader-date').hasText('3 days ago');
});
test('it does not render uploader info when profile is missing', async function (assert) {
this.nostrData.profiles = {};
this.photos = [
{
eventId: 'event1',
pubkey: USER_A,
url: 'https://example.com/photo.jpg',
createdAt: Math.floor(Date.now() / 1000),
},
];
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
assert
.dom('.photo-gallery-uploader-name')
.doesNotExist('uploader name is not rendered without profile');
// Date should still render since createdAt is present
assert.dom('.photo-gallery-uploader-date').exists('date is still rendered');
});
test('it falls back through displayName -> display_name -> name', async function (assert) {
this.nostrData.profiles = {
[USER_A]: { display_name: 'Bob', name: 'Robert' },
};
this.photos = [
{
eventId: 'event1',
pubkey: USER_A,
url: 'https://example.com/photo.jpg',
createdAt: Math.floor(Date.now() / 1000) - 60,
},
];
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
assert
.dom('.photo-gallery-uploader-name')
.hasText('Bob', 'falls back to display_name when displayName is missing');
});
});
+60
View File
@@ -210,6 +210,66 @@ module('Unit | Utility | nostr', function () {
assert.strictEqual(photos[0].alt, null);
});
test('parsePlacePhotos extracts published_at when present', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 200,
tags: [
['i', 'osm:node:123'],
['published_at', '100'],
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].publishedAt, 100);
assert.strictEqual(photos[0].createdAt, 200);
});
test('parsePlacePhotos sets publishedAt to null when not present', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 200,
tags: [
['i', 'osm:node:123'],
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].publishedAt, null);
});
test('parsePlacePhotos ignores invalid published_at values', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 200,
tags: [
['i', 'osm:node:123'],
['published_at', 'not-a-number'],
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].publishedAt, null);
});
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
const relays = uniqNormalizedRelays([
'Relay.example.com',