diff --git a/app/components/app-menu/settings.gjs b/app/components/app-menu/settings.gjs index 67e32c7..2271394 100644 --- a/app/components/app-menu/settings.gjs +++ b/app/components/app-menu/settings.gjs @@ -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 { + diff --git a/app/components/app-menu/settings/experimental.gjs b/app/components/app-menu/settings/experimental.gjs new file mode 100644 index 0000000..728fbe7 --- /dev/null +++ b/app/components/app-menu/settings/experimental.gjs @@ -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; + + +} diff --git a/app/components/app-menu/settings/nostr.gjs b/app/components/app-menu/settings/nostr.gjs index 850a3d4..29c7dc0 100644 --- a/app/components/app-menu/settings/nostr.gjs +++ b/app/components/app-menu/settings/nostr.gjs @@ -109,32 +109,6 @@ export default class AppMenuSettingsNostr extends Component { Nostr
-
- - -
-
    @@ -225,6 +199,32 @@ export default class AppMenuSettingsNostr extends Component { {{/if}}
+
+ + +
+
+ {{yield}} +
+
+; + 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 { } } diff --git a/app/components/photo-carousel.gjs b/app/components/photo-carousel.gjs index 866e764..d901dfb 100644 --- a/app/components/photo-carousel.gjs +++ b/app/components/photo-carousel.gjs @@ -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; diff --git a/app/components/photo-gallery.gjs b/app/components/photo-gallery.gjs index 7bf9aca..34e6e27 100644 --- a/app/components/photo-gallery.gjs +++ b/app/components/photo-gallery.gjs @@ -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 = ; 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(); + } + }); + } diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js index fd6c256..c1fe03d 100644 --- a/app/services/nostr-data.js +++ b/app/services/nostr-data.js @@ -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], }, ]) diff --git a/app/services/settings.js b/app/services/settings.js index 669a4ed..a198166 100644 --- a/app/services/settings.js +++ b/app/services/settings.js @@ -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)); } diff --git a/app/utils/icons.js b/app/utils/icons.js index 9ffb642..0bf5cb2 100644 --- a/app/utils/icons.js +++ b/app/utils/icons.js @@ -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, diff --git a/app/utils/nostr.js b/app/utils/nostr.js index fae34c4..aad664e 100644 --- a/app/utils/nostr.js +++ b/app/utils/nostr.js @@ -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, }); } } diff --git a/index.html b/index.html index 1dff21f..30eae7e 100644 --- a/index.html +++ b/index.html @@ -42,6 +42,7 @@ +