Render date and uploader name for current photo in gallery #80
@@ -7,10 +7,12 @@ import { service } from '@ember/service';
|
|||||||
import { modifier } from 'ember-modifier';
|
import { modifier } from 'ember-modifier';
|
||||||
import { task } from 'ember-concurrency';
|
import { task } from 'ember-concurrency';
|
||||||
import { EventFactory } from 'applesauce-core';
|
import { EventFactory } from 'applesauce-core';
|
||||||
|
import or from 'ember-truth-helpers/helpers/or';
|
||||||
import config from 'marco/config/environment';
|
import config from 'marco/config/environment';
|
||||||
import DropdownMenu from './dropdown-menu';
|
import DropdownMenu from './dropdown-menu';
|
||||||
import PhotoCarousel from './photo-carousel';
|
import PhotoCarousel from './photo-carousel';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
import formatRelativeDate from '../helpers/format-relative-date';
|
||||||
|
|
||||||
const GalleryContent = <template>
|
const GalleryContent = <template>
|
||||||
<div
|
<div
|
||||||
@@ -25,26 +27,41 @@ const GalleryContent = <template>
|
|||||||
class="photo-gallery-content"
|
class="photo-gallery-content"
|
||||||
data-current-event-id={{@currentPhoto.eventId}}
|
data-current-event-id={{@currentPhoto.eventId}}
|
||||||
>
|
>
|
||||||
<div class="actions-btn-container">
|
<div class="photo-gallery-header">
|
||||||
<DropdownMenu
|
<div class="actions-btn-container">
|
||||||
@iconSize={{24}}
|
<DropdownMenu
|
||||||
@triggerIcon="more-horizontal"
|
@iconSize={{24}}
|
||||||
@iconColor="white"
|
@triggerIcon={{@triggerIcon}}
|
||||||
as |closeMenu|
|
@iconColor="white"
|
||||||
>
|
as |closeMenu|
|
||||||
<button
|
>
|
||||||
class="dropdown-item"
|
|
||||||
type="button"
|
|
||||||
{{on "click" (fn @copyEventId closeMenu)}}
|
|
||||||
>Copy Photo Event ID</button>
|
|
||||||
{{#if @canDeletePhoto}}
|
|
||||||
<button
|
<button
|
||||||
class="dropdown-item text-danger"
|
class="dropdown-item"
|
||||||
type="button"
|
type="button"
|
||||||
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
{{on "click" (fn @copyEventId closeMenu)}}
|
||||||
>Delete Photo</button>
|
>Copy Photo Event ID</button>
|
||||||
{{/if}}
|
{{#if @canDeletePhoto}}
|
||||||
</DropdownMenu>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -98,6 +115,13 @@ export default class PhotoGallery extends Component {
|
|||||||
|
|
||||||
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
|
@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() {
|
get isCreator() {
|
||||||
return (
|
return (
|
||||||
this.currentPhoto?.pubkey &&
|
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]) => {
|
bindKeyboard = modifier((element, [handler]) => {
|
||||||
document.addEventListener('keydown', handler);
|
document.addEventListener('keydown', handler);
|
||||||
return () => document.removeEventListener('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('.thumbnail-strip-container') ||
|
||||||
e.target.closest('.carousel-nav-btn') ||
|
e.target.closest('.carousel-nav-btn') ||
|
||||||
e.target.closest('.close-btn') ||
|
e.target.closest('.close-btn') ||
|
||||||
e.target.closest('.actions-btn-container')
|
e.target.closest('.photo-gallery-header')
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -268,6 +305,9 @@ export default class PhotoGallery extends Component {
|
|||||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||||
@placeName={{@placeName}}
|
@placeName={{@placeName}}
|
||||||
@selectPhoto={{this.selectPhoto}}
|
@selectPhoto={{this.selectPhoto}}
|
||||||
|
@uploaderName={{this.uploaderName}}
|
||||||
|
@photoDate={{this.photoDate}}
|
||||||
|
@triggerIcon={{this.triggerIcon}}
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#in-element this.destinationElement}}
|
{{#in-element this.destinationElement}}
|
||||||
@@ -284,6 +324,9 @@ export default class PhotoGallery extends Component {
|
|||||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||||
@placeName={{@placeName}}
|
@placeName={{@placeName}}
|
||||||
@selectPhoto={{this.selectPhoto}}
|
@selectPhoto={{this.selectPhoto}}
|
||||||
|
@uploaderName={{this.uploaderName}}
|
||||||
|
@photoDate={{this.photoDate}}
|
||||||
|
@triggerIcon={{this.triggerIcon}}
|
||||||
/>
|
/>
|
||||||
{{/in-element}}
|
{{/in-element}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import Service, { service } from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { EMPTY, from } from 'rxjs';
|
||||||
import { EventStore } from 'applesauce-core/event-store';
|
import { EventStore } from 'applesauce-core/event-store';
|
||||||
import { ProfileModel } from 'applesauce-core/models/profile';
|
import { ProfileModel } from 'applesauce-core/models/profile';
|
||||||
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
import { MailboxesModel } from 'applesauce-core/models/mailboxes';
|
||||||
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
import { npubEncode } from 'applesauce-core/helpers/pointers';
|
||||||
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
|
||||||
|
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
|
||||||
import { NostrIDB, openDB } from 'nostr-idb';
|
import { NostrIDB, openDB } from 'nostr-idb';
|
||||||
import {
|
import {
|
||||||
excludeRequiredRelays,
|
excludeRequiredRelays,
|
||||||
@@ -15,9 +17,9 @@ import {
|
|||||||
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||||
|
|
||||||
const DIRECTORY_RELAYS = [
|
const DIRECTORY_RELAYS = [
|
||||||
'wss://purplepag.es',
|
'wss://relay.primal.net',
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
|
'wss://relay.damus.io',
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
|
||||||
@@ -35,20 +37,36 @@ export default class NostrDataService extends Service {
|
|||||||
@tracked blossomServers = [];
|
@tracked blossomServers = [];
|
||||||
@tracked placePhotos = [];
|
@tracked placePhotos = [];
|
||||||
@tracked myContributionEvents = [];
|
@tracked myContributionEvents = [];
|
||||||
|
@tracked profiles = {};
|
||||||
|
|
||||||
_profileSub = null;
|
_profileSub = null;
|
||||||
_mailboxesSub = null;
|
_mailboxesSub = null;
|
||||||
_blossomSub = null;
|
_blossomSub = null;
|
||||||
_photosSub = null;
|
_photosSub = null;
|
||||||
_contributionsSub = null;
|
_contributionsSub = null;
|
||||||
|
_profileModelSubs = new Map();
|
||||||
|
|
||||||
_requestSub = null;
|
_requestSub = null;
|
||||||
_cachePromise = null;
|
_cachePromise = null;
|
||||||
|
_currentPlaceEntityId = null;
|
||||||
loadedGeohashPrefixes = new Set();
|
loadedGeohashPrefixes = new Set();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
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
|
// Initialize the IndexedDB cache
|
||||||
this._cachePromise = openDB('applesauce-events').then(async (db) => {
|
this._cachePromise = openDB('applesauce-events').then(async (db) => {
|
||||||
this.cache = new NostrIDB(db, {
|
this.cache = new NostrIDB(db, {
|
||||||
@@ -212,19 +230,31 @@ export default class NostrDataService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadPhotosForPlace(place) {
|
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) {
|
if (this._photosSub) {
|
||||||
this._photosSub.unsubscribe();
|
this._photosSub.unsubscribe();
|
||||||
this._photosSub = null;
|
this._photosSub = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.placePhotos = [];
|
this.placePhotos = [];
|
||||||
|
this._clearProfileSubs();
|
||||||
|
this._currentPlaceEntityId = entityId;
|
||||||
|
|
||||||
if (!place || !place.osmId || !place.osmType) {
|
if (!entityId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityId = `osm:${place.osmType}:${place.osmId}`;
|
|
||||||
|
|
||||||
// Setup reactive store query
|
// Setup reactive store query
|
||||||
this._photosSub = this.store
|
this._photosSub = this.store
|
||||||
.timeline([
|
.timeline([
|
||||||
@@ -235,6 +265,8 @@ export default class NostrDataService extends Service {
|
|||||||
])
|
])
|
||||||
.subscribe((events) => {
|
.subscribe((events) => {
|
||||||
this.placePhotos = events;
|
this.placePhotos = events;
|
||||||
|
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
|
||||||
|
this.loadProfiles(pubkeys);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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) {
|
async loadProfile(pubkey) {
|
||||||
if (!pubkey) return;
|
if (!pubkey) return;
|
||||||
|
|
||||||
@@ -471,6 +530,7 @@ export default class NostrDataService extends Service {
|
|||||||
willDestroy() {
|
willDestroy() {
|
||||||
super.willDestroy(...arguments);
|
super.willDestroy(...arguments);
|
||||||
this._cleanupSubscriptions();
|
this._cleanupSubscriptions();
|
||||||
|
this._clearProfileSubs();
|
||||||
|
|
||||||
if (this._stopPersisting) {
|
if (this._stopPersisting) {
|
||||||
this._stopPersisting();
|
this._stopPersisting();
|
||||||
|
|||||||
+62
-6
@@ -2151,17 +2151,73 @@ button.create-place {
|
|||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions button in photo gallery */
|
/* Photo gallery header (actions button + uploader info) */
|
||||||
.photo-gallery-overlay .actions-btn-container {
|
.photo-gallery-overlay .photo-gallery-header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
left: 0.5rem;
|
left: 1rem;
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
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;
|
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) */
|
/* Snappy slide-in from left (Desktop) */
|
||||||
|
|||||||
@@ -94,11 +94,14 @@ function parsePhotoFromEvent(event) {
|
|||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
const placeIdentifier = tags.find((t) => t[0] === 'i')?.[1];
|
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 {
|
return {
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
|
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
|
||||||
url,
|
url,
|
||||||
thumbUrl,
|
thumbUrl,
|
||||||
blurhash,
|
blurhash,
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ export function parsePlacePhotos(events) {
|
|||||||
let aspectRatio = 16 / 9; // default
|
let aspectRatio = 16 / 9; // default
|
||||||
let altText = null;
|
let altText = null;
|
||||||
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
|
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)) {
|
for (const tag of imeta.slice(1)) {
|
||||||
if (tag.startsWith('url ')) {
|
if (tag.startsWith('url ')) {
|
||||||
@@ -98,6 +102,7 @@ export function parsePlacePhotos(events) {
|
|||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
|
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
|
||||||
url,
|
url,
|
||||||
thumbUrl,
|
thumbUrl,
|
||||||
blurhash,
|
blurhash,
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
"@noble/hashes": "^2.3.0",
|
"@noble/hashes": "^2.3.0",
|
||||||
"@waysidemapping/pinhead": "^15.25.0",
|
"@waysidemapping/pinhead": "^15.25.0",
|
||||||
"applesauce-core": "^6.2.0",
|
"applesauce-core": "^6.2.0",
|
||||||
|
"applesauce-loaders": "^6.2.0",
|
||||||
"applesauce-relay": "^6.2.1",
|
"applesauce-relay": "^6.2.1",
|
||||||
"applesauce-signers": "^6.2.2",
|
"applesauce-signers": "^6.2.2",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
|
|||||||
Generated
+15
@@ -20,6 +20,9 @@ importers:
|
|||||||
applesauce-core:
|
applesauce-core:
|
||||||
specifier: ^6.2.0
|
specifier: ^6.2.0
|
||||||
version: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
|
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:
|
applesauce-relay:
|
||||||
specifier: ^6.2.1
|
specifier: ^6.2.1
|
||||||
version: 6.2.1(supports-color@10.2.2)(typescript@5.9.3)
|
version: 6.2.1(supports-color@10.2.2)(typescript@5.9.3)
|
||||||
@@ -2636,6 +2639,9 @@ packages:
|
|||||||
applesauce-core@6.2.0:
|
applesauce-core@6.2.0:
|
||||||
resolution: {integrity: sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==}
|
resolution: {integrity: sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==}
|
||||||
|
|
||||||
|
applesauce-loaders@6.2.0:
|
||||||
|
resolution: {integrity: sha512-isU2BuoVFhugv4WxdbUFrqm7mCNgsGrMU4BYcNqMm8qAVpVb3L7K3I1hlkIZWwOvNTkhoYO/u0utgwnkQj4Hrg==}
|
||||||
|
|
||||||
applesauce-relay@6.2.1:
|
applesauce-relay@6.2.1:
|
||||||
resolution: {integrity: sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg==}
|
resolution: {integrity: sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg==}
|
||||||
|
|
||||||
@@ -9354,6 +9360,15 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- 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):
|
applesauce-relay@6.2.1(supports-color@10.2.2)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/hashes': 2.3.0
|
'@noble/hashes': 2.3.0
|
||||||
|
|||||||
@@ -36,11 +36,16 @@ export class MockNostrDataService extends Service {
|
|||||||
@tracked mailboxes = null;
|
@tracked mailboxes = null;
|
||||||
@tracked blossomServers = [];
|
@tracked blossomServers = [];
|
||||||
@tracked placePhotos = [];
|
@tracked placePhotos = [];
|
||||||
|
@tracked profiles = {};
|
||||||
|
|
||||||
store = {
|
store = {
|
||||||
add: () => {},
|
add: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getProfile(pubkey) {
|
||||||
|
return this.profiles[pubkey];
|
||||||
|
}
|
||||||
|
|
||||||
get activeReadRelays() {
|
get activeReadRelays() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,4 +339,143 @@ module('Integration | Component | photo-gallery', function (hooks) {
|
|||||||
await triggerKeyEvent(document, 'keydown', 'Escape');
|
await triggerKeyEvent(document, 'keydown', 'Escape');
|
||||||
assert.ok(closed, 'gallery was closed on escape key');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -210,6 +210,66 @@ module('Unit | Utility | nostr', function () {
|
|||||||
assert.strictEqual(photos[0].alt, null);
|
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) {
|
test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
|
||||||
const relays = uniqNormalizedRelays([
|
const relays = uniqNormalizedRelays([
|
||||||
'Relay.example.com',
|
'Relay.example.com',
|
||||||
|
|||||||
Reference in New Issue
Block a user