Merge pull request 'Render data and uploader name for current photo in gallery' (#80) from feature/photo_details into master
CI / Lint (push) Successful in 59s
CI / Test (push) Successful in 1m11s

Reviewed-on: #80
This commit was merged in pull request #80.
This commit is contained in:
2026-08-20 01:06:13 +00:00
10 changed files with 417 additions and 30 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}}
+65 -5
View File
@@ -1,10 +1,12 @@
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
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';
import { npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
import { NostrIDB, openDB } from 'nostr-idb';
import {
excludeRequiredRelays,
@@ -15,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'];
@@ -35,20 +37,36 @@ export default class NostrDataService extends Service {
@tracked blossomServers = [];
@tracked placePhotos = [];
@tracked myContributionEvents = [];
@tracked profiles = {};
_profileSub = null;
_mailboxesSub = null;
_blossomSub = null;
_photosSub = null;
_contributionsSub = null;
_profileModelSubs = new Map();
_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, {
@@ -212,19 +230,31 @@ 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;
}
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([
@@ -235,6 +265,8 @@ export default class NostrDataService extends Service {
])
.subscribe((events) => {
this.placePhotos = events;
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
this.loadProfiles(pubkeys);
});
try {
@@ -331,6 +363,33 @@ export default class NostrDataService extends Service {
});
}
loadProfiles(pubkeys) {
const newPubkeys = pubkeys.filter(
(pk) => pk && !this._profileModelSubs.has(pk)
);
for (const pubkey of newPubkeys) {
const sub = this.store
.model(ProfileModel, pubkey)
.subscribe((profileContent) => {
this.profiles = { ...this.profiles, [pubkey]: profileContent };
});
this._profileModelSubs.set(pubkey, sub);
}
}
getProfile(pubkey) {
return this.profiles[pubkey];
}
_clearProfileSubs() {
for (const sub of this._profileModelSubs.values()) {
sub.unsubscribe();
}
this._profileModelSubs.clear();
this.profiles = {};
}
async loadProfile(pubkey) {
if (!pubkey) return;
@@ -471,6 +530,7 @@ export default class NostrDataService extends Service {
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,
+1
View File
@@ -105,6 +105,7 @@
"@noble/hashes": "^2.3.0",
"@waysidemapping/pinhead": "^15.25.0",
"applesauce-core": "^6.2.0",
"applesauce-loaders": "^6.2.0",
"applesauce-relay": "^6.2.1",
"applesauce-signers": "^6.2.2",
"blurhash": "^2.0.5",
+15
View File
@@ -20,6 +20,9 @@ importers:
applesauce-core:
specifier: ^6.2.0
version: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
applesauce-loaders:
specifier: ^6.2.0
version: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
applesauce-relay:
specifier: ^6.2.1
version: 6.2.1(supports-color@10.2.2)(typescript@5.9.3)
@@ -2636,6 +2639,9 @@ packages:
applesauce-core@6.2.0:
resolution: {integrity: sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==}
applesauce-loaders@6.2.0:
resolution: {integrity: sha512-isU2BuoVFhugv4WxdbUFrqm7mCNgsGrMU4BYcNqMm8qAVpVb3L7K3I1hlkIZWwOvNTkhoYO/u0utgwnkQj4Hrg==}
applesauce-relay@6.2.1:
resolution: {integrity: sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg==}
@@ -9354,6 +9360,15 @@ snapshots:
- supports-color
- typescript
applesauce-loaders@6.2.0(supports-color@10.2.2)(typescript@5.9.3):
dependencies:
applesauce-core: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
nanoid: 5.1.9
rxjs: 7.8.2
transitivePeerDependencies:
- supports-color
- typescript
applesauce-relay@6.2.1(supports-color@10.2.2)(typescript@5.9.3):
dependencies:
'@noble/hashes': 2.3.0
+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',