Merge pull request 'Delete own photos, fetch/sync remote deletions' (#55) from feature/delete_own_photos into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 1m0s

Reviewed-on: #55
This commit was merged in pull request #55.
This commit is contained in:
2026-05-13 10:08:22 +00:00
12 changed files with 734 additions and 114 deletions

View File

@@ -6,6 +6,7 @@ import Icon from '#components/icon';
import AppMenuSettingsMapUi from './settings/map-ui';
import AppMenuSettingsApis from './settings/apis';
import AppMenuSettingsNostr from './settings/nostr';
import AppMenuSettingsExperimental from './settings/experimental';
export default class AppMenuSettings extends Component {
@service settings;
@@ -35,6 +36,7 @@ export default class AppMenuSettings extends Component {
<AppMenuSettingsMapUi @onChange={{this.updateSetting}} />
<AppMenuSettingsApis @onChange={{this.updateSetting}} />
<AppMenuSettingsNostr @onChange={{this.updateSetting}} />
<AppMenuSettingsExperimental @onChange={{this.updateSetting}} />
</section>
</div>
</template>

View File

@@ -0,0 +1,49 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { service } from '@ember/service';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
export default class AppMenuSettingsExperimental extends Component {
@service settings;
<template>
{{! template-lint-disable no-nested-interactive }}
<details>
<summary>
<Icon @name="alert-triangle" @size={{20}} />
<span>Experimental</span>
</summary>
<div class="details-content form-layout">
<div class="form-group">
<label for="experimental-enable-photo-deletion">Enable photo deletion
(own photos)</label>
<select
id="experimental-enable-photo-deletion"
class="form-control"
{{on "change" (fn @onChange "experimentalEnablePhotoDeletion")}}
>
<option
value="true"
selected={{if
this.settings.experimentalEnablePhotoDeletion
"selected"
}}
>
On
</option>
<option
value="false"
selected={{unless
this.settings.experimentalEnablePhotoDeletion
"selected"
}}
>
Off
</option>
</select>
</div>
</div>
</details>
</template>
}

View File

@@ -109,32 +109,6 @@ export default class AppMenuSettingsNostr extends Component {
<span>Nostr</span>
</summary>
<div class="details-content form-layout">
<div class="form-group">
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
servers</label>
<select
id="nostr-photo-fallback-uploads"
class="form-control"
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
>
<option
value="true"
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.nostrPhotoFallbackUploads
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label for="new-read-relay">Read Relays</label>
<ul class="relay-list">
@@ -225,6 +199,32 @@ export default class AppMenuSettingsNostr extends Component {
{{/if}}
</div>
<div class="form-group">
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
servers</label>
<select
id="nostr-photo-fallback-uploads"
class="form-control"
{{on "change" (fn @onChange "nostrPhotoFallbackUploads")}}
>
<option
value="true"
selected={{if this.settings.nostrPhotoFallbackUploads "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.nostrPhotoFallbackUploads
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<label>Cached data</label>
<button

View File

@@ -1,9 +1,39 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import config from 'marco/config/environment';
import Icon from './icon';
const ModalContent = <template>
<div class="modal-overlay" role="dialog" tabindex="-1" {{on "click" @close}}>
<div
class="modal-content"
role="document"
tabindex="0"
{{on "click" @stopProp}}
>
<button
type="button"
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
disabled={{@disableClose}}
{{on "click" @close}}
>
<Icon @name="x" @size={{24}} @color="currentColor" />
</button>
{{yield}}
</div>
</div>
</template>;
export default class Modal extends Component {
get isTesting() {
return config.environment === 'test';
}
get destinationElement() {
return document.getElementById('modal-portal') || document.body;
}
@action
stopProp(e) {
e.stopPropagation();
@@ -18,28 +48,24 @@ export default class Modal extends Component {
}
<template>
<div
class="modal-overlay"
role="dialog"
tabindex="-1"
{{on "click" this.close}}
>
<div
class="modal-content"
role="document"
tabindex="0"
{{on "click" this.stopProp}}
{{#if this.isTesting}}
<ModalContent
@close={{this.close}}
@stopProp={{this.stopProp}}
@disableClose={{@disableClose}}
>
<button
type="button"
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
disabled={{@disableClose}}
{{on "click" this.close}}
>
<Icon @name="x" @size={{24}} @color="currentColor" />
</button>
{{yield}}
</div>
</div>
</ModalContent>
{{else}}
{{#in-element this.destinationElement}}
<ModalContent
@close={{this.close}}
@stopProp={{this.stopProp}}
@disableClose={{@disableClose}}
>
{{yield}}
</ModalContent>
{{/in-element}}
{{/if}}
</template>
}

View File

@@ -8,6 +8,7 @@ import Icon from './icon';
import fadeInImage from '../modifiers/fade-in-image';
import { on } from '@ember/modifier';
import { modifier } from 'ember-modifier';
import config from 'marco/config/environment';
export default class PhotoCarousel extends Component {
@tracked canScrollLeft = false;
@@ -55,6 +56,8 @@ export default class PhotoCarousel extends Component {
}
});
isProgrammaticScroll = false;
scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) {
const isInitial = !this.lastEventId;
@@ -65,6 +68,9 @@ export default class PhotoCarousel extends Component {
return;
}
this.internalEventId = eventId;
this.isProgrammaticScroll = true;
const scrollAction = () => {
const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]`
@@ -78,11 +84,18 @@ export default class PhotoCarousel extends Component {
// Restore smooth scroll after the jump
setTimeout(() => {
element.style.scrollBehavior = originalScrollBehavior;
this.isProgrammaticScroll = false;
}, 50);
} else {
// Use native CSS smooth scrolling for subsequent clicks
element.scrollLeft = targetSlide.offsetLeft;
// Clear programmatic scroll flag after a delay to let scroll finish
setTimeout(() => {
this.isProgrammaticScroll = false;
}, 500);
}
} else {
this.isProgrammaticScroll = false;
}
};
@@ -111,10 +124,16 @@ export default class PhotoCarousel extends Component {
}
let intersectionObserver;
if (this.args.onVisiblePhotoChange && window.IntersectionObserver) {
if (
this.args.onVisiblePhotoChange &&
window.IntersectionObserver &&
config.environment !== 'test'
) {
// Set up intersection observer to track which photo is currently "most" visible
intersectionObserver = new IntersectionObserver(
(entries) => {
if (this.isProgrammaticScroll) return;
for (let entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
const eventId = entry.target.dataset.eventId;

View File

@@ -1,17 +1,119 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import { inject as service } from '@ember/service';
import { modifier } from 'ember-modifier';
import { task } from 'ember-concurrency';
import { EventFactory } from 'applesauce-factory';
import config from 'marco/config/environment';
import DropdownMenu from './dropdown-menu';
import PhotoCarousel from './photo-carousel';
import DropdownMenu from '#components/dropdown-menu';
import Icon from './icon';
const GalleryContent = <template>
<div
class="photo-gallery-overlay"
role="dialog"
tabindex="-1"
{{on "click" @handleBackgroundClick}}
{{@bindKeyboard @handleKeydown}}
>
{{! template-lint-disable no-invalid-interactive }}
<div class="photo-gallery-content">
<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}}
<button
class="dropdown-item text-danger"
type="button"
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
>Delete Photo</button>
{{/if}}
</DropdownMenu>
</div>
<button
type="button"
class="close-btn btn-text"
{{on "click" @handleClose}}
aria-label="Close gallery"
title="Close"
>
<Icon @name="x" @size={{24}} @color="white" />
</button>
<div class="main-photo-container">
<PhotoCarousel
@variant="gallery-main"
@photos={{@photos}}
@scrollToEventId={{@currentPhoto.eventId}}
@onVisiblePhotoChange={{@handleVisiblePhotoChange}}
@name={{@placeName}}
/>
</div>
<div class="thumbnail-strip-container">
<PhotoCarousel
@variant="gallery-thumbnails"
@photos={{@photos}}
@scrollToEventId={{@currentPhoto.eventId}}
@onPhotoClick={{@selectPhoto}}
@name={{@placeName}}
/>
</div>
</div>
</div>
</template>;
export default class PhotoGallery extends Component {
get isTesting() {
return config.environment === 'test';
}
get destinationElement() {
return document.getElementById('modal-portal') || document.body;
}
@service toast;
@service nostrAuth;
@service nostrData;
@service nostrRelay;
@service blossom;
@service settings;
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
get isCreator() {
return (
this.currentPhoto?.pubkey &&
this.nostrAuth.pubkey &&
this.currentPhoto.pubkey === this.nostrAuth.pubkey
);
}
get canDeletePhoto() {
return (
this.isCreator && this.settings.experimentalEnablePhotoDeletion === true
);
}
bindKeyboard = modifier((element, [handler]) => {
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
});
@action
handleClose() {
if (this.args.onClose) {
@@ -46,6 +148,28 @@ export default class PhotoGallery extends Component {
}
}
@action
handleKeydown(e) {
if (!this.args.photos || this.args.photos.length === 0) return;
if (e.key === 'Escape') {
this.handleClose();
return;
}
const currentIndex = this.args.photos.indexOf(this.currentPhoto);
if (currentIndex === -1) return;
if (e.key === 'ArrowLeft' && currentIndex > 0) {
this.currentPhoto = this.args.photos[currentIndex - 1];
} else if (
e.key === 'ArrowRight' &&
currentIndex < this.args.photos.length - 1
) {
this.currentPhoto = this.args.photos[currentIndex + 1];
}
}
@action
async copyEventId(closeMenu) {
if (this.currentPhoto?.eventId) {
@@ -60,65 +184,109 @@ export default class PhotoGallery extends Component {
closeMenu();
}
deletePhotoTask = task(async (closeMenu) => {
if (
!confirm(
'Are you sure you want to delete this photo? This cannot be undone.'
)
) {
if (closeMenu) closeMenu();
return;
}
try {
const eventId = this.currentPhoto.eventId;
// Publish Nostr kind: 5 deletion event first so we don't end up with dead blossom links on a failure
const factory = new EventFactory({ signer: this.nostrAuth.signer });
const tags = [['e', eventId]];
if (this.currentPhoto.placeIdentifier) {
tags.push(['i', this.currentPhoto.placeIdentifier]);
}
const template = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
content: 'Deleted photo',
tags,
};
const event = await factory.sign(template);
await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event);
// Remove from local store by adding the kind 5 to it
this.nostrData.store.add(event);
// Now that the event is published, try to delete from Blossom
const hashRegex = /[0-9a-f]{64}/i;
if (this.currentPhoto.url) {
const match = this.currentPhoto.url.match(hashRegex);
if (match) {
try {
await this.blossom.delete(match[0]);
} catch (e) {
console.warn('Failed to delete main image from blossom:', e);
}
}
}
if (this.currentPhoto.thumbUrl) {
const match = this.currentPhoto.thumbUrl.match(hashRegex);
if (match) {
try {
await this.blossom.delete(match[0]);
} catch (e) {
console.warn('Failed to delete thumb image from blossom:', e);
}
}
}
this.toast.show('Photo deleted successfully');
if (closeMenu) closeMenu();
this.handleClose();
} catch (e) {
console.error('Failed to delete photo:', e);
this.toast.show('Failed to delete photo: ' + e.message);
if (closeMenu) closeMenu();
}
});
<template>
<div
class="photo-gallery-overlay"
role="dialog"
tabindex="-1"
{{on "click" this.handleBackgroundClick}}
>
{{! template-lint-disable no-invalid-interactive }}
<div class="photo-gallery-content">
<div class="actions-btn-container">
<DropdownMenu
@iconSize={{24}}
@triggerIcon="more-horizontal"
@iconColor="white"
as |closeMenu|
>
<button
class="dropdown-item"
type="button"
{{on "click" (fn this.copyEventId closeMenu)}}
>Copy Photo Event ID</button>
<button
class="dropdown-item"
type="button"
{{on "click" closeMenu}}
>Report Photo</button>
</DropdownMenu>
</div>
<button
type="button"
class="close-btn btn-text"
{{on "click" this.handleClose}}
aria-label="Close gallery"
title="Close"
>
<Icon @name="x" @size={{24}} @color="white" />
</button>
<div class="main-photo-container">
<PhotoCarousel
@variant="gallery-main"
@photos={{@photos}}
@scrollToEventId={{this.currentPhoto.eventId}}
@onVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@name={{@placeName}}
/>
</div>
<div class="thumbnail-strip-container">
<PhotoCarousel
@variant="gallery-thumbnails"
@photos={{@photos}}
@scrollToEventId={{this.currentPhoto.eventId}}
@onPhotoClick={{this.selectPhoto}}
@name={{@placeName}}
/>
</div>
</div>
</div>
{{#if this.isTesting}}
<GalleryContent
@handleBackgroundClick={{this.handleBackgroundClick}}
@bindKeyboard={{this.bindKeyboard}}
@handleKeydown={{this.handleKeydown}}
@copyEventId={{this.copyEventId}}
@canDeletePhoto={{this.canDeletePhoto}}
@deletePhotoTask={{this.deletePhotoTask}}
@handleClose={{this.handleClose}}
@photos={{@photos}}
@currentPhoto={{this.currentPhoto}}
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@placeName={{@placeName}}
@selectPhoto={{this.selectPhoto}}
/>
{{else}}
{{#in-element this.destinationElement}}
<GalleryContent
@handleBackgroundClick={{this.handleBackgroundClick}}
@bindKeyboard={{this.bindKeyboard}}
@handleKeydown={{this.handleKeydown}}
@copyEventId={{this.copyEventId}}
@canDeletePhoto={{this.canDeletePhoto}}
@deletePhotoTask={{this.deletePhotoTask}}
@handleClose={{this.handleClose}}
@photos={{@photos}}
@currentPhoto={{this.currentPhoto}}
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@placeName={{@placeName}}
@selectPhoto={{this.selectPhoto}}
/>
{{/in-element}}
{{/if}}
</template>
}

View File

@@ -55,10 +55,11 @@ export default class NostrDataService extends Service {
this._stopPersisting = persistEventsToCache(
this.store,
async (events) => {
// Only cache profiles, mailboxes, blossom servers, and place photos
// Only cache profiles, mailboxes, blossom servers, and place photos, and deletions
const toCache = events.filter(
(e) =>
e.kind === 0 ||
e.kind === 5 ||
e.kind === 10002 ||
e.kind === 10063 ||
e.kind === 360
@@ -215,7 +216,7 @@ export default class NostrDataService extends Service {
const cachedEvents = await this.cache.query([
{
kinds: [360],
kinds: [360, 5],
'#i': [entityId],
},
]);
@@ -236,7 +237,7 @@ export default class NostrDataService extends Service {
this.nostrRelay.pool
.request(this.activeReadRelays, [
{
kinds: [360],
kinds: [360, 5],
'#i': [entityId],
},
])

View File

@@ -9,6 +9,7 @@ const DEFAULT_SETTINGS = {
nostrPhotoFallbackUploads: false,
nostrReadRelays: null,
nostrWriteRelays: null,
experimentalEnablePhotoDeletion: false,
};
export default class SettingsService extends Service {
@@ -20,6 +21,8 @@ export default class SettingsService extends Service {
DEFAULT_SETTINGS.nostrPhotoFallbackUploads;
@tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays;
@tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays;
@tracked experimentalEnablePhotoDeletion =
DEFAULT_SETTINGS.experimentalEnablePhotoDeletion;
overpassApis = [
{
@@ -108,6 +111,8 @@ export default class SettingsService extends Service {
this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads;
this.nostrReadRelays = finalSettings.nostrReadRelays;
this.nostrWriteRelays = finalSettings.nostrWriteRelays;
this.experimentalEnablePhotoDeletion =
finalSettings.experimentalEnablePhotoDeletion;
// Save to ensure migrated settings are stored in the new format
this.saveSettings();
@@ -122,6 +127,7 @@ export default class SettingsService extends Service {
nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads,
nostrReadRelays: this.nostrReadRelays,
nostrWriteRelays: this.nostrWriteRelays,
experimentalEnablePhotoDeletion: this.experimentalEnablePhotoDeletion,
};
localStorage.setItem('marco:settings', JSON.stringify(settings));
}

View File

@@ -6,6 +6,7 @@ import featherCamera from 'feather-icons/dist/icons/camera.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import chevronLeft from 'feather-icons/dist/icons/chevron-left.svg?raw';
import chevronRight from 'feather-icons/dist/icons/chevron-right.svg?raw';
import alertTriangle from 'feather-icons/dist/icons/alert-triangle.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import database from 'feather-icons/dist/icons/database.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
@@ -146,6 +147,7 @@ const ICONS = {
climbing_wall: climbingWall,
check,
'alert-circle': alertCircle,
'alert-triangle': alertTriangle,
'classical-building': classicalBuilding,
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
'classical-building-with-flag': classicalBuildingWithFlag,

View File

@@ -38,6 +38,7 @@ export function parsePlacePhotos(events) {
let blurhash = null;
let isLandscape = false;
let aspectRatio = 16 / 9; // default
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
for (const tag of imeta.slice(1)) {
if (tag.startsWith('url ')) {
@@ -68,6 +69,7 @@ export function parsePlacePhotos(events) {
blurhash,
isLandscape,
aspectRatio,
placeIdentifier,
});
}
}

View File

@@ -42,6 +42,7 @@
<link rel="stylesheet" href="/app/styles/app.css">
</head>
<body>
<div id="modal-portal"></div>
<script type="module">
import Application from './app/app';
import environment from './app/config/environment';

View File

@@ -0,0 +1,344 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click, triggerKeyEvent } from '@ember/test-helpers';
import Service from '@ember/service';
import PhotoGallery from 'marco/components/photo-gallery';
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
import sinon from 'sinon';
class MockBlossomService extends Service {
async delete() {
return true;
}
}
class MockToastService extends Service {
show() {}
}
module('Integration | Component | photo-gallery', function (hooks) {
setupRenderingTest(hooks);
setupNostrMocks(hooks);
hooks.beforeEach(function () {
this.owner.register('service:blossom', MockBlossomService);
this.owner.register('service:toast', MockToastService);
this.blossom = this.owner.lookup('service:blossom');
this.nostrAuth = this.owner.lookup('service:nostrAuth');
this.nostrData = this.owner.lookup('service:nostrData');
this.nostrRelay = this.owner.lookup('service:nostrRelay');
this.toast = this.owner.lookup('service:toast');
this.settings = this.owner.lookup('service:settings');
this.photos = [
{
eventId: 'event1',
pubkey: 'userA',
placeIdentifier: 'osm:node:12345',
url: 'https://example.com/a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
thumbUrl:
'https://example.com/b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.jpg',
},
{
eventId: 'event2',
pubkey: 'userB',
placeIdentifier: 'osm:node:12345',
url: 'photo2.jpg',
},
];
});
hooks.afterEach(function () {
sinon.restore();
localStorage.removeItem('marco:settings');
});
test('it does not show delete button if user is not creator', async function (assert) {
this.nostrAuth.pubkey = 'userB'; // Different from photo1's pubkey
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>
);
// Open dropdown
await click('.dropdown-trigger-btn');
assert.dom('.dropdown-popover').exists('Dropdown opened');
assert
.dom('.dropdown-item.text-danger')
.doesNotExist('Delete button is hidden for non-creator');
});
test('it shows delete button if user is creator and setting is enabled', async function (assert) {
this.nostrAuth.pubkey = 'userA'; // Matches photo1's pubkey
this.settings.update('experimentalEnablePhotoDeletion', true); // Enable the setting
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>
);
// Open dropdown
await click('.dropdown-trigger-btn');
assert.dom('.dropdown-popover').exists('Dropdown opened');
assert
.dom('.dropdown-item.text-danger')
.exists('Delete button is visible for creator when setting is enabled');
});
test('it handles cancellation of deletion', async function (assert) {
this.nostrAuth.pubkey = 'userA';
this.settings.update('experimentalEnablePhotoDeletion', true);
this.selectedPhoto = this.photos[0];
const confirmStub = sinon.stub(window, 'confirm').returns(false);
const blossomSpy = sinon.spy(this.blossom, 'delete');
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
await click('.dropdown-trigger-btn');
await click('.dropdown-item.text-danger');
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
assert.ok(blossomSpy.notCalled, 'blossom.delete was NOT called');
});
test('it performs full deletion flow when confirmed', async function (assert) {
this.nostrAuth.pubkey = 'userA';
this.settings.update('experimentalEnablePhotoDeletion', true);
// Override the mock's getter just for this test
Object.defineProperty(this.nostrAuth, 'signer', {
configurable: true,
get: () => ({
signEvent: async (e) => ({
...e,
id: 'signed-id',
sig: 'sig',
pubkey: 'userA',
}),
getPublicKey: async () => 'userA',
}),
});
this.selectedPhoto = this.photos[0];
let closed = false;
this.handleClose = () => {
closed = true;
};
const confirmStub = sinon.stub(window, 'confirm').returns(true);
const blossomStub = sinon.stub(this.blossom, 'delete').resolves();
const publishStub = sinon.stub(this.nostrRelay, 'publish').resolves();
const storeStub = sinon.stub(this.nostrData.store, 'add');
const toastSpy = sinon.spy(this.toast, 'show');
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
@onClose={{this.handleClose}}
/>
</div>
</template>
);
await click('.dropdown-trigger-btn');
await click('.dropdown-item.text-danger');
assert.ok(confirmStub.calledOnce, 'confirmation dialog was shown');
// Check blossom deletions
assert.ok(
blossomStub.calledTwice,
'blossom.delete was called twice (main + thumb)'
);
assert.strictEqual(
blossomStub.firstCall.args[0],
'a3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
'extracted correct hash for main image'
);
assert.strictEqual(
blossomStub.secondCall.args[0],
'b3b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1',
'extracted correct hash for thumb image'
);
// Check Nostr kind 5
assert.ok(publishStub.calledOnce, 'nostrRelay.publish was called');
const publishedEvent = publishStub.firstCall.args[1];
assert.strictEqual(publishedEvent.kind, 5, 'published event is kind 5');
assert.deepEqual(
publishedEvent.tags[0],
['e', 'event1'],
'event tags reference the deleted photo'
);
assert.deepEqual(
publishedEvent.tags[1],
['i', 'osm:node:12345'],
'event tags include the place identifier'
);
// Check store update
assert.ok(storeStub.calledOnce, 'nostrData.store.add was called');
assert.strictEqual(
storeStub.firstCall.args[0].kind,
5,
'added kind 5 event to local store'
);
// Check UX
assert.ok(
toastSpy.calledWith('Photo deleted successfully'),
'success toast was shown'
);
assert.ok(closed, 'gallery was closed after deletion');
});
test('it copies event id to clipboard', async function (assert) {
this.nostrAuth.pubkey = 'userA';
this.selectedPhoto = this.photos[0];
const clipboardStub = sinon
.stub(navigator.clipboard, 'writeText')
.resolves();
const toastSpy = sinon.spy(this.toast, 'show');
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
await click('.dropdown-trigger-btn');
// Find the copy button (it should be the first one)
const items = document.querySelectorAll('.dropdown-item');
let copyBtn;
items.forEach((item) => {
if (item.textContent.includes('Copy Photo Event ID')) {
copyBtn = item;
}
});
await click(copyBtn);
assert.ok(clipboardStub.calledWith('event1'), 'copied correct event id');
assert.ok(
toastSpy.calledWith('Event ID copied to clipboard'),
'success toast was shown'
);
});
test('keyboard navigation changes photos', async function (assert) {
this.photos = [
{ eventId: 'event1', url: 'photo1.jpg' },
{ eventId: 'event2', url: 'photo2.jpg' },
{ eventId: 'event3', url: 'photo3.jpg' },
];
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>
);
// Let carousel settle
await new Promise((resolve) => setTimeout(resolve, 150));
// Right Arrow
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
await new Promise((resolve) => setTimeout(resolve, 150));
// Let's just assert that currentPhoto was updated internally, which trickles down.
// The actual DOM update for the main image might be tricky if the carousel relies on scroll events.
// We can at least check if the thumbnail selection changed, as that is directly driven by currentPhoto
assert
.dom('.thumbnail-strip-container .carousel-slide.active img')
.hasAttribute('data-src', 'photo2.jpg');
// Right Arrow again
await triggerKeyEvent(document, 'keydown', 'ArrowRight');
await new Promise((resolve) => setTimeout(resolve, 150));
assert
.dom('.thumbnail-strip-container .carousel-slide.active img')
.hasAttribute('data-src', 'photo3.jpg');
// Left Arrow
await triggerKeyEvent(document, 'keydown', 'ArrowLeft');
await new Promise((resolve) => setTimeout(resolve, 150));
assert
.dom('.thumbnail-strip-container .carousel-slide.active img')
.hasAttribute('data-src', 'photo2.jpg');
});
test('escape key closes gallery', async function (assert) {
this.selectedPhoto = this.photos[0];
let closed = false;
this.handleClose = () => {
closed = true;
};
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
@onClose={{this.handleClose}}
/>
</div>
</template>
);
await triggerKeyEvent(document, 'keydown', 'Escape');
assert.ok(closed, 'gallery was closed on escape key');
});
});