diff --git a/app/components/app-header.gjs b/app/components/app-header.gjs index cbc771c..0c666ca 100644 --- a/app/components/app-header.gjs +++ b/app/components/app-header.gjs @@ -7,10 +7,14 @@ import Icon from '#components/icon'; import UserMenu from '#components/user-menu'; import SearchBox from '#components/search-box'; import CategoryChips from '#components/category-chips'; +import { and } from 'ember-truth-helpers'; +import cachedImage from '../modifiers/cached-image'; export default class AppHeaderComponent extends Component { @service storage; @service settings; + @service nostrAuth; + @service nostrData; @tracked isUserMenuOpen = false; @tracked searchQuery = ''; @@ -64,9 +68,19 @@ export default class AppHeaderComponent extends Component { aria-label="User Menu" {{on "click" this.toggleUserMenu}} > -
- -
+ {{#if + (and this.nostrAuth.isConnected this.nostrData.profile.picture) + }} + User Avatar + {{else}} +
+ +
+ {{/if}} {{#if this.isUserMenuOpen}} diff --git a/app/components/app-menu/settings.gjs b/app/components/app-menu/settings.gjs index 1e5f909..67e32c7 100644 --- a/app/components/app-menu/settings.gjs +++ b/app/components/app-menu/settings.gjs @@ -1,31 +1,22 @@ import Component from '@glimmer/component'; import { on } from '@ember/modifier'; -import { service } from '@ember/service'; import { action } from '@ember/object'; +import { service } from '@ember/service'; import Icon from '#components/icon'; -import eq from 'ember-truth-helpers/helpers/eq'; +import AppMenuSettingsMapUi from './settings/map-ui'; +import AppMenuSettingsApis from './settings/apis'; +import AppMenuSettingsNostr from './settings/nostr'; export default class AppMenuSettings extends Component { @service settings; @action - updateApi(event) { - this.settings.updateOverpassApi(event.target.value); - } + updateSetting(key, event) { + let value = event.target.value; + if (value === 'true') value = true; + if (value === 'false') value = false; - @action - toggleKinetic(event) { - this.settings.updateMapKinetic(event.target.value === 'true'); - } - - @action - toggleQuickSearchButtons(event) { - this.settings.updateShowQuickSearchButtons(event.target.value === 'true'); - } - - @action - updatePhotonApi(event) { - this.settings.updatePhotonApi(event.target.value); + this.settings.update(key, value); } diff --git a/app/components/app-menu/settings/apis.gjs b/app/components/app-menu/settings/apis.gjs new file mode 100644 index 0000000..8231b34 --- /dev/null +++ b/app/components/app-menu/settings/apis.gjs @@ -0,0 +1,59 @@ +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'; +import eq from 'ember-truth-helpers/helpers/eq'; + +export default class AppMenuSettingsApis extends Component { + @service settings; + + +} diff --git a/app/components/app-menu/settings/map-ui.gjs b/app/components/app-menu/settings/map-ui.gjs new file mode 100644 index 0000000..451c407 --- /dev/null +++ b/app/components/app-menu/settings/map-ui.gjs @@ -0,0 +1,66 @@ +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 AppMenuSettingsMapUi extends Component { + @service settings; + + +} diff --git a/app/components/app-menu/settings/nostr.gjs b/app/components/app-menu/settings/nostr.gjs new file mode 100644 index 0000000..d332111 --- /dev/null +++ b/app/components/app-menu/settings/nostr.gjs @@ -0,0 +1,219 @@ +import Component from '@glimmer/component'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { service } from '@ember/service'; +import { fn } from '@ember/helper'; +import Icon from '#components/icon'; +import { normalizeRelayUrl } from '../../../utils/nostr'; + +const stripProtocol = (url) => (url ? url.replace(/^wss?:\/\//, '') : ''); + +export default class AppMenuSettingsNostr extends Component { + @service settings; + @service nostrData; + + @tracked newReadRelay = ''; + @tracked newWriteRelay = ''; + + @action + updateNewReadRelay(event) { + this.newReadRelay = event.target.value; + } + + @action + updateNewWriteRelay(event) { + this.newWriteRelay = event.target.value; + } + + @action + addReadRelay() { + const url = normalizeRelayUrl(this.newReadRelay); + if (!url) return; + + const current = + this.settings.nostrReadRelays || this.nostrData.defaultReadRelays; + const set = new Set([...current, url]); + this.settings.update('nostrReadRelays', Array.from(set)); + this.newReadRelay = ''; + } + + @action + removeReadRelay(url) { + const current = + this.settings.nostrReadRelays || this.nostrData.defaultReadRelays; + const filtered = current.filter((r) => r !== url); + this.settings.update('nostrReadRelays', filtered); + } + + @action + handleReadRelayKeydown(event) { + if (event.key === 'Enter') { + this.addReadRelay(); + } + } + + @action + handleWriteRelayKeydown(event) { + if (event.key === 'Enter') { + this.addWriteRelay(); + } + } + + @action + resetReadRelays() { + this.settings.update('nostrReadRelays', null); + } + + @action + addWriteRelay() { + const url = normalizeRelayUrl(this.newWriteRelay); + if (!url) return; + + const current = + this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays; + const set = new Set([...current, url]); + this.settings.update('nostrWriteRelays', Array.from(set)); + this.newWriteRelay = ''; + } + + @action + removeWriteRelay(url) { + const current = + this.settings.nostrWriteRelays || this.nostrData.defaultWriteRelays; + const filtered = current.filter((r) => r !== url); + this.settings.update('nostrWriteRelays', filtered); + } + + @action + resetWriteRelays() { + this.settings.update('nostrWriteRelays', null); + } + + +} diff --git a/app/components/blurhash.gjs b/app/components/blurhash.gjs new file mode 100644 index 0000000..bb9dd7d --- /dev/null +++ b/app/components/blurhash.gjs @@ -0,0 +1,37 @@ +import Component from '@glimmer/component'; +import { modifier } from 'ember-modifier'; +import { decode } from 'blurhash'; + +export default class Blurhash extends Component { + renderBlurhash = modifier((canvas, [hash, width, height]) => { + if (!hash || !canvas) return; + + // Default size to a small multiple of aspect ratio to save CPU + // 32x18 is a good balance of speed vs quality for 16:9 + const renderWidth = width || 32; + const renderHeight = height || 18; + + canvas.width = renderWidth; + canvas.height = renderHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + try { + const pixels = decode(hash, renderWidth, renderHeight); + const imageData = ctx.createImageData(renderWidth, renderHeight); + imageData.data.set(pixels); + ctx.putImageData(imageData, 0, 0); + } catch (e) { + console.warn('Failed to decode blurhash:', e.message || e); + } + }); + + +} diff --git a/app/components/map.gjs b/app/components/map.gjs index 4e48050..5b0877b 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -27,6 +27,7 @@ export default class MapComponent extends Component { @service mapUi; @service router; @service settings; + @service nostrData; mapInstance; bookmarkSource; @@ -1033,7 +1034,7 @@ export default class MapComponent extends Component { } handleMapMove = async () => { - if (!this.mapInstance) return; + if (!this.mapInstance || this.isDestroying || this.isDestroyed) return; const view = this.mapInstance.getView(); const center = toLonLat(view.getCenter()); @@ -1078,6 +1079,7 @@ export default class MapComponent extends Component { const bbox = { minLat, minLon, maxLat, maxLon }; this.mapUi.updateBounds(bbox); await this.storage.loadPlacesInBounds(bbox); + this.nostrData.loadPlacesInBounds(bbox); this.loadBookmarks(this.storage.placesInView); // Persist view to localStorage diff --git a/app/components/modal.gjs b/app/components/modal.gjs new file mode 100644 index 0000000..e9751e3 --- /dev/null +++ b/app/components/modal.gjs @@ -0,0 +1,43 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import Icon from './icon'; + +export default class Modal extends Component { + @action + stopProp(e) { + e.stopPropagation(); + } + + @action + close() { + if (this.args.onClose) { + this.args.onClose(); + } + } + + +} diff --git a/app/components/nostr-connect.gjs b/app/components/nostr-connect.gjs new file mode 100644 index 0000000..de728ac --- /dev/null +++ b/app/components/nostr-connect.gjs @@ -0,0 +1,93 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import { on } from '@ember/modifier'; +import { eq } from 'ember-truth-helpers'; +import qrCode from '../modifiers/qr-code'; + +export default class NostrConnectComponent extends Component { + @service nostrAuth; + @service toast; + + get hasExtension() { + return typeof window !== 'undefined' && typeof window.nostr !== 'undefined'; + } + + @action + async connectExtension() { + try { + await this.nostrAuth.connectWithExtension(); + this.toast.show('Nostr connected successfully'); + if (this.args.onConnect) { + this.args.onConnect(); + } + } catch (e) { + console.error(e); + alert(e.message); + } + } + + @action + async connectApp() { + try { + await this.nostrAuth.connectWithApp(); + this.toast.show('Nostr connected successfully'); + if (this.args.onConnect) { + this.args.onConnect(); + } + } catch (e) { + console.error(e); + alert(e.message); + } + } + + +} diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index 0377e2c..1ea5dee 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -6,17 +6,66 @@ import { humanizeOsmTag } from '../utils/format-text'; import { getLocalizedName, getPlaceType } from '../utils/osm'; import { mapToStorageSchema } from '../utils/place-mapping'; import { getSocialInfo } from '../utils/social-links'; +import { parsePlacePhotos } from '../utils/nostr'; import Icon from '../components/icon'; import PlaceEditForm from './place-edit-form'; import PlaceListsManager from './place-lists-manager'; +import PlacePhotoUpload from './place-photo-upload'; +import NostrConnect from './nostr-connect'; +import Modal from './modal'; +import PlacePhotosCarousel from './place-photos-carousel'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; export default class PlaceDetails extends Component { @service storage; + @service nostrAuth; + @service nostrData; @tracked isEditing = false; @tracked showLists = false; + @tracked isPhotoUploadModalOpen = false; + @tracked isNostrConnectModalOpen = false; + @tracked newlyUploadedPhotoId = null; + + @action + openPhotoUploadModal(e) { + if (e) { + e.preventDefault(); + } + if (!this.nostrAuth.isConnected) { + this.isNostrConnectModalOpen = true; + } else { + this.isPhotoUploadModalOpen = true; + } + } + + @action + closePhotoUploadModal(eventId) { + this.isPhotoUploadModalOpen = false; + if (typeof eventId === 'string') { + this.newlyUploadedPhotoId = eventId; + + // Allow DOM to update first, then scroll to the top to show the new photo in the carousel + setTimeout(() => { + const sidebar = document.querySelector('.sidebar-content'); + if (sidebar) { + sidebar.scrollTop = 0; + } + }, 50); + } + } + + @action + closeNostrConnectModal() { + this.isNostrConnectModalOpen = false; + } + + @action + onNostrConnected() { + this.isNostrConnectModalOpen = false; + this.isPhotoUploadModalOpen = true; + } get isSaved() { return this.storage.isPlaceSaved(this.place.id || this.place.osmId); @@ -42,6 +91,16 @@ export default class PlaceDetails extends Component { return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place'; } + get photos() { + const rawPhotos = this.nostrData.placePhotos; + const parsedPhotos = parsePlacePhotos(rawPhotos); + + return parsedPhotos.map((photo) => ({ + ...photo, + style: htmlSafe(`--slide-ratio: ${photo.aspectRatio};`), + })); + } + @action startEditing() { if (!this.isSaved) return; // Only allow editing saved places @@ -305,6 +364,12 @@ export default class PlaceDetails extends Component { @onCancel={{this.cancelEditing}} /> {{else}} +

{{this.name}}

{{this.type}} @@ -500,6 +565,38 @@ export default class PlaceDetails extends Component { {{/if}} + + {{#if this.osmUrl}} +

+

+ + + + +

+
+ {{/if}} + + {{#if this.isPhotoUploadModalOpen}} + + + + {{/if}} + + {{#if this.isNostrConnectModalOpen}} + + + + {{/if}} } diff --git a/app/components/place-photo-upload-item.gjs b/app/components/place-photo-upload-item.gjs new file mode 100644 index 0000000..0194e1b --- /dev/null +++ b/app/components/place-photo-upload-item.gjs @@ -0,0 +1,154 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import Icon from '#components/icon'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { isMobile } from '../utils/device'; +import Blurhash from './blurhash'; + +const MAX_IMAGE_DIMENSION = 1920; +const IMAGE_QUALITY = 0.94; +const MAX_THUMBNAIL_DIMENSION = 350; +const THUMBNAIL_QUALITY = 0.9; + +export default class PlacePhotoUploadItem extends Component { + @service blossom; + @service imageProcessor; + @service toast; + + @tracked thumbnailUrl = ''; + @tracked blurhash = ''; + @tracked error = ''; + + constructor() { + super(...arguments); + if (this.args.file) { + this.thumbnailUrl = URL.createObjectURL(this.args.file); + this.uploadTask.perform(this.args.file); + } + } + + willDestroy() { + super.willDestroy(...arguments); + if (this.thumbnailUrl) { + URL.revokeObjectURL(this.thumbnailUrl); + } + } + + @action + showErrorToast() { + if (this.error) { + this.toast.show(this.error); + } + } + + uploadTask = task(async (file) => { + this.error = ''; + try { + // 1. Process main image and generate blurhash in worker + const mainData = await this.imageProcessor.process( + file, + MAX_IMAGE_DIMENSION, + IMAGE_QUALITY, + true // computeBlurhash + ); + + this.blurhash = mainData.blurhash; + + // 2. Process thumbnail (no blurhash needed) + const thumbData = await this.imageProcessor.process( + file, + MAX_THUMBNAIL_DIMENSION, + THUMBNAIL_QUALITY, + false + ); + + // 3. Upload main image + // 4. Upload thumbnail + let mainResult, thumbResult; + const isMobileDevice = isMobile(); + + if (isMobileDevice) { + // Mobile: sequential uploads to preserve bandwidth and memory + mainResult = await this.blossom.upload(mainData.blob, { + sequential: true, + }); + thumbResult = await this.blossom.upload(thumbData.blob, { + sequential: true, + }); + } else { + // Desktop: concurrent uploads + const mainUploadPromise = this.blossom.upload(mainData.blob); + const thumbUploadPromise = this.blossom.upload(thumbData.blob); + + [mainResult, thumbResult] = await Promise.all([ + mainUploadPromise, + thumbUploadPromise, + ]); + } + + if (this.args.onSuccess) { + this.args.onSuccess({ + file, + url: mainResult.url, + fallbackUrls: mainResult.fallbackUrls, + thumbUrl: thumbResult.url, + blurhash: mainData.blurhash, + type: 'image/jpeg', + dim: mainData.dim, + hash: mainResult.hash, + thumbHash: thumbResult.hash, + }); + } + } catch (e) { + this.error = e.message; + } + }); + + +} diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs new file mode 100644 index 0000000..cda50bb --- /dev/null +++ b/app/components/place-photo-upload.gjs @@ -0,0 +1,265 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import { on } from '@ember/modifier'; +import { EventFactory } from 'applesauce-core'; +import { task } from 'ember-concurrency'; +import Geohash from 'latlon-geohash'; +import PlacePhotoUploadItem from './place-photo-upload-item'; +import Icon from '#components/icon'; +import { or, not } from 'ember-truth-helpers'; + +export default class PlacePhotoUpload extends Component { + @service nostrAuth; + @service nostrRelay; + @service nostrData; + @service blossom; + @service toast; + + @tracked file = null; + @tracked uploadedPhoto = null; + @tracked status = ''; + @tracked error = ''; + @tracked isPublishing = false; + @tracked isDragging = false; + + get place() { + return this.args.place || {}; + } + + get title() { + return this.place.title || 'this place'; + } + + get allUploaded() { + return ( + this.file && this.uploadedPhoto && this.file === this.uploadedPhoto.file + ); + } + + @action + handleFileSelect(event) { + this.addFile(event.target.files[0]); + event.target.value = ''; // Reset input + } + + @action + handleDragOver(event) { + event.preventDefault(); + this.isDragging = true; + } + + @action + handleDragLeave(event) { + event.preventDefault(); + this.isDragging = false; + } + + @action + handleDrop(event) { + event.preventDefault(); + this.isDragging = false; + if (event.dataTransfer.files.length > 0) { + this.addFile(event.dataTransfer.files[0]); + } + } + + addFile(file) { + if (!file || !file.type.startsWith('image/')) { + this.error = 'Please select a valid image file.'; + return; + } + this.error = ''; + // If a photo was already uploaded but not published, delete it from the server + if (this.uploadedPhoto) { + this.deletePhotoTask.perform(this.uploadedPhoto); + } + this.file = file; + this.uploadedPhoto = null; + } + + @action + handleUploadSuccess(photoData) { + this.uploadedPhoto = photoData; + } + + @action + removeFile() { + if (this.uploadedPhoto) { + this.deletePhotoTask.perform(this.uploadedPhoto); + } + this.file = null; + this.uploadedPhoto = null; + } + + deletePhotoTask = task(async (photoData) => { + try { + if (photoData.hash) { + await this.blossom.delete(photoData.hash); + } + if (photoData.thumbHash) { + await this.blossom.delete(photoData.thumbHash); + } + } catch (e) { + this.toast.show(`Failed to delete photo from server: ${e.message}`, 5000); + } + }); + + @action + async publish() { + if (!this.nostrAuth.isConnected) { + this.error = 'You must connect Nostr first.'; + return; + } + + if (!this.allUploaded) { + this.error = 'Please wait for all photos to finish uploading.'; + return; + } + + const { osmId, lat, lon } = this.place; + const osmType = this.place.osmType || 'node'; + + if (!osmId) { + this.error = 'This place does not have a valid OSM ID.'; + return; + } + + this.status = 'Publishing event...'; + this.error = ''; + this.isPublishing = true; + + try { + const factory = new EventFactory({ signer: this.nostrAuth.signer }); + + const tags = [['i', `osm:${osmType}:${osmId}`]]; + + if (lat && lon) { + tags.push(['g', Geohash.encode(lat, lon, 4)]); + tags.push(['g', Geohash.encode(lat, lon, 6)]); + tags.push(['g', Geohash.encode(lat, lon, 7)]); + tags.push(['g', Geohash.encode(lat, lon, 9)]); + } + + const photo = this.uploadedPhoto; + const imeta = ['imeta', `url ${photo.url}`]; + + imeta.push(`m ${photo.type}`); + + if (photo.dim) { + imeta.push(`dim ${photo.dim}`); + } + + imeta.push('alt A photo of a place'); + + if (photo.fallbackUrls && photo.fallbackUrls.length > 0) { + for (const fallbackUrl of photo.fallbackUrls) { + imeta.push(`fallback ${fallbackUrl}`); + } + } + + if (photo.thumbUrl) { + imeta.push(`thumb ${photo.thumbUrl}`); + } + + if (photo.blurhash) { + imeta.push(`blurhash ${photo.blurhash}`); + } + + tags.push(imeta); + + // NIP-XX draft Place Photo event + const template = { + kind: 360, + content: '', + tags, + }; + + if (!template.created_at) { + template.created_at = Math.floor(Date.now() / 1000); + } + + const event = await factory.sign(template); + await this.nostrRelay.publish(this.nostrData.activeWriteRelays, event); + this.nostrData.store.add(event); + + this.toast.show('Photo published successfully'); + this.status = ''; + + // Clear out the file so user can upload more or be done + this.file = null; + this.uploadedPhoto = null; + + if (this.args.onClose) { + this.args.onClose(event.id); + } + } catch (e) { + this.error = 'Failed to publish: ' + e.message; + this.status = ''; + } finally { + this.isPublishing = false; + } + } + + +} diff --git a/app/components/place-photos-carousel.gjs b/app/components/place-photos-carousel.gjs new file mode 100644 index 0000000..f5e4687 --- /dev/null +++ b/app/components/place-photos-carousel.gjs @@ -0,0 +1,188 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import Blurhash from './blurhash'; +import Icon from './icon'; +import fadeInImage from '../modifiers/fade-in-image'; +import { on } from '@ember/modifier'; +import { modifier } from 'ember-modifier'; + +export default class PlacePhotosCarousel extends Component { + @tracked canScrollLeft = false; + @tracked canScrollRight = false; + + carouselElement = null; + + get photos() { + return this.args.photos || []; + } + + get showChevrons() { + return this.photos.length > 1; + } + + get cannotScrollLeft() { + return !this.canScrollLeft; + } + + get cannotScrollRight() { + return !this.canScrollRight; + } + + lastResetKey = null; + + resetScrollPosition = modifier((element, [resetKey]) => { + if (resetKey !== undefined && resetKey !== this.lastResetKey) { + this.lastResetKey = resetKey; + element.scrollLeft = 0; + setTimeout(() => this.updateScrollState(), 50); + } + }); + + scrollToNewPhoto = modifier((element, [eventId]) => { + if (eventId && eventId !== this.lastEventId) { + this.lastEventId = eventId; + // Allow DOM to update first since the photo was *just* added to the store + setTimeout(() => { + const targetSlide = element.querySelector( + `[data-event-id="${eventId}"]` + ); + if (targetSlide) { + element.scrollLeft = targetSlide.offsetLeft; + } + }, 100); + } + }); + + setupCarousel = modifier((element) => { + this.carouselElement = element; + + // Defer the initial calculation slightly to ensure CSS and images have applied + setTimeout(() => { + this.updateScrollState(); + }, 50); + + let resizeObserver; + if (window.ResizeObserver) { + resizeObserver = new ResizeObserver(() => this.updateScrollState()); + resizeObserver.observe(element); + } + + return () => { + if (resizeObserver) { + resizeObserver.unobserve(element); + } + }; + }); + + @action + updateScrollState() { + if (!this.carouselElement) return; + + const { scrollLeft, scrollWidth, clientWidth } = this.carouselElement; + // tolerance of 1px for floating point rounding issues + this.canScrollLeft = scrollLeft > 1; + this.canScrollRight = scrollLeft + clientWidth < scrollWidth - 1; + } + + @action + scrollLeft() { + if (!this.carouselElement) return; + this.carouselElement.scrollBy({ + left: -this.carouselElement.clientWidth, + behavior: 'smooth', + }); + } + + @action + scrollRight() { + if (!this.carouselElement) return; + this.carouselElement.scrollBy({ + left: this.carouselElement.clientWidth, + behavior: 'smooth', + }); + } + + +} diff --git a/app/components/places-sidebar.gjs b/app/components/places-sidebar.gjs index bf39204..e3bcc2d 100644 --- a/app/components/places-sidebar.gjs +++ b/app/components/places-sidebar.gjs @@ -14,6 +14,7 @@ export default class PlacesSidebar extends Component { @service storage; @service router; @service mapUi; + @service nostrData; @action createNewPlace() { @@ -149,9 +150,17 @@ export default class PlacesSidebar extends Component { return !qp.q && !qp.category && qp.lat && qp.lon; } + get hasHeaderPhoto() { + return ( + this.args.selectedPlace && + this.nostrData.placePhotos && + this.nostrData.placePhotos.length > 0 + ); + } + } diff --git a/app/modifiers/cached-image.js b/app/modifiers/cached-image.js new file mode 100644 index 0000000..20e1674 --- /dev/null +++ b/app/modifiers/cached-image.js @@ -0,0 +1,64 @@ +import { modifier } from 'ember-modifier'; + +const CACHE_NAME = 'nostr-image-cache-v1'; + +export default modifier((element, [url]) => { + let objectUrl = null; + + async function loadImage() { + if (!url) { + element.src = ''; + return; + } + + try { + const cache = await caches.open(CACHE_NAME); + const cachedResponse = await cache.match(url); + + if (cachedResponse) { + const blob = await cachedResponse.blob(); + objectUrl = URL.createObjectURL(blob); + element.src = objectUrl; + return; + } + + // Not in cache, try to fetch it + // eslint-disable-next-line warp-drive/no-external-request-patterns + const response = await fetch(url, { + mode: 'cors', // Required to read the blob for caching + credentials: 'omit', + }); + + if (response.ok) { + // Clone the response before reading the blob because a response stream can only be read once + const cacheResponse = response.clone(); + await cache.put(url, cacheResponse); + + const blob = await response.blob(); + objectUrl = URL.createObjectURL(blob); + element.src = objectUrl; + } else { + // Fetch failed (e.g. 404), fallback to standard browser loading + element.src = url; + } + } catch (error) { + // CORS errors or network failures will land here. + // Fallback to letting the browser handle it directly. + console.warn( + `Failed to cache image ${url}, falling back to standard src`, + error + ); + element.src = url; + } + } + + loadImage(); + + // Cleanup: revoke the object URL when the element is destroyed or the URL changes + return () => { + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + objectUrl = null; + } + }; +}); diff --git a/app/modifiers/fade-in-image.js b/app/modifiers/fade-in-image.js new file mode 100644 index 0000000..119b415 --- /dev/null +++ b/app/modifiers/fade-in-image.js @@ -0,0 +1,75 @@ +import { modifier } from 'ember-modifier'; + +export default modifier((element, [url]) => { + if (!url) return; + + // Remove classes when URL changes + element.classList.remove('loaded'); + element.classList.remove('loaded-instant'); + + let observer; + + const handleLoad = () => { + // Only apply the fade-in animation if it wasn't already loaded instantly + if (!element.classList.contains('loaded-instant')) { + element.classList.add('loaded'); + } + }; + + element.addEventListener('load', handleLoad); + + const loadWhenVisible = (entries, obs) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + // Stop observing once we start loading + obs.unobserve(element); + + // Check if the image is already in the browser cache + // Create an off-DOM image to reliably check cache status + // without waiting for the actual DOM element to load it + const img = new Image(); + img.src = url; + + if (img.complete) { + // Already in browser cache, skip the animation + element.classList.add('loaded-instant'); + } + + // If this image is inside a tag, we also need to swap tags + const parent = element.parentElement; + if (parent && parent.tagName === 'PICTURE') { + const sources = parent.querySelectorAll('source'); + sources.forEach((source) => { + if (source.dataset.srcset) { + source.srcset = source.dataset.srcset; + } + }); + } + + // Swap data-src to src to trigger the actual network fetch (or render from cache) + if (element.dataset.src) { + element.src = element.dataset.src; + } else { + // Fallback if data-src wasn't used but the modifier was called + element.src = url; + } + } + }); + }; + + // Setup Intersection Observer to only load when the image enters the viewport + observer = new IntersectionObserver(loadWhenVisible, { + root: null, // Use the viewport as the root + rootMargin: '100px 100%', // Load one full viewport width ahead/behind + threshold: 0, // Trigger immediately when any part enters the expanded margin + }); + + observer.observe(element); + + return () => { + element.removeEventListener('load', handleLoad); + if (observer) { + observer.disconnect(); + } + }; +}); diff --git a/app/modifiers/qr-code.js b/app/modifiers/qr-code.js new file mode 100644 index 0000000..ea7bc52 --- /dev/null +++ b/app/modifiers/qr-code.js @@ -0,0 +1,17 @@ +import { modifier } from 'ember-modifier'; +import QRCode from 'qrcode'; + +export default modifier((element, [text]) => { + if (text) { + QRCode.toCanvas(element, text, { + width: 256, + margin: 2, + color: { + dark: '#000000', + light: '#ffffff', + }, + }).catch((err) => { + console.error('Failed to generate QR code', err); + }); + } +}); diff --git a/app/routes/place.js b/app/routes/place.js index efc5231..5742974 100644 --- a/app/routes/place.js +++ b/app/routes/place.js @@ -9,25 +9,47 @@ export default class PlaceRoute extends Route { async model(params) { const id = params.place_id; + let type, osmId; + let isExplicitOsm = false; + if ( id.startsWith('osm:node:') || id.startsWith('osm:way:') || id.startsWith('osm:relation:') ) { - const [, type, osmId] = id.split(':'); + isExplicitOsm = true; + [, type, osmId] = id.split(':'); console.debug(`Fetching explicit OSM ${type}:`, osmId); - return this.loadOsmPlace(osmId, type); + } + + let backgroundFetchPromise = null; + if (isExplicitOsm) { + backgroundFetchPromise = this.loadOsmPlace(osmId, type); } await this.waitForSync(); - let bookmark = this.storage.findPlaceById(id); + let lookupId = isExplicitOsm ? osmId : id; + let bookmark = this.storage.findPlaceById(lookupId); + + // Ensure type matches if we are looking up by osmId + if (bookmark && isExplicitOsm && bookmark.osmType !== type) { + bookmark = null; // Type mismatch, not the same OSM object + } if (bookmark) { console.debug('Found in bookmarks:', bookmark.title); return bookmark; } + if (isExplicitOsm) { + console.debug( + `Not in bookmarks, using explicitly fetched OSM ${type}:`, + osmId + ); + return await backgroundFetchPromise; + } + console.warn('Not in bookmarks:', id); return null; } @@ -119,14 +141,14 @@ export default class PlaceRoute extends Route { } serialize(model) { - // If the model is a saved bookmark, use its ID - if (model.id) { - return { place_id: model.id }; - } - // If it's an OSM POI, use the explicit format + // If it's an OSM POI, use the explicit format first if (model.osmId && model.osmType) { return { place_id: `osm:${model.osmType}:${model.osmId}` }; } + // If the model is a saved bookmark (and not OSM, e.g. custom place), use its ID + if (model.id) { + return { place_id: model.id }; + } // Fallback return { place_id: model.osmId }; } diff --git a/app/services/blossom.js b/app/services/blossom.js new file mode 100644 index 0000000..4a189a0 --- /dev/null +++ b/app/services/blossom.js @@ -0,0 +1,205 @@ +import Service, { service } from '@ember/service'; +import { EventFactory } from 'applesauce-core'; +import { sha256 } from '@noble/hashes/sha2.js'; + +export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build'; + +function bufferToHex(buffer) { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function getBlossomUrl(serverUrl, path) { + let url = serverUrl || DEFAULT_BLOSSOM_SERVER; + if (url.endsWith('/')) { + url = url.slice(0, -1); + } + return path.startsWith('/') ? `${url}${path}` : `${url}/${path}`; +} + +export default class BlossomService extends Service { + @service nostrAuth; + @service nostrData; + @service settings; + + get servers() { + const servers = this.nostrData.blossomServers; + const allServers = servers.length ? servers : [DEFAULT_BLOSSOM_SERVER]; + + if (!this.settings.nostrPhotoFallbackUploads) { + return [allServers[0]]; + } + + return allServers; + } + + async _getAuthHeader(action, hash, serverUrl) { + const factory = new EventFactory({ signer: this.nostrAuth.signer }); + const now = Math.floor(Date.now() / 1000); + const serverHostname = new URL(serverUrl).hostname; + + const authTemplate = { + kind: 24242, + created_at: now, + content: action === 'upload' ? 'Upload photo for place' : 'Delete photo', + tags: [ + ['t', action], + ['x', hash], + ['expiration', String(now + 3600)], + ['server', serverHostname], + ], + }; + + const authEvent = await factory.sign(authTemplate); + const base64 = btoa(JSON.stringify(authEvent)); + const base64url = base64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return `Nostr ${base64url}`; + } + + async _uploadToServer(file, hash, serverUrl) { + const uploadUrl = getBlossomUrl(serverUrl, 'upload'); + const authHeader = await this._getAuthHeader('upload', hash, serverUrl); + + // eslint-disable-next-line warp-drive/no-external-request-patterns + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { + Authorization: authHeader, + 'X-SHA-256': hash, + }, + body: file, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Upload failed (${response.status}): ${text}`); + } + + return response.json(); + } + + async upload(file, options = { sequential: false }) { + if (!this.nostrAuth.isConnected) throw new Error('Not connected'); + + const buffer = await file.arrayBuffer(); + let hashBuffer; + + if ( + typeof crypto !== 'undefined' && + crypto.subtle && + crypto.subtle.digest + ) { + hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + } else { + hashBuffer = sha256(new Uint8Array(buffer)); + } + + const payloadHash = bufferToHex(hashBuffer); + + const servers = this.servers; + const mainServer = servers[0]; + const fallbackServers = servers.slice(1); + + const fallbackUrls = []; + let mainResult; + + if (options.sequential) { + // Sequential upload logic + mainResult = await this._uploadToServer(file, payloadHash, mainServer); + + for (const serverUrl of fallbackServers) { + try { + const result = await this._uploadToServer( + file, + payloadHash, + serverUrl + ); + fallbackUrls.push(result.url); + } catch (error) { + console.warn(`Fallback upload to ${serverUrl} failed:`, error); + } + } + } else { + // Concurrent upload logic + const mainPromise = this._uploadToServer(file, payloadHash, mainServer); + const fallbackPromises = fallbackServers.map((serverUrl) => + this._uploadToServer(file, payloadHash, serverUrl) + ); + + // Main server MUST succeed + mainResult = await mainPromise; + + // Fallback servers can fail, but we log the warnings + const fallbackResults = await Promise.allSettled(fallbackPromises); + + for (let i = 0; i < fallbackResults.length; i++) { + const result = fallbackResults[i]; + if (result.status === 'fulfilled') { + fallbackUrls.push(result.value.url); + } else { + console.warn( + `Fallback upload to ${fallbackServers[i]} failed:`, + result.reason + ); + } + } + } + + return { + url: mainResult.url, + fallbackUrls, + hash: payloadHash, + type: file.type, + }; + } + + async _deleteFromServer(hash, serverUrl) { + const deleteUrl = getBlossomUrl(serverUrl, hash); + const authHeader = await this._getAuthHeader('delete', hash, serverUrl); + + // eslint-disable-next-line warp-drive/no-external-request-patterns + const response = await fetch(deleteUrl, { + method: 'DELETE', + headers: { + Authorization: authHeader, + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || response.statusText); + } + } + + async delete(hash) { + if (!this.nostrAuth.isConnected) throw new Error('Not connected'); + + const servers = this.servers; + const mainServer = servers[0]; + const fallbackServers = servers.slice(1); + + const mainPromise = this._deleteFromServer(hash, mainServer); + const fallbackPromises = fallbackServers.map((serverUrl) => + this._deleteFromServer(hash, serverUrl) + ); + + // Main server MUST succeed + await mainPromise; + + // Fallback servers can fail, log warnings + const fallbackResults = await Promise.allSettled(fallbackPromises); + for (let i = 0; i < fallbackResults.length; i++) { + const result = fallbackResults[i]; + if (result.status === 'rejected') { + console.warn( + `Fallback delete from ${fallbackServers[i]} failed:`, + result.reason + ); + } + } + } +} diff --git a/app/services/image-processor.js b/app/services/image-processor.js new file mode 100644 index 0000000..792ad0b --- /dev/null +++ b/app/services/image-processor.js @@ -0,0 +1,129 @@ +import Service from '@ember/service'; +// We use the special Vite query parameter to load this as a web worker +import Worker from '../workers/image-processor?worker'; + +export default class ImageProcessorService extends Service { + _worker = null; + _callbacks = new Map(); + _msgId = 0; + + constructor() { + super(...arguments); + this._initWorker(); + } + + _initWorker() { + if (!this._worker && typeof Worker !== 'undefined') { + try { + this._worker = new Worker(); + this._worker.onmessage = this._handleMessage.bind(this); + this._worker.onerror = this._handleError.bind(this); + } catch (e) { + console.warn('Failed to initialize image-processor worker:', e); + } + } + } + + _handleMessage(e) { + const { id, success, blob, dim, blurhash, error } = e.data; + const resolver = this._callbacks.get(id); + + if (resolver) { + this._callbacks.delete(id); + if (success) { + resolver.resolve({ blob, dim, blurhash }); + } else { + resolver.reject(new Error(error)); + } + } + } + + _handleError(error) { + console.error('Image Processor Worker Error:', error); + // Reject all pending jobs + for (const [, resolver] of this._callbacks.entries()) { + resolver.reject(new Error('Worker crashed')); + } + this._callbacks.clear(); + // Restart the worker for future jobs + this._worker.terminate(); + this._worker = null; + this._initWorker(); + } + + _getImageDimensions(file) { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + const dimensions = { width: img.width, height: img.height }; + URL.revokeObjectURL(url); + resolve(dimensions); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Could not read image dimensions')); + }; + + img.src = url; + }); + } + + async process(file, maxDimension, quality, computeBlurhash = false) { + if (!this._worker) { + // Fallback if worker initialization failed (e.g. incredibly old browsers) + throw new Error('Image processor worker is not available.'); + } + + try { + // 1. Get dimensions safely on the main thread + const { width: origWidth, height: origHeight } = + await this._getImageDimensions(file); + + // 2. Calculate aspect-ratio preserving dimensions + let targetWidth = origWidth; + let targetHeight = origHeight; + + if (origWidth > origHeight) { + if (origWidth > maxDimension) { + targetHeight = Math.round(origHeight * (maxDimension / origWidth)); + targetWidth = maxDimension; + } + } else { + if (origHeight > maxDimension) { + targetWidth = Math.round(origWidth * (maxDimension / origHeight)); + targetHeight = maxDimension; + } + } + + // 3. Send to worker for processing + return new Promise((resolve, reject) => { + const id = ++this._msgId; + this._callbacks.set(id, { resolve, reject }); + + this._worker.postMessage({ + type: 'PROCESS_IMAGE', + id, + file, + targetWidth, + targetHeight, + quality, + computeBlurhash, + }); + }); + } catch (e) { + throw new Error(`Failed to process image: ${e.message}`); + } + } + + willDestroy() { + super.willDestroy(...arguments); + if (this._worker) { + this._worker.terminate(); + this._worker = null; + } + this._callbacks.clear(); + } +} diff --git a/app/services/map-ui.js b/app/services/map-ui.js index ca6e8e7..63e9603 100644 --- a/app/services/map-ui.js +++ b/app/services/map-ui.js @@ -1,7 +1,9 @@ -import Service from '@ember/service'; +import Service, { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; export default class MapUiService extends Service { + @service nostrData; + @tracked selectedPlace = null; @tracked isSearching = false; @tracked isCreating = false; @@ -19,12 +21,14 @@ export default class MapUiService extends Service { selectPlace(place, options = {}) { this.selectedPlace = place; this.selectionOptions = options; + this.nostrData.loadPhotosForPlace(place); } clearSelection() { this.selectedPlace = null; this.selectionOptions = {}; this.preventNextZoom = false; + this.nostrData.loadPhotosForPlace(null); } setSearchResults(results) { diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js new file mode 100644 index 0000000..132c7dc --- /dev/null +++ b/app/services/nostr-auth.js @@ -0,0 +1,295 @@ +import Service, { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { + ExtensionSigner, + NostrConnectSigner, + PrivateKeySigner, +} from 'applesauce-signers'; + +const STORAGE_KEY = 'marco:nostr_pubkey'; +const STORAGE_KEY_TYPE = 'marco:nostr_signer_type'; // 'extension' | 'connect' +const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key'; +const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey'; +const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay'; + +const DEFAULT_CONNECT_RELAY = 'wss://relay.nsec.app'; + +import { isMobile } from '../utils/device'; + +export default class NostrAuthService extends Service { + @service nostrRelay; + @service nostrData; + + @tracked pubkey = null; + @tracked signerType = null; // 'extension' or 'connect' + + // Track NostrConnect state for the UI + @tracked connectStatus = null; // null | 'waiting' | 'connected' + @tracked connectUri = null; // For displaying a QR code if needed + + _signerInstance = null; + + constructor() { + super(...arguments); + + // Enable debug logging for applesauce packages + if (typeof localStorage !== 'undefined') { + localStorage.debug = 'applesauce:*'; + } + + const saved = localStorage.getItem(STORAGE_KEY); + const type = localStorage.getItem(STORAGE_KEY_TYPE); + if (saved) { + this.pubkey = saved; + this.signerType = type || 'extension'; + this._verifyPubkey(); + } + } + + async _verifyPubkey() { + if (this.signerType === 'extension') { + if (typeof window.nostr === 'undefined') { + this.disconnect(); + return; + } + try { + const signer = new ExtensionSigner(); + const extensionPubkey = await signer.getPublicKey(); + if (extensionPubkey !== this.pubkey) { + this.pubkey = extensionPubkey; + localStorage.setItem(STORAGE_KEY, this.pubkey); + } + this.nostrData.loadProfile(this.pubkey); + } catch (e) { + console.warn('Failed to verify extension nostr pubkey, logging out', e); + this.disconnect(); + } + } else if (this.signerType === 'connect') { + try { + await this._initConnectSigner(); + } catch (e) { + console.warn('Failed to verify connect nostr pubkey, logging out', e); + this.disconnect(); + } + } + } + + get isMobile() { + return isMobile(); + } + + get isConnected() { + return ( + !!this.pubkey && + (this.signerType === 'extension' + ? typeof window.nostr !== 'undefined' + : true) + ); + } + + get signer() { + if (this._signerInstance) return this._signerInstance; + + if ( + this.signerType === 'extension' && + typeof window.nostr !== 'undefined' + ) { + return new ExtensionSigner(); + } + + if (this.signerType === 'connect') { + // Must be initialized async due to the connect handshakes + return null; + } + + return null; + } + + async connectWithExtension() { + if (typeof window.nostr === 'undefined') { + throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).'); + } + + try { + this._signerInstance = new ExtensionSigner(); + this.pubkey = await this._signerInstance.getPublicKey(); + this.signerType = 'extension'; + localStorage.setItem(STORAGE_KEY, this.pubkey); + localStorage.setItem(STORAGE_KEY_TYPE, 'extension'); + this.nostrData.loadProfile(this.pubkey); + return this.pubkey; + } catch (error) { + console.error('Failed to get public key from extension:', error); + throw error; + } + } + + _getLocalSigner() { + let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY); + let localSigner; + if (localKeyHex) { + localSigner = PrivateKeySigner.fromKey(localKeyHex); + } else { + localSigner = new PrivateKeySigner(); + // Store the raw Uint8Array as hex string + localKeyHex = Array.from(localSigner.key) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex); + } + return localSigner; + } + + async connectWithApp() { + this.connectStatus = 'waiting'; + + try { + const localSigner = this._getLocalSigner(); + + // We use a specific relay for the connection handshake. + const relay = DEFAULT_CONNECT_RELAY; + localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay); + + // Override aggressive 10s EOSE timeout to allow time for QR scanning + this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes + + this._signerInstance = new NostrConnectSigner({ + pool: this.nostrRelay.pool, + relays: [relay], + signer: localSigner, + onAuth: async (url) => { + // NIP-46 auth callback. Normally the signer app does this natively via notification. + // But if it requires an explicit browser window: + if ( + confirm( + `Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}` + ) + ) { + window.open(url, '_blank'); + } + }, + }); + + // Set the uri for display (e.g., to redirect via intent) + this.connectUri = this._signerInstance.getNostrConnectURI({ + name: 'Marco', + url: window.location.origin, + description: 'An unhosted maps application.', + icons: [], + }); + + // Trigger the deep link intent immediately for the user if on mobile + if (this.isMobile) { + console.debug('Mobile detected, triggering deep link intent.'); + window.location.href = this.connectUri; + } + + // Start listening to the relay + console.debug('Opening signer connection to relay...'); + await this._signerInstance.open(); + console.debug('Signer connection opened successfully.'); + + // Wait for the remote signer to reply with their pubkey + console.debug('Waiting for remote signer to ack via relay...'); + try { + await this._signerInstance.waitForSigner(); + console.debug('Remote signer ack received!'); + } catch (waitErr) { + console.error('Error while waiting for remote signer ack:', waitErr); + throw waitErr; + } + + // Once connected, get the actual user pubkey + this.pubkey = await this._signerInstance.getPublicKey(); + this.signerType = 'connect'; + this.connectStatus = 'connected'; + + // Save connection state + localStorage.setItem(STORAGE_KEY, this.pubkey); + localStorage.setItem(STORAGE_KEY_TYPE, 'connect'); + localStorage.setItem( + STORAGE_KEY_CONNECT_REMOTE_PUBKEY, + this._signerInstance.remote + ); + + this.nostrData.loadProfile(this.pubkey); + + return this.pubkey; + } catch (error) { + this.connectStatus = null; + console.error('Failed to connect via Nostr Connect:', error); + throw error; + } + } + + async _initConnectSigner() { + const remotePubkey = localStorage.getItem( + STORAGE_KEY_CONNECT_REMOTE_PUBKEY + ); + const relay = + localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || DEFAULT_CONNECT_RELAY; + + if (!remotePubkey) { + throw new Error('Missing Nostr Connect remote pubkey.'); + } + + const localSigner = this._getLocalSigner(); + + // Override aggressive 10s EOSE timeout to allow time for QR scanning + this.nostrRelay.pool.relay(relay).eoseTimeout = 180000; // 3 minutes + + this._signerInstance = new NostrConnectSigner({ + pool: this.nostrRelay.pool, + relays: [relay], + signer: localSigner, + remote: remotePubkey, + onAuth: async (url) => { + if ( + confirm( + `Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}` + ) + ) { + window.open(url, '_blank'); + } + }, + }); + + await this._signerInstance.open(); + // Validate we can still get the pubkey from the remote signer + const pubkey = await this._signerInstance.getPublicKey(); + if (pubkey !== this.pubkey) { + throw new Error('Remote signer pubkey mismatch'); + } + this.nostrData.loadProfile(this.pubkey); + } + + async signEvent(event) { + if (!this.signer) { + throw new Error( + 'Not connected or extension missing. Please connect Nostr again.' + ); + } + return await this.signer.signEvent(event); + } + + async disconnect() { + this.pubkey = null; + this.nostrData?.loadProfile(null); + this.signerType = null; + this.connectStatus = null; + this.connectUri = null; + if ( + this._signerInstance && + typeof this._signerInstance.close === 'function' + ) { + await this._signerInstance.close(); + } + this._signerInstance = null; + + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY_TYPE); + localStorage.removeItem(STORAGE_KEY_CONNECT_LOCAL_KEY); + localStorage.removeItem(STORAGE_KEY_CONNECT_REMOTE_PUBKEY); + localStorage.removeItem(STORAGE_KEY_CONNECT_RELAY); + } +} diff --git a/app/services/nostr-data.js b/app/services/nostr-data.js new file mode 100644 index 0000000..a3df768 --- /dev/null +++ b/app/services/nostr-data.js @@ -0,0 +1,394 @@ +import Service, { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +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 { NostrIDB, openDB } from 'nostr-idb'; +import { normalizeRelayUrl } from '../utils/nostr'; +import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage'; + +const DIRECTORY_RELAYS = [ + 'wss://purplepag.es', + 'wss://relay.damus.io', + 'wss://nos.lol', +]; + +const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org']; +const DEFAULT_WRITE_RELAYS = []; + +export default class NostrDataService extends Service { + @service nostrRelay; + @service nostrAuth; + @service settings; + + store = new EventStore(); + + @tracked profile = null; + @tracked mailboxes = null; + @tracked blossomServers = []; + @tracked placePhotos = []; + + _profileSub = null; + _mailboxesSub = null; + _blossomSub = null; + _photosSub = null; + + _requestSub = null; + _cachePromise = null; + loadedGeohashPrefixes = new Set(); + + constructor() { + super(...arguments); + + // Initialize the IndexedDB cache + this._cachePromise = openDB('applesauce-events').then(async (db) => { + this.cache = new NostrIDB(db, { + cacheIndexes: 1000, + maxEvents: 10000, + }); + + await this.cache.start(); + + // Automatically persist new events to the cache + this._stopPersisting = persistEventsToCache( + this.store, + async (events) => { + // Only cache profiles, mailboxes, blossom servers, and place photos + const toCache = events.filter( + (e) => + e.kind === 0 || + e.kind === 10002 || + e.kind === 10063 || + e.kind === 360 + ); + + if (toCache.length > 0) { + await Promise.all(toCache.map((event) => this.cache.add(event))); + } + }, + { + batchTime: 1000, // Batch writes every 1 second + maxBatchSize: 100, + } + ); + }); + + // Feed events from the relay pool into the event store + this.nostrRelay.pool.relays$.subscribe(() => { + // Setup relay subscription tracking if needed, or we just rely on request() + // which returns an Observable + }); + } + + get defaultReadRelays() { + const mailboxes = (this.mailboxes?.inboxes || []) + .map(normalizeRelayUrl) + .filter(Boolean); + const defaults = DEFAULT_READ_RELAYS.map(normalizeRelayUrl).filter(Boolean); + return Array.from(new Set([...defaults, ...mailboxes])); + } + + get defaultWriteRelays() { + const mailboxes = (this.mailboxes?.outboxes || []) + .map(normalizeRelayUrl) + .filter(Boolean); + const defaults = + DEFAULT_WRITE_RELAYS.map(normalizeRelayUrl).filter(Boolean); + return Array.from(new Set([...defaults, ...mailboxes])); + } + + get activeReadRelays() { + if (this.settings.nostrReadRelays) { + return Array.from( + new Set( + this.settings.nostrReadRelays.map(normalizeRelayUrl).filter(Boolean) + ) + ); + } + return this.defaultReadRelays; + } + + get activeWriteRelays() { + if (this.settings.nostrWriteRelays) { + return Array.from( + new Set( + this.settings.nostrWriteRelays.map(normalizeRelayUrl).filter(Boolean) + ) + ); + } + return this.defaultWriteRelays; + } + + async loadPlacesInBounds(bbox) { + const requiredPrefixes = getGeohashPrefixesInBbox(bbox); + + const missingPrefixes = requiredPrefixes.filter( + (p) => !this.loadedGeohashPrefixes.has(p) + ); + + if (missingPrefixes.length === 0) { + return; + } + + console.debug( + '[nostr-data] Loading place photos for prefixes:', + missingPrefixes + ); + + try { + await this._cachePromise; + + const cachedEvents = await this.cache.query([ + { + kinds: [360], + '#g': missingPrefixes, + }, + ]); + + if (cachedEvents && cachedEvents.length > 0) { + for (const event of cachedEvents) { + this.store.add(event); + } + } + } catch (e) { + console.warn( + '[nostr-data] Failed to read photos from local Nostr IDB cache', + e + ); + } + + // Fire network request for new prefixes + this.nostrRelay.pool + .request(this.activeReadRelays, [ + { + kinds: [360], + '#g': missingPrefixes, + }, + ]) + .subscribe({ + next: (event) => { + this.store.add(event); + }, + error: (err) => { + console.error( + '[nostr-data] Error fetching place photos by geohash:', + err + ); + }, + }); + + for (const p of missingPrefixes) { + this.loadedGeohashPrefixes.add(p); + } + } + + async loadPhotosForPlace(place) { + if (this._photosSub) { + this._photosSub.unsubscribe(); + this._photosSub = null; + } + + this.placePhotos = []; + + if (!place || !place.osmId || !place.osmType) { + return; + } + + const entityId = `osm:${place.osmType}:${place.osmId}`; + + // Setup reactive store query + this._photosSub = this.store + .timeline([ + { + kinds: [360], + '#i': [entityId], + }, + ]) + .subscribe((events) => { + this.placePhotos = events; + }); + + try { + await this._cachePromise; + + const cachedEvents = await this.cache.query([ + { + kinds: [360], + '#i': [entityId], + }, + ]); + + if (cachedEvents && cachedEvents.length > 0) { + for (const event of cachedEvents) { + this.store.add(event); + } + } + } catch (e) { + console.warn( + '[nostr-data] Failed to read photos for place from local Nostr IDB cache', + e + ); + } + + // Fire network request specifically for this place + this.nostrRelay.pool + .request(this.activeReadRelays, [ + { + kinds: [360], + '#i': [entityId], + }, + ]) + .subscribe({ + next: (event) => { + this.store.add(event); + }, + error: (err) => { + console.error( + '[nostr-data] Error fetching place photos for place:', + err + ); + }, + }); + } + + async loadProfile(pubkey) { + if (!pubkey) return; + + // Reset state + this.profile = null; + this.mailboxes = null; + this.blossomServers = []; + + this._cleanupSubscriptions(); + + // Setup models to track state reactively FIRST + // This way, if cached events populate the store, the UI updates instantly. + this._profileSub = this.store + .model(ProfileModel, pubkey) + .subscribe((profileContent) => { + this.profile = profileContent; + }); + + this._mailboxesSub = this.store + .model(MailboxesModel, pubkey) + .subscribe((mailboxesData) => { + this.mailboxes = mailboxesData; + }); + + this._blossomSub = this.store + .replaceable(10063, pubkey) + .subscribe((event) => { + if (event && event.tags) { + this.blossomServers = event.tags + .filter((t) => t[0] === 'server' && t[1]) + .map((t) => t[1]); + } else { + this.blossomServers = []; + } + }); + + // 1. Await cache initialization and populate the EventStore with local data + try { + await this._cachePromise; + + const cachedEvents = await this.cache.query([ + { + authors: [pubkey], + kinds: [0, 10002, 10063], + }, + ]); + + if (cachedEvents && cachedEvents.length > 0) { + for (const event of cachedEvents) { + this.store.add(event); + } + } + } catch (e) { + console.warn('Failed to read from local Nostr IDB cache', e); + } + + // 2. Request new events from the network in the background and dump them into the store + const profileRelays = Array.from( + new Set([...DIRECTORY_RELAYS, ...this.activeWriteRelays]) + ); + this._requestSub = this.nostrRelay.pool + .request(profileRelays, [ + { + authors: [pubkey], + kinds: [0, 10002, 10063], + }, + ]) + .subscribe({ + next: (event) => { + this.store.add(event); + }, + error: (err) => { + console.error('Error fetching profile events:', err); + }, + }); + } + + get userDisplayName() { + if (this.profile) { + if (this.profile.nip05) { + return this.profile.nip05; + } + if (this.profile.displayName || this.profile.display_name) { + return this.profile.displayName || this.profile.display_name; + } + if (this.profile.name) { + return this.profile.name; + } + } + + // Fallback to npub + if (this.nostrAuth.pubkey) { + try { + const npub = npubEncode(this.nostrAuth.pubkey); + return `${npub.slice(0, 9)}...${npub.slice(-4)}`; + } catch { + return this.nostrAuth.pubkey; + } + } + + return 'Not connected'; + } + + _cleanupSubscriptions() { + if (this._requestSub) { + this._requestSub.unsubscribe(); + this._requestSub = null; + } + if (this._profileSub) { + this._profileSub.unsubscribe(); + this._profileSub = null; + } + if (this._mailboxesSub) { + this._mailboxesSub.unsubscribe(); + this._mailboxesSub = null; + } + if (this._blossomSub) { + this._blossomSub.unsubscribe(); + this._blossomSub = null; + } + if (this._photosSub) { + this._photosSub.unsubscribe(); + this._photosSub = null; + } + } + + willDestroy() { + super.willDestroy(...arguments); + this._cleanupSubscriptions(); + + if (this._stopPersisting) { + this._stopPersisting(); + } + + if (this.cache) { + this.cache.stop(); + } + } +} diff --git a/app/services/nostr-relay.js b/app/services/nostr-relay.js new file mode 100644 index 0000000..320a38b --- /dev/null +++ b/app/services/nostr-relay.js @@ -0,0 +1,25 @@ +import Service from '@ember/service'; +import { RelayPool } from 'applesauce-relay'; + +export default class NostrRelayService extends Service { + pool = new RelayPool(); + + async publish(relays, event) { + if (!relays || relays.length === 0) { + throw new Error('No relays provided to publish the event.'); + } + // The publish method is a wrapper around the event method that returns a Promise + // and automatically handles reconnecting and retrying. + const responses = await this.pool.publish(relays, event); + + // Check if at least one relay accepted the event + const success = responses.some((res) => res.ok); + if (!success) { + throw new Error( + `Failed to publish event. Responses: ${JSON.stringify(responses)}` + ); + } + + return responses; + } +} diff --git a/app/services/osm.js b/app/services/osm.js index e265178..5d42b6d 100644 --- a/app/services/osm.js +++ b/app/services/osm.js @@ -8,6 +8,7 @@ export default class OsmService extends Service { controller = null; cachedResults = null; lastQueryKey = null; + cachedPlaces = new Map(); cancelAll() { if (this.controller) { @@ -232,6 +233,13 @@ out center; async fetchOsmObject(osmId, osmType) { if (!osmId || !osmType) return null; + const cacheKey = `${osmType}:${osmId}`; + const cached = this.cachedPlaces.get(cacheKey); + if (cached && Date.now() - cached.timestamp < 10000) { + console.debug(`Using in-memory cached OSM object for ${cacheKey}`); + return cached.data; + } + let url; if (osmType === 'node') { url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`; @@ -253,8 +261,25 @@ out center; } throw new Error(`OSM API request failed: ${res.status}`); } + const data = await res.json(); - return this.normalizeOsmApiData(data.elements, osmId, osmType); + const normalizedData = this.normalizeOsmApiData( + data.elements, + osmId, + osmType + ); + + this.cachedPlaces.set(cacheKey, { + data: normalizedData, + timestamp: Date.now(), + }); + + // Cleanup cache entry automatically after 10 seconds + setTimeout(() => { + this.cachedPlaces.delete(cacheKey); + }, 10000); + + return normalizedData; } catch (e) { console.error('Failed to fetch OSM object:', e); return null; diff --git a/app/services/settings.js b/app/services/settings.js index e9e265e..669a4ed 100644 --- a/app/services/settings.js +++ b/app/services/settings.js @@ -1,11 +1,25 @@ import Service from '@ember/service'; import { tracked } from '@glimmer/tracking'; +const DEFAULT_SETTINGS = { + overpassApi: 'https://overpass-api.de/api/interpreter', + mapKinetic: true, + photonApi: 'https://photon.komoot.io/api/', + showQuickSearchButtons: true, + nostrPhotoFallbackUploads: false, + nostrReadRelays: null, + nostrWriteRelays: null, +}; + export default class SettingsService extends Service { - @tracked overpassApi = 'https://overpass-api.de/api/interpreter'; - @tracked mapKinetic = true; - @tracked photonApi = 'https://photon.komoot.io/api/'; - @tracked showQuickSearchButtons = true; + @tracked overpassApi = DEFAULT_SETTINGS.overpassApi; + @tracked mapKinetic = DEFAULT_SETTINGS.mapKinetic; + @tracked photonApi = DEFAULT_SETTINGS.photonApi; + @tracked showQuickSearchButtons = DEFAULT_SETTINGS.showQuickSearchButtons; + @tracked nostrPhotoFallbackUploads = + DEFAULT_SETTINGS.nostrPhotoFallbackUploads; + @tracked nostrReadRelays = DEFAULT_SETTINGS.nostrReadRelays; + @tracked nostrWriteRelays = DEFAULT_SETTINGS.nostrWriteRelays; overpassApis = [ { @@ -39,49 +53,83 @@ export default class SettingsService extends Service { } loadSettings() { - const savedApi = localStorage.getItem('marco:overpass-api'); - if (savedApi) { - // Check if saved API is still in the allowed list - const isValid = this.overpassApis.some((api) => api.url === savedApi); - if (isValid) { - this.overpassApi = savedApi; - } else { - // If not valid, revert to default - this.overpassApi = 'https://overpass-api.de/api/interpreter'; - localStorage.setItem('marco:overpass-api', this.overpassApi); + let settings = {}; + const savedSettings = localStorage.getItem('marco:settings'); + + if (savedSettings) { + try { + settings = JSON.parse(savedSettings); + } catch (e) { + console.error('Failed to parse settings from localStorage', e); } + } else { + // Migration from old individual keys + const savedApi = localStorage.getItem('marco:overpass-api'); + if (savedApi) settings.overpassApi = savedApi; + + const savedKinetic = localStorage.getItem('marco:map-kinetic'); + if (savedKinetic !== null) settings.mapKinetic = savedKinetic === 'true'; + + const savedShowQuickSearch = localStorage.getItem( + 'marco:show-quick-search' + ); + if (savedShowQuickSearch !== null) { + settings.showQuickSearchButtons = savedShowQuickSearch === 'true'; + } + + const savedNostrPhotoFallbackUploads = localStorage.getItem( + 'marco:nostr-photo-fallback-uploads' + ); + if (savedNostrPhotoFallbackUploads !== null) { + settings.nostrPhotoFallbackUploads = + savedNostrPhotoFallbackUploads === 'true'; + } + + const savedPhotonApi = localStorage.getItem('marco:photon-api'); + if (savedPhotonApi) settings.photonApi = savedPhotonApi; } - const savedKinetic = localStorage.getItem('marco:map-kinetic'); - if (savedKinetic !== null) { - this.mapKinetic = savedKinetic === 'true'; - } - // Default is true (initialized in class field) + // Merge with defaults + const finalSettings = { ...DEFAULT_SETTINGS, ...settings }; - const savedShowQuickSearch = localStorage.getItem( - 'marco:show-quick-search' + // Validate overpass API + const isValid = this.overpassApis.some( + (api) => api.url === finalSettings.overpassApi ); - if (savedShowQuickSearch !== null) { - this.showQuickSearchButtons = savedShowQuickSearch === 'true'; + if (!isValid) { + finalSettings.overpassApi = DEFAULT_SETTINGS.overpassApi; } + + // Apply to tracked properties + this.overpassApi = finalSettings.overpassApi; + this.mapKinetic = finalSettings.mapKinetic; + this.photonApi = finalSettings.photonApi; + this.showQuickSearchButtons = finalSettings.showQuickSearchButtons; + this.nostrPhotoFallbackUploads = finalSettings.nostrPhotoFallbackUploads; + this.nostrReadRelays = finalSettings.nostrReadRelays; + this.nostrWriteRelays = finalSettings.nostrWriteRelays; + + // Save to ensure migrated settings are stored in the new format + this.saveSettings(); } - updateOverpassApi(url) { - this.overpassApi = url; - localStorage.setItem('marco:overpass-api', url); + saveSettings() { + const settings = { + overpassApi: this.overpassApi, + mapKinetic: this.mapKinetic, + photonApi: this.photonApi, + showQuickSearchButtons: this.showQuickSearchButtons, + nostrPhotoFallbackUploads: this.nostrPhotoFallbackUploads, + nostrReadRelays: this.nostrReadRelays, + nostrWriteRelays: this.nostrWriteRelays, + }; + localStorage.setItem('marco:settings', JSON.stringify(settings)); } - updateMapKinetic(enabled) { - this.mapKinetic = enabled; - localStorage.setItem('marco:map-kinetic', String(enabled)); - } - - updateShowQuickSearchButtons(enabled) { - this.showQuickSearchButtons = enabled; - localStorage.setItem('marco:show-quick-search', String(enabled)); - } - - updatePhotonApi(url) { - this.photonApi = url; + update(key, value) { + if (key in DEFAULT_SETTINGS) { + this[key] = value; + this.saveSettings(); + } } } diff --git a/app/styles/app.css b/app/styles/app.css index a6ac5da..c1109bd 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -8,6 +8,8 @@ --link-color-visited: #6a4fbf; --marker-color-primary: #ea4335; --marker-color-dark: #b31412; + --danger-color: var(--marker-color-primary); + --danger-color-dark: var(--marker-color-dark); } html, @@ -180,6 +182,9 @@ body { border: none; cursor: pointer; padding: 0; + display: flex; + align-items: center; + justify-content: center; } .user-avatar-placeholder { @@ -190,7 +195,146 @@ body { display: flex; align-items: center; justify-content: center; - box-shadow: 0 2px 5px rgb(0 0 0 / 20%); + flex-shrink: 0; +} + +.user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + display: block; +} + +.photo-preview-img { + max-width: 100%; + height: auto; +} + +.dropzone { + border: 2px dashed #ccc; + border-radius: 8px; + text-align: center; + transition: all 0.2s ease; + margin: 1.5rem 0 1rem; + background-color: rgb(255 255 255 / 2%); + cursor: pointer; + aspect-ratio: 4 / 3; +} + +.dropzone.is-dragging { + border-color: #61afef; + background-color: rgb(97 175 239 / 5%); +} + +.dropzone-label { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + cursor: pointer; + color: #898989; + width: 100%; + height: 100%; +} + +.dropzone-label p { + margin: 0; +} + +.file-input-hidden { + display: none; +} + +.photo-grid { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.photo-upload-item { + position: relative; + aspect-ratio: 4 / 3; + border-radius: 6px; + overflow: hidden; + background: #1e262e; + width: 100%; +} + +.photo-upload-item img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + display: block; + z-index: 1; +} + +.photo-upload-item .overlay, +.photo-upload-item .btn-remove-photo { + z-index: 2; +} + +.photo-upload-item .overlay { + position: absolute; + inset: 0; + background: rgb(0 0 0 / 60%); + display: flex; + align-items: center; + justify-content: center; +} + +.photo-upload-item .error-overlay { + background: rgb(224 108 117 / 80%); + cursor: pointer; + border: none; + padding: 0; + margin: 0; + width: 100%; +} + +.photo-upload-item .btn-remove-photo { + position: absolute; + top: 4px; + right: 4px; + background: rgb(0 0 0 / 70%); + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: white; + padding: 0; +} + +.photo-upload-item .btn-remove-photo:hover { + background: var(--danger-color); +} + +.spin-animation { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.btn-publish { + width: 100%; } /* User Menu Popover */ @@ -252,6 +396,9 @@ body { color: #898989; margin-top: 0.35rem; margin-left: calc(18px + 0.75rem); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .account-status strong { @@ -328,6 +475,10 @@ body { align-items: center; } +.sidebar-header.no-border { + border-bottom-color: transparent; +} + .sidebar-header h2 { margin: 0; font-size: 1.2rem; @@ -433,6 +584,64 @@ body { padding: 0 1.4rem 1rem; animation: details-slide-down 0.2s ease-out; font-size: 0.9rem; + display: flex; + flex-direction: column; + gap: 16px; +} + +.relay-list { + list-style: none; + padding: 0; + margin: 0 0 0.75rem; + display: flex; + flex-direction: column; + gap: 4px; +} + +.relay-list li { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.25rem 0; + border-radius: 4px; + font-size: 0.9rem; + word-break: break-all; +} + +.btn-remove-relay { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #fff; + border: 1px solid var(--danger-color); + color: var(--danger-color); + cursor: pointer; + padding: 0; + transition: all 0.1s ease; + flex-shrink: 0; +} + +.btn-remove-relay svg { + stroke: currentcolor; +} + +.btn-remove-relay:hover, +.btn-remove-relay:active { + background-color: var(--danger-color); + color: #fff; +} + +.add-relay-input { + display: flex; + gap: 0.5rem; +} + +.btn-link.reset-relays { + margin-top: 0.75rem; + font-size: 0.85rem; } @keyframes details-slide-down { @@ -463,7 +672,7 @@ body { display: block; font-size: 0.85rem; color: #666; - margin-bottom: 0.25rem; + margin-bottom: 0.5rem; } .form-control { @@ -507,6 +716,11 @@ select.form-control { } .settings-section .form-group { + margin-top: 0.5rem; + margin-bottom: 0; +} + +.settings-section .form-group:first-of-type { margin-top: 1rem; } @@ -560,12 +774,19 @@ select.form-control { border-top: 1px solid #eee; } -.meta-info a { +.meta-info a, +.meta-info .btn-link { color: var(--link-color); text-decoration: none; + background: none; + border: none; + padding: 0; + font: inherit; + cursor: pointer; } -.meta-info a:hover { +.meta-info a:hover, +.meta-info .btn-link:hover { text-decoration: underline; } @@ -659,6 +880,153 @@ abbr[title] { padding-bottom: 2rem; } +.place-photos-carousel-wrapper { + position: relative; + margin: -1rem -1rem 1rem; +} + +.place-photos-carousel-track { + display: flex; + overflow-x: auto; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + scrollbar-width: none; /* Firefox */ + background-color: var(--hover-bg); +} + +.place-photos-carousel-track::-webkit-scrollbar { + display: none; /* Safari and Chrome */ +} + +.carousel-slide { + position: relative; + flex: 0 0 100%; + scroll-snap-align: start; + aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.carousel-placeholder { + display: none; +} + +.place-header-photo-blur { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.place-header-photo { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: block; + opacity: 0; + transition: opacity 0.3s ease-in-out; + z-index: 1; /* Stay above blurhash */ +} + +.place-header-photo.landscape { + object-fit: cover; +} + +.place-header-photo.portrait { + object-fit: contain; +} + +.place-header-photo.loaded { + opacity: 1; +} + +.place-header-photo.loaded-instant { + opacity: 1; + transition: none; +} + +.carousel-nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgb(0 0 0 / 50%); + color: white; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 2; + opacity: 0; + transition: + opacity 0.2s, + background-color 0.2s; + padding: 0; +} + +.place-photos-carousel-wrapper:hover .carousel-nav-btn:not(.disabled) { + opacity: 1; +} + +.carousel-nav-btn:not(.disabled):hover { + background: rgb(0 0 0 / 80%); +} + +.carousel-nav-btn.disabled { + opacity: 0; + pointer-events: none; +} + +.carousel-nav-btn.prev { + left: 0.5rem; +} + +.carousel-nav-btn.next { + right: 0.5rem; +} + +@media (width <= 768px) { + .place-photos-carousel-track { + scroll-snap-type: none; + gap: 2px; + background-color: #fff; + } + + .carousel-slide { + flex: 0 0 auto; + height: 100px; + width: auto; + aspect-ratio: var(--slide-ratio, 16 / 9); + scroll-snap-align: none; + } + + .carousel-placeholder { + display: block; + background-color: var(--hover-bg); + flex: 1 1 0%; + min-width: 0; + } + + .place-header-photo.landscape, + .place-header-photo.portrait { + object-fit: cover; + } + + .carousel-nav-btn { + display: none; + } +} + .place-details h3 { font-size: 1.2rem; margin-top: 0; @@ -835,6 +1203,7 @@ abbr[title] { display: inline-flex; width: 32px; height: 32px; + margin: -6px 0; } .app-logo-icon svg { @@ -1374,3 +1743,108 @@ button.create-place { transform: translate(-50%, 0); } } + +/* Nostr Connect */ +.nostr-connect-modal h2 { + margin-top: 0; +} + +.nostr-connect-options { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1.5rem; +} + +.nostr-connect-status { + margin-top: 1.5rem; + text-align: center; +} + +.qr-code-container { + display: flex; + justify-content: center; + margin-top: 1rem; +} + +.qr-code-container canvas { + border-radius: 8px; + background: white; /* Ensure good contrast for scanning */ +} + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 50%); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); + padding: 1.5rem; + max-width: 90vw; + width: 450px; + position: relative; +} + +.close-modal-btn { + position: absolute; + top: 1rem; + right: 1rem; + cursor: pointer; +} + +.place-photo-upload h2 { + margin-top: 0; + font-size: 1.2rem; +} + +.alert { + padding: 0.5rem; + margin-bottom: 1rem; + border-radius: 0.25rem; +} + +.alert-error { + background: #fee; + color: #c00; +} + +.alert-info { + background: #eef; + color: #00c; +} + +.preview-group { + margin-bottom: 1rem; +} + +.preview-group p { + margin-bottom: 0.25rem; + font-weight: bold; +} + +.preview-group img { + max-width: 100%; + border-radius: 0.25rem; +} + +.btn-link { + background: none; + border: none; + padding: 0; + color: var(--link-color); + text-decoration: none; + cursor: pointer; + font: inherit; +} + +.btn-link:hover { + text-decoration: underline; +} diff --git a/app/utils/device.js b/app/utils/device.js new file mode 100644 index 0000000..857cd68 --- /dev/null +++ b/app/utils/device.js @@ -0,0 +1,4 @@ +export function isMobile() { + if (typeof navigator === 'undefined') return false; + return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent); +} diff --git a/app/utils/icons.js b/app/utils/icons.js index 78348fe..4f24d68 100644 --- a/app/utils/icons.js +++ b/app/utils/icons.js @@ -2,7 +2,10 @@ import activity from 'feather-icons/dist/icons/activity.svg?raw'; import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw'; import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw'; +import camera 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 clock from 'feather-icons/dist/icons/clock.svg?raw'; import edit from 'feather-icons/dist/icons/edit.svg?raw'; import facebook from 'feather-icons/dist/icons/facebook.svg?raw'; @@ -25,8 +28,12 @@ import search from 'feather-icons/dist/icons/search.svg?raw'; import server from 'feather-icons/dist/icons/server.svg?raw'; import settings from 'feather-icons/dist/icons/settings.svg?raw'; import target from 'feather-icons/dist/icons/target.svg?raw'; +import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw'; +import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw'; import user from 'feather-icons/dist/icons/user.svg?raw'; import x from 'feather-icons/dist/icons/x.svg?raw'; +import check from 'feather-icons/dist/icons/check.svg?raw'; +import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw'; import zap from 'feather-icons/dist/icons/zap.svg?raw'; import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw'; @@ -39,7 +46,6 @@ import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_fo import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw'; import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw'; import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw'; -import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw'; import car from '@waysidemapping/pinhead/dist/icons/car.svg?raw'; import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw'; import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw'; @@ -128,8 +134,12 @@ const ICONS = { bus, camera, 'check-square': checkSquare, + 'chevron-left': chevronLeft, + 'chevron-right': chevronRight, 'cigarette-with-smoke-curl': cigaretteWithSmokeCurl, climbing_wall: climbingWall, + check, + 'alert-circle': alertCircle, 'classical-building': classicalBuilding, 'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag, 'classical-building-with-flag': classicalBuildingWithFlag, @@ -214,6 +224,8 @@ const ICONS = { 'tattoo-machine': tattooMachine, toolbox, target, + 'trash-2': trash2, + 'upload-cloud': uploadCloud, 'tree-and-bench-with-backrest': treeAndBenchWithBackrest, user, 'village-buildings': villageBuildings, @@ -235,7 +247,6 @@ const FILLED_ICONS = [ 'cup-and-saucer', 'coffee-bean', 'shopping-basket', - 'camera', 'person-sleeping-in-bed', 'loading-ring', 'nostrich', diff --git a/app/utils/nostr.js b/app/utils/nostr.js new file mode 100644 index 0000000..fae34c4 --- /dev/null +++ b/app/utils/nostr.js @@ -0,0 +1,88 @@ +export function normalizeRelayUrl(url) { + if (!url) return ''; + let normalized = url.trim().toLowerCase(); + if (!normalized) return ''; + + if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) { + normalized = 'wss://' + normalized; + } + + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + + return normalized; +} + +/** + * Extracts and normalizes photo data from NIP-360 (Place Photos) events. + * Sorts chronologically and guarantees the first landscape photo (or first portrait) is at index 0. + * + * @param {Array} events NIP-360 events + * @returns {Array} Array of photo objects + */ +export function parsePlacePhotos(events) { + if (!events || events.length === 0) return []; + + // Sort by created_at ascending (oldest first) + const sortedEvents = [...events].sort((a, b) => a.created_at - b.created_at); + + const allPhotos = []; + + for (const event of sortedEvents) { + // Find all imeta tags + const imetas = event.tags.filter((t) => t[0] === 'imeta'); + for (const imeta of imetas) { + let url = null; + let thumbUrl = null; + let blurhash = null; + let isLandscape = false; + let aspectRatio = 16 / 9; // default + + for (const tag of imeta.slice(1)) { + if (tag.startsWith('url ')) { + url = tag.substring(4); + } else if (tag.startsWith('thumb ')) { + thumbUrl = tag.substring(6); + } else if (tag.startsWith('blurhash ')) { + blurhash = tag.substring(9); + } else if (tag.startsWith('dim ')) { + const dimStr = tag.substring(4); + const [width, height] = dimStr.split('x').map(Number); + if (width && height) { + aspectRatio = width / height; + if (width > height) { + isLandscape = true; + } + } + } + } + + if (url) { + allPhotos.push({ + eventId: event.id, + pubkey: event.pubkey, + createdAt: event.created_at, + url, + thumbUrl, + blurhash, + isLandscape, + aspectRatio, + }); + } + } + } + + if (allPhotos.length === 0) return []; + + // Find the first landscape photo + const firstLandscapeIndex = allPhotos.findIndex((p) => p.isLandscape); + + if (firstLandscapeIndex > 0) { + // Move the first landscape photo to the front + const [firstLandscape] = allPhotos.splice(firstLandscapeIndex, 1); + allPhotos.unshift(firstLandscape); + } + + return allPhotos; +} diff --git a/app/utils/poi-categories.js b/app/utils/poi-categories.js index 966eeac..78c4ef4 100644 --- a/app/utils/poi-categories.js +++ b/app/utils/poi-categories.js @@ -55,7 +55,7 @@ export const POI_CATEGORIES = [ id: 'accommodation', label: 'Hotels', icon: 'person-sleeping-in-bed', - filter: ['["tourism"~"^(hotel|hostel|motel)$"]'], + filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'], types: ['node', 'way', 'relation'], }, ]; diff --git a/app/workers/image-processor.js b/app/workers/image-processor.js new file mode 100644 index 0000000..830036c --- /dev/null +++ b/app/workers/image-processor.js @@ -0,0 +1,130 @@ +import { encode } from 'blurhash'; + +self.onmessage = async (e) => { + // Ignore internal browser/Vite/extension pings that don't match our exact job signature + if (e.data?.type !== 'PROCESS_IMAGE') return; + + const { id, file, targetWidth, targetHeight, quality, computeBlurhash } = + e.data; + + try { + let finalCanvas; + let finalCtx; + + // --- 1. Attempt Hardware Resizing (Happy Path) --- + try { + const resizedBitmap = await createImageBitmap(file, { + resizeWidth: targetWidth, + resizeHeight: targetHeight, + resizeQuality: 'high', + }); + + finalCanvas = new OffscreenCanvas(targetWidth, targetHeight); + finalCtx = finalCanvas.getContext('2d'); + if (!finalCtx) { + throw new Error('Failed to get 2d context from OffscreenCanvas'); + } + finalCtx.drawImage(resizedBitmap, 0, 0, targetWidth, targetHeight); + resizedBitmap.close(); + } catch (hwError) { + console.warn( + 'Hardware resize failed, falling back to stepped software scaling:', + hwError + ); + + // --- 2. Fallback to Stepped Software Scaling (Robust Path) --- + // Bypass Android File descriptor bug by reading into memory + const buffer = await file.arrayBuffer(); + const blob = new Blob([buffer], { type: file.type }); + + const source = await createImageBitmap(blob); + let srcWidth = source.width; + let srcHeight = source.height; + + let currentCanvas = new OffscreenCanvas(srcWidth, srcHeight); + let ctx = currentCanvas.getContext('2d'); + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(source, 0, 0); + + // Step down by halves until near target + while ( + currentCanvas.width * 0.5 > targetWidth && + currentCanvas.height * 0.5 > targetHeight + ) { + const nextCanvas = new OffscreenCanvas( + Math.floor(currentCanvas.width * 0.5), + Math.floor(currentCanvas.height * 0.5) + ); + const nextCtx = nextCanvas.getContext('2d'); + + nextCtx.imageSmoothingEnabled = true; + nextCtx.imageSmoothingQuality = 'high'; + + nextCtx.drawImage( + currentCanvas, + 0, + 0, + nextCanvas.width, + nextCanvas.height + ); + + currentCanvas = nextCanvas; + } + + // Final resize to exact target + finalCanvas = new OffscreenCanvas(targetWidth, targetHeight); + finalCtx = finalCanvas.getContext('2d'); + + finalCtx.imageSmoothingEnabled = true; + finalCtx.imageSmoothingQuality = 'high'; + + finalCtx.drawImage(currentCanvas, 0, 0, targetWidth, targetHeight); + + source.close(); + } + + // --- 3. Generate Blurhash (if requested) --- + let blurhash = null; + if (computeBlurhash) { + try { + const imageData = finalCtx.getImageData( + 0, + 0, + targetWidth, + targetHeight + ); + blurhash = encode(imageData.data, targetWidth, targetHeight, 4, 3); + } catch (blurhashError) { + console.warn( + 'Could not generate blurhash (possible canvas fingerprinting protection):', + blurhashError + ); + } + } + + // --- 4. Compress to JPEG Blob --- + const finalBlob = await finalCanvas.convertToBlob({ + type: 'image/jpeg', + quality: quality, + }); + + const dim = `${targetWidth}x${targetHeight}`; + + // --- 5. Send results back to main thread --- + self.postMessage({ + id, + success: true, + blob: finalBlob, + dim, + blurhash, + }); + } catch (error) { + self.postMessage({ + id, + success: false, + error: error.message, + }); + } +}; diff --git a/doc/nostr/nip-place-photos.md b/doc/nostr/nip-place-photos.md new file mode 100644 index 0000000..61cc14c --- /dev/null +++ b/doc/nostr/nip-place-photos.md @@ -0,0 +1,111 @@ +# NIP-XX: Place Photos and Media + +`draft` `optional` + +## Abstract + +This NIP defines a standardized event format for sharing photos, videos, and other visual media tied to specific real-world locations (e.g., OpenStreetMap POIs). + +While NIP-68 (Picture-first feeds) caters to general visual feeds, this NIP specifically targets map-based applications, travel logs, and location directories by mandating strict entity identifiers (`i` tags) and spatial indexing (`g` tags). + +## Event Kind + +`kind: 360` + +## Content + +The `.content` of the event SHOULD generally be empty. If a user wishes to provide a detailed description, summary, or caption for a place, clients SHOULD encourage them to create a Place Review event (`kind: 30360`) instead. + +## Tags + +This NIP relies on existing Nostr tag conventions to link media to places and provide inline metadata. + +### Required Tags + +#### 1. `i` — Entity Identifier + +Identifies the exact place the media depicts using an external identifier (as defined in NIP-73). OpenStreetMap data is the default: + +```json +["i", "osm:node:123456"] +``` + +- For OSM POIs, `` MUST be one of: `node`, `way`, `relation`. + +#### 2. `g` — Geohash + +Used for spatial indexing and discovery. Events MUST include at least one high-precision geohash. To optimize for map-based discovery across different zoom levels, clients SHOULD include geohashes at multiple resolutions: + +```json +["g", "thrr"] // coarse (~city) +["g", "thrrn5"] // medium (~1km) +["g", "thrrn5k"] // fine (~150m) +["g", "thrrn5kxyz"] // exact +``` + +#### 3. `imeta` — Inline Media Metadata + +An event MUST contain exactly one `imeta` tag representing a single media item. The primary `url` SHOULD also be appended to the event's `.content` for backwards compatibility with clients that do not parse `imeta` tags. + +Clients SHOULD include `alt` (accessibility descriptions), `dim` (dimensions), `m` (MIME type), and `blurhash` where possible. + +```json +[ + "imeta", + "url https://example.com/photo.jpg", + "m image/jpeg", + "dim 3024x4032", + "alt A steaming bowl of ramen on a wooden table at the restaurant.", + "blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$" +] +``` + +### Optional Tags + +- `t`: Hashtags for categorization (e.g., `["t", "food"]`, `["t", "architecture"]`). +- `content-warning`: If the media contains NSFW or sensitive imagery. +- `published_at`: Unix timestamp of when the photo was originally taken or published. + +## Example Event + +```json +{ + "id": "<32-bytes hex>", + "pubkey": "<32-bytes hex>", + "created_at": 1713205000, + "kind": 360, + "content": "", + "tags": [ + ["i", "osm:node:987654321"], + ["g", "xn0m"], + ["g", "xn0m7h"], + ["g", "xn0m7hwq"], + + [ + "imeta", + "url https://example.com/ramen.jpg", + "m image/jpeg", + "dim 1080x1080", + "alt A close-up of spicy miso ramen with chashu pork, soft boiled egg, and scallions.", + "blurhash UHI=0o~q4T-o~q%MozM{x]t7RjRPt7oKkCWB" + ], + + ["t", "ramen"], + ["t", "food"] + ] +} +``` + +## Rationale + +### Why not use NIP-68 (Picture-first feeds)? + +NIP-68 is designed for general-purpose social feeds (like Instagram). Place photos require strict guarantees about what entity is being depicted to be useful for map clients, directories, and review aggregators. By mandating the `i` tag for POI linking and the `g` tag for spatial querying, this kind ensures interoperability for geo-spatial applications without cluttering general picture feeds with mundane POI images (like photos of storefronts or menus). + +### Separation from Place Reviews + +Reviews (kind 30360) and media have different lifecycles and data models. A user might upload 10 photos of a park without writing a review, or write a detailed review without attaching photos. Keeping them as separate events allows clients to query `imeta` attachments for a specific `i` tag to quickly build a photo gallery for a place, regardless of whether a review was attached. + +### Single Photo per Event + +Restricting events to a single `imeta` attachment (one photo per event) is an intentional design choice. Batching photos into a single event forces all engagement (likes, zaps) to apply to the entire batch, rendering granular tagging and sorting impossible. Single-photo events enable per-photo engagement, fine-grained categorization (e.g., tagging one photo as "food" and another as "menu"), and richer sorting algorithms based on individual photo popularity. diff --git a/doc/nostr/nip-place-reviews.md b/doc/nostr/nip-place-reviews.md new file mode 100644 index 0000000..177d6d9 --- /dev/null +++ b/doc/nostr/nip-place-reviews.md @@ -0,0 +1,313 @@ +# NIP-XX: Place Reviews + +`draft` `optional` + +## Abstract + +This NIP defines a standardized event format for decentralized place reviews using Nostr. Reviews are tied to real-world locations (e.g. OpenStreetMap POIs) via tags, and include structured, multi-aspect ratings, a binary recommendation signal, and optional contextual metadata. + +The design prioritizes: + +- Small event size +- Interoperability across clients +- Flexibility for different place types +- Efficient geospatial querying using geohashes + +## Event Kind + +`kind: 30360` + +## Tags + +Additional tags MAY be included by clients but are not defined by this specification. + +This NIP reuses and builds upon existing Nostr tag conventions: + +- `i` tag: see NIP-73 (External Content Identifiers) +- `g` tag: geohash-based geotagging (community conventions) + +Where conflicts arise, this NIP specifies the behavior for review events. + +### Required + +#### `i` — Entity Identifier + +Identifies the reviewed place using an external identifier. OpenStreetMap data is the default: + +``` +["i", "osm::"] +``` + +Requirements: + +- For OSM POIs, `` MUST be one of: `node`, `way`, `relation` + +Examples: + +``` +["i", "osm:node:123456"] +["i", "osm:way:987654"] +``` + +### Geospatial Tags + +#### `g` — Geohash + +Geohash tags are used for spatial indexing and discovery. + +##### Requirements + +- Clients MUST include at least one high-precision geohash (length ≥ 9) + +##### Recommendations + +Clients SHOULD include geohashes at the following resolutions: + +- length 4 — coarse (city-scale discovery) +- length 6 — medium (default query level, ~1 km) +- length 7 — fine (neighborhood, ~150 m) + +Example: + +``` +["g", "thrr"] +["g", "thrrn5"] +["g", "thrrn5k"] +["g", "thrrn5kxyz"] +``` + +##### Querying + +Geospatial queries are performed using the `g` tag. + +- Clients SHOULD query using a single geohash precision level per request +- Clients MAY include multiple geohash values in a filter to cover a bounding box +- Clients SHOULD limit the number of geohash values per query (e.g. ≤ 30) +- Clients MAY reduce precision or split queries when necessary + +Note: Other queries (e.g. fetching reviews for a specific place) are performed using the `i` tag and are outside the scope of geospatial querying. + +## Content (JSON) + +The event `content` MUST be valid JSON matching the following schema. + +### Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["version", "ratings"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "const": 1 + }, + "ratings": { + "type": "object", + "required": ["quality"], + "additionalProperties": false, + "properties": { + "quality": { "$ref": "#/$defs/score" }, + "value": { "$ref": "#/$defs/score" }, + "experience": { "$ref": "#/$defs/score" }, + "accessibility": { "$ref": "#/$defs/score" }, + "aspects": { + "type": "object", + "minProperties": 1, + "maxProperties": 20, + "additionalProperties": { "$ref": "#/$defs/score" }, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]{1,31}$" + } + } + } + }, + "recommend": { + "type": "boolean" + }, + "familiarity": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "User familiarity: low = first visit; medium = occasional; high = frequent" + }, + "context": { + "type": "object", + "additionalProperties": false, + "properties": { + "visited_at": { + "type": "integer", + "minimum": 0 + }, + "duration_minutes": { + "type": "integer", + "minimum": 0, + "maximum": 1440 + }, + "party_size": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + }, + "review": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "string", + "maxLength": 1000 + }, + "language": { + "type": "string", + "pattern": "^[a-z]{2}(-[A-Z]{2})?$" + } + } + } + }, + "$defs": { + "score": { + "type": "integer", + "minimum": 1, + "maximum": 10 + } + } +} +``` + +## Example + +### Restaurant Review Event + +#### Tags + +``` +[ + ["i", "osm:node:123456"], + ["g", "thrr"], + ["g", "thrrn5"], + ["g", "thrrn5k"], + ["g", "thrrn5kxyz"] +] +``` + +#### Content + +```json +{ + "version": 1, + "ratings": { + "quality": 9, + "value": 8, + "experience": 9, + "accessibility": 7, + "aspects": { + "food": 9, + "service": 6, + "ambience": 8, + "wait_time": 5 + } + }, + "recommend": true, + "familiarity": "medium", + "context": { + "visited_at": 1713200000, + "duration_minutes": 90, + "party_size": 2 + }, + "review": { + "text": "Excellent food with bold flavors. Service was a bit slow, but the atmosphere made up for it.", + "language": "en" + } +} +``` + +## Semantics + +### Ratings + +- Scores are integers from 1 to 10 +- `quality` is required and represents the core evaluation of the place +- Other fields are optional and context-dependent + +### Aspects + +- Free-form keys allow domain-specific ratings +- Clients MAY define and interpret aspect keys +- Clients SHOULD reuse commonly established aspect keys where possible + +## Recommendation Signal + +The `recommend` field represents a binary verdict: + +- `true` → user recommends the place +- `false` → user does not recommend the place + +Clients SHOULD strongly encourage users to provide this value. + +## Familiarity + +Represents user familiarity with the place: + +- `low` → first visit or limited exposure +- `medium` → occasional visits +- `high` → frequent or expert-level familiarity + +Clients MAY use this signal for weighting during aggregation. + +## Context + +Optional metadata about the visit. + +- `visited_at` is a Unix timestamp +- `duration_minutes` represents time spent +- `party_size` indicates group size + +## Interoperability + +This specification defines a content payload only. + +- In Nostr: place identity is conveyed via tags +- In other protocols (e.g. ActivityPub, AT Protocol): identity MUST be mapped to the equivalent field (e.g. `object`) + +Content payloads SHOULD NOT include place identifiers. + +## Rationale + +### No Place Field in Content + +Avoids duplication and inconsistency with tags. + +### Multi-Aspect Ratings + +Separates concerns (e.g. quality vs service), improving signal quality. + +### Recommendation vs Score + +Binary recommendation avoids averaging pitfalls and improves ranking. + +### Familiarity + +Provides a human-friendly proxy for confidence without requiring numeric input. + +### Geohash Strategy + +Multiple resolutions balance: + +- efficient querying +- small event size +- early-stage discoverability + +## Future Work + +- Standardized aspect vocabularies +- Reputation and weighting models +- Indexing/aggregation services +- Cross-protocol mappings + +## Security Considerations + +- Clients SHOULD validate all input +- Malicious or spam reviews may require external moderation or reputation systems diff --git a/doc/nostr/notes.md b/doc/nostr/notes.md new file mode 100644 index 0000000..315771e --- /dev/null +++ b/doc/nostr/notes.md @@ -0,0 +1,7 @@ +# Notes + +- NIP-73 for external IDs ("osm:node:123456"): https://github.com/nostr-protocol/nips/blob/744bce8fcae0aca07b936b6662db635c8b4253dd/73.md +- NIP 68/92/94 for place photos and image metadata (add "i" tag for "osm:node:123456" to NIP-68) +- Places NIP-XX draft PR: https://github.com/nostr-protocol/nips/pull/927 +- NPM package for generating multi-resolution geotags: https://sandwichfarm.github.io/nostr-geotags/#md:nostr-geotags +- AppleSauce docs for AI agents: https://applesauce.build/introduction/mcp-server.html diff --git a/doc/nostr/ranking.md b/doc/nostr/ranking.md new file mode 100644 index 0000000..f39bf33 --- /dev/null +++ b/doc/nostr/ranking.md @@ -0,0 +1,250 @@ +# Ranking Algorithm + +Your inputs: + +- many users +- partial ratings +- different priorities + +Your output: + +> “Best place _for this user right now_” + +--- + +## Step 1: Normalize scores + +Convert 1–10 → 0–1: + +```text +normalized_score = (score - 1) / 9 +``` + +Why: + +- easier math +- comparable across aspects + +--- + +## Step 2: Per-aspect aggregation (avoid averages trap) + +Instead of mean, compute: + +### A. Positive ratio + +```text +positive = score >= 7 +negative = score <= 4 +``` + +Then: + +```text +positive_ratio = positive_votes / total_votes +``` + +--- + +### B. Confidence-weighted score + +Use something like a **Wilson score interval** (this is key): + +- prevents small-sample abuse +- avoids “1 review = #1 place” + +--- + +## Step 3: Build aspect scores + +For each aspect: + +```text +aspect_score = f( + positive_ratio, + confidence, + number_of_reviews +) +``` + +You can approximate with: + +```text +aspect_score = positive_ratio * log(1 + review_count) +``` + +(Simple, works surprisingly well) + +--- + +## Step 4: User preference weighting + +User defines: + +```json +{ + "quality": 0.5, + "value": 0.2, + "service": 0.2, + "speed": 0.1 +} +``` + +Then: + +```text +final_score = Σ (aspect_score × weight) +``` + +--- + +## Step 5: Context filtering (this is your unfair advantage) + +Filter reviews before scoring: + +- time-based: + - “last 6 months” + +- context-based: + - lunch vs dinner + - solo vs group + +This is something centralized platforms barely do. + +--- + +## Step 6: Reviewer weighting (later, but powerful) + +Weight reviews by: + +- consistency +- similarity to user preferences +- past agreement + +This gives you: + +> “people like you liked this” + +--- + +# 3. Example end-to-end + +### Raw reviews: + +| User | Food | Service | +| ---- | ---- | ------- | +| A | 9 | 4 | +| B | 8 | 5 | +| C | 10 | 3 | + +--- + +### Derived: + +- food → high positive ratio (~100%) +- service → low (~33%) + +--- + +### User preferences: + +```json +{ + "food": 0.8, + "service": 0.2 +} +``` + +→ ranks high + +Another user: + +```json +{ + "food": 0.3, + "service": 0.7 +} +``` + +→ ranks low + +👉 Same data, different truth +That’s your killer feature. + +--- + +# 4. Critical design choices (don’t skip these) + +## A. No global score in protocol + +Let clients compute it. + +--- + +## B. Embrace incomplete data + +Most reviews will have: + +- 1–3 aspects only + +That’s fine. + +--- + +## C. Time decay (important) + +Recent reviews should matter more: + +```text +weight = e^(-λ × age) +``` + +--- + +## D. Anti-gaming baseline + +Even in nostr: + +- spam will happen + +Mitigation later: + +- require minimum interactions +- reputation layers + +--- + +# 5. What you’ve built (zooming out) + +This is not a review system. + +It’s: + +> A decentralized, multi-dimensional reputation graph for real-world places + +That’s much bigger. + +--- + +# 6. Next step (if you want to go deeper) + +We can design: + +### A. Query layer + +- how clients fetch & merge nostr reviews efficiently + +### B. Anti-spam / trust model + +- web-of-trust +- staking / reputation + +### C. OSM integration details + +- handling duplicates +- POI identity conflicts + +--- + +If I had to pick one next: +👉 **trust/reputation system** — because without it, everything you built _will_ get gamed. diff --git a/doc/nostr/ratings.md b/doc/nostr/ratings.md new file mode 100644 index 0000000..593091e --- /dev/null +++ b/doc/nostr/ratings.md @@ -0,0 +1,100 @@ +# Canonical Aspect Vocabulary (v0.1) + +## A. Core universal aspects + +These should work for _any_ place: + +```json +[ + "quality", // core offering (food, repair, exhibits, etc.) + "value", // value for money/time + "experience", // comfort, usability, vibe + "accessibility" // ease of access, inclusivity +] +``` + +### Why these work + +- **quality** → your “product” abstraction (critical) +- **value** → universally meaningful signal +- **experience** → captures everything “soft” +- **accessibility** → often ignored but high utility + +👉 Resist adding more. Every extra “universal” weakens the concept. + +--- + +## B. Common cross-domain aspects (recommended pool) + +Not universal, but widely reusable: + +```json +[ + "service", // human interaction + "speed", // waiting time / turnaround + "cleanliness", + "safety", + "reliability", + "atmosphere" +] +``` + +These apply to: + +- restaurants, garages, clinics, parks, etc. + +--- + +## C. Domain-specific examples (NOT standardized) + +Let clients define freely: + +```json +{ + "restaurant": ["food", "drinks"], + "bar": ["drinks", "music"], + "garage": ["work_quality", "honesty"], + "park": ["greenery", "amenities"], + "museum": ["exhibits", "crowding"] +} +``` + +--- + +## D. Key rule (this prevents chaos) + +👉 **Aspect keys MUST be lowercase snake_case** + +👉 **Meaning is defined socially, not technically** + +To reduce fragmentation: + +- publish a **public registry (GitHub repo)** +- clients can: + - suggest additions + - map synonyms + +--- + +## E. Optional normalization hint (important later) + +Allow this: + +```json +"aspect_aliases": { + "food": "quality", + "work_quality": "quality" +} +``` + +Not required, but useful for aggregation engines. + +--- + +## Notes + +Map familiarity in UI to: + +- high: “I know this place well” +- medium: “Been a few times” +- low: “First visit” diff --git a/doc/nostr/review-schema.json b/doc/nostr/review-schema.json new file mode 100644 index 0000000..b2a2cb0 --- /dev/null +++ b/doc/nostr/review-schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.org/schemas/place-review.json", + "title": "Decentralized Place Review (Nostr/Event Content)", + "type": "object", + "required": ["version", "place", "ratings"], + "additionalProperties": false, + + "properties": { + "version": { + "type": "integer", + "const": 1 + }, + + "ratings": { + "type": "object", + "required": ["quality"], + "additionalProperties": false, + "properties": { + "quality": { "$ref": "#/$defs/score" }, + "value": { "$ref": "#/$defs/score" }, + "experience": { "$ref": "#/$defs/score" }, + "accessibility": { "$ref": "#/$defs/score" }, + + "aspects": { + "type": "object", + "minProperties": 1, + "maxProperties": 20, + "additionalProperties": { "$ref": "#/$defs/score" }, + "propertyNames": { + "pattern": "^[a-z][a-z0-9_]{1,31}$" + } + } + } + }, + + "recommend": { + "type": "boolean", + "description": "Whether the user recommends this place to others" + }, + + "familiarity": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "User familiarity with the place. Suggested interpretation: 'low' = first visit or very limited experience; 'medium' = visited a few times or moderate familiarity; 'high' = frequent visitor or strong familiarity." + }, + + "context": { + "type": "object", + "additionalProperties": false, + "properties": { + "visited_at": { + "type": "integer", + "minimum": 0 + }, + "duration_minutes": { + "type": "integer", + "minimum": 0, + "maximum": 1440 + }, + "party_size": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + }, + + "review": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "string", + "maxLength": 1000 + }, + "language": { + "type": "string", + "pattern": "^[a-z]{2}(-[A-Z]{2})?$" + } + } + } + }, + + "$defs": { + "score": { + "type": "integer", + "minimum": 1, + "maximum": 10 + } + } +} diff --git a/package.json b/package.json index a7d304b..52fd0fb 100644 --- a/package.json +++ b/package.json @@ -102,9 +102,18 @@ "edition": "octane" }, "dependencies": { + "@noble/hashes": "^2.2.0", "@waysidemapping/pinhead": "^15.20.0", + "applesauce-core": "^5.2.0", + "applesauce-factory": "^4.0.0", + "applesauce-relay": "^5.2.0", + "applesauce-signers": "^5.2.0", + "blurhash": "^2.0.5", "ember-concurrency": "^5.2.0", "ember-lifeline": "^7.0.0", - "oauth2-pkce": "^2.1.3" + "nostr-idb": "^5.0.0", + "oauth2-pkce": "^2.1.3", + "qrcode": "^1.5.4", + "rxjs": "^7.8.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1102d9..b6993c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,45 @@ importers: .: dependencies: + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 '@waysidemapping/pinhead': specifier: ^15.20.0 version: 15.20.0 + applesauce-core: + specifier: ^5.2.0 + version: 5.2.0(typescript@5.9.3) + applesauce-factory: + specifier: ^4.0.0 + version: 4.0.0(typescript@5.9.3) + applesauce-relay: + specifier: ^5.2.0 + version: 5.2.0(typescript@5.9.3) + applesauce-signers: + specifier: ^5.2.0 + version: 5.2.0(@capacitor/core@7.6.2)(typescript@5.9.3) + blurhash: + specifier: ^2.0.5 + version: 2.0.5 ember-concurrency: specifier: ^5.2.0 version: 5.2.0(@babel/core@7.28.6) ember-lifeline: specifier: ^7.0.0 version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6)) + nostr-idb: + specifier: ^5.0.0 + version: 5.0.0 oauth2-pkce: specifier: ^2.1.3 version: 2.1.3 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 devDependencies: '@babel/core': specifier: ^7.28.5 @@ -764,6 +791,13 @@ packages: '@cacheable/utils@2.3.3': resolution: {integrity: sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==} + '@capacitor/core@7.6.2': + resolution: {integrity: sha512-8HRKEUlYpCOeRec8bCHZwEA4o/E2q5dhHSd0v/Cr6+ume08fZY/gniF+ZCKF+6DO0T/nRaBeNRQn6Up+45J1mg==} + + '@cashu/cashu-ts@2.9.0': + resolution: {integrity: sha512-UesYcBkkJAGPbob2I/SX0aht4MQlfxUVPzI+NQqLKSg2l3m0EnmMznvnXZQnDZnhGy0Hd6rkgwAJTVOYcr0v/Q==} + engines: {node: '>=22.4.0'} + '@cnakazawa/watch@1.0.4': resolution: {integrity: sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==} engines: {node: '>=0.1.95'} @@ -1355,6 +1389,54 @@ packages: '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} + '@noble/ciphers@0.5.3': + resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} + + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@1.1.0': + resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} + + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@1.3.1': + resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==} + engines: {node: '>= 16'} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@noble/secp256k1@1.7.2': + resolution: {integrity: sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1549,6 +1631,30 @@ packages: cpu: [x64] os: [win32] + '@scure/base@1.1.1': + resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + + '@scure/bip32@1.3.1': + resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip32@2.0.1': + resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + + '@scure/bip39@1.2.1': + resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + + '@scure/bip39@2.0.1': + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -1577,6 +1683,9 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/eslint@8.56.12': resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==} @@ -1593,15 +1702,24 @@ packages: resolution: {integrity: sha512-00UxlRaIUvYm4R4W9WYkN8/J+kV8fmOQ7okeH6YFtGWFMt3odD45tpG5yA5wnL7HE6lLgjaTW5n14ju2hl2NNA==} deprecated: This is a stub types definition. glob provides its own type definitions, so you do not need this installed. + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} '@types/minimatch@5.1.2': resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.14.0': resolution: {integrity: sha512-5cHBxFGJx6L4s56Bubp4fglrEpmyJypsqI6RgzMfBHWUJQGWAAi8cWcgetEbZXHYXo9C2Fa4EEds/uSyS4cxmA==} @@ -1620,6 +1738,9 @@ packages: '@types/tv4@1.2.33': resolution: {integrity: sha512-7phCVTXC6Bj50IV1iKOwqGkR4JONJyMbRZnKTSuujv1S/tO9rG5OdCt7BMSjytO+zJmYdn1/I4fd3SH0gtO99g==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/tsconfig-utils@8.53.0': resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1752,6 +1873,24 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + applesauce-content@4.0.0: + resolution: {integrity: sha512-2ZrhM/UCQkcZcAldXJX+KfWAPAtkoTXH5BwPhYpaMw0UgHjWX8mYiy/801PtLBr2gWkKd/Dw1obdNDcPUO3idw==} + + applesauce-core@4.4.2: + resolution: {integrity: sha512-zuZB74Pp28UGM4e8DWbN1atR95xL7ODENvjkaGGnvAjIKvfdgMznU7m9gLxr/Hu+IHOmVbbd4YxwNmKBzCWhHQ==} + + applesauce-core@5.2.0: + resolution: {integrity: sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==} + + applesauce-factory@4.0.0: + resolution: {integrity: sha512-Sqsg+bC7CkRXMxXLkO6YGoKxy/Aqtia9YenasS5qjPOQFmyFMwKRxaHCu6vX6KdpNSABusw0b9Tnn4gTh6CxLw==} + + applesauce-relay@5.2.0: + resolution: {integrity: sha512-ty8PzHenocGdTr3x3It8Ql0rMD9rxB6VGCzGRfL5QF6epdstv2YHKuTyr8QdPBvf7yxfc7oZcMi6djSwNxXqkQ==} + + applesauce-signers@5.2.0: + resolution: {integrity: sha512-7tN7lNK2XERdrRchG5z4rdpMqOacFdv7rRhiS+DLTdlbqeSf0wD6Kj8M3vSqq5f2pVS2cl5Z4E/m5RpWC4PSxg==} + aproba@2.1.0: resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} @@ -1879,6 +2018,9 @@ packages: backburner.js@2.8.0: resolution: {integrity: sha512-zYXY0KvpD7/CWeOLF576mV8S+bQsaIoj/GNLXXB+Eb8SJcQy5lqSjkRrZ0MZhdKUs9QoqmGNIEIe3NQfGiiscQ==} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1911,6 +2053,9 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + blurhash@2.0.5: + resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==} + body-parser@1.20.4: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2085,6 +2230,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + can-symlink@1.0.0: resolution: {integrity: sha512-RbsNrFyhwkx+6psk/0fK/Q9orOUr9VMxohGd8vTa4djf4TGLfblBgUfqZChrZuW0Q+mz2eBPFLusw9Jfukzmhg==} hasBin: true @@ -2112,6 +2261,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} @@ -2157,6 +2309,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2541,9 +2696,16 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decorator-transforms@1.2.1: resolution: {integrity: sha512-UUtmyfdlHvYoX3VSG1w5rbvBQ2r5TX1JsE4hmKU9snleFymadA3VACjl6SRfi9YgBCSjBbfQvR1bs9PRW9yBKw==} @@ -2575,6 +2737,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2591,6 +2757,9 @@ packages: resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@7.0.0: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} @@ -2599,6 +2768,9 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -2859,6 +3031,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-compat-utils@0.5.1: resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} engines: {node: '>=12'} @@ -3022,6 +3198,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + external-editor@3.1.0: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} @@ -3135,6 +3314,10 @@ packages: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3439,6 +3622,9 @@ packages: hash-for-dep@1.5.1: resolution: {integrity: sha512-/dQ/A2cl7FBPI2pO0CANkvuuVi/IFS5oTyJ0PsOb6jW6WbVW1js5qJXMJTNbWHXBIPdFTWFbabjB+mE0d+gelw==} + hash-sum@2.0.0: + resolution: {integrity: sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==} + hashery@1.4.0: resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==} engines: {node: '>=20'} @@ -3526,6 +3712,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + idb@8.0.3: + resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3808,6 +3997,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + light-bolt11-decoder@3.2.0: + resolution: {integrity: sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3825,6 +4017,10 @@ packages: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3883,6 +4079,9 @@ packages: resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} engines: {node: '>=4'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -3929,6 +4128,21 @@ packages: mathml-tag-names@2.1.3: resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} @@ -3975,6 +4189,69 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -4084,6 +4361,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.9: + resolution: {integrity: sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -4129,6 +4411,41 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nostr-idb@5.0.0: + resolution: {integrity: sha512-w5y4AnHefZIwCCL11NryfM2xp3U0Ka4qVNQEYAjnQbPwyoV+bZTdwuPXHCdRDWvhOFP2bZr1WBegcsAmkBjrxQ==} + + nostr-signer-capacitor-plugin@0.0.5: + resolution: {integrity: sha512-/EvqWz71HZ5cWmzvfXWTm48AWZtbeZDbOg3vLwXyXPjnIp1DR7Wurww/Mo41ORNu1DNPlqH20l7kIXKO6vR5og==} + peerDependencies: + '@capacitor/core': ^7.0.0 + + nostr-tools@2.17.4: + resolution: {integrity: sha512-LGqpKufnmR93tOjFi4JZv1BTTVIAVfZAaAa+1gMqVfI0wNz2DnCB6UDXmjVTRrjQHMw2ykbk0EZLPzV5UeCIJw==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-tools@2.19.4: + resolution: {integrity: sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-tools@2.23.3: + resolution: {integrity: sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + npm-package-arg@13.0.2: resolution: {integrity: sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -4255,6 +4572,10 @@ packages: resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} engines: {node: '>=6'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -4385,6 +4706,10 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + portfinder@1.0.38: resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} engines: {node: '>= 10.12'} @@ -4481,6 +4806,11 @@ packages: resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==} engines: {node: '>=20'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -4564,6 +4894,15 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + remotestorage-widget@1.8.1: resolution: {integrity: sha512-HxNu2VvIRW3wzkf5fLEzs56ySQ7+YQbRqyp3CKvmw/G+zKhRsmj06HtFoAcm3B14/nJh2SOAv3LyfKuXfUsKPw==} @@ -4581,6 +4920,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} engines: {node: '>=0.10.5'} @@ -5164,6 +5506,9 @@ packages: resolution: {integrity: sha512-OLWW+Nd99NOM53aZ8ilT/YpEiOo6mXD3F4/wLbARqybSZ3Jb8IxHK5UGVbZaae0wtXAyQshVV+SeqVBik+Fbmw==} engines: {node: '>=8'} + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-declaration-location@1.0.7: resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} peerDependencies: @@ -5259,6 +5604,21 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -5306,6 +5666,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5412,6 +5778,9 @@ packages: when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -5440,6 +5809,10 @@ packages: workerpool@6.5.1: resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5501,6 +5874,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5512,10 +5888,18 @@ packages: resolution: {integrity: sha512-Hv9xxHtsJ9228wNhk03xnlDReUuWVvHwM4rIbjdAXYvHLs17xjuyF50N6XXFMN6N0omBaqgOok/MCK3At9fTAg==} engines: {node: ^4.5 || 6.* || >= 7.*} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -5535,6 +5919,9 @@ packages: zstddec@0.1.0: resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@asamuzakjp/css-color@3.2.0': @@ -6301,6 +6688,17 @@ snapshots: hashery: 1.4.0 keyv: 5.5.5 + '@capacitor/core@7.6.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@cashu/cashu-ts@2.9.0': + dependencies: + '@noble/curves': 2.2.0 + '@noble/hashes': 2.2.0 + '@scure/bip32': 1.7.0 + '@cnakazawa/watch@1.0.4': dependencies: exec-sh: 0.3.6 @@ -6986,6 +7384,42 @@ snapshots: dependencies: eslint-scope: 5.1.1 + '@noble/ciphers@0.5.3': {} + + '@noble/ciphers@2.1.1': {} + + '@noble/curves@1.1.0': + dependencies: + '@noble/hashes': 1.3.1 + + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + + '@noble/hashes@1.3.1': {} + + '@noble/hashes@1.3.2': {} + + '@noble/hashes@1.8.0': {} + + '@noble/hashes@2.0.1': {} + + '@noble/hashes@2.2.0': {} + + '@noble/secp256k1@1.7.2': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7112,6 +7546,40 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true + '@scure/base@1.1.1': {} + + '@scure/base@1.2.6': {} + + '@scure/base@2.0.0': {} + + '@scure/bip32@1.3.1': + dependencies: + '@noble/curves': 1.1.0 + '@noble/hashes': 1.3.2 + '@scure/base': 1.1.1 + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip32@2.0.1': + dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@scure/bip39@1.2.1': + dependencies: + '@noble/hashes': 1.3.2 + '@scure/base': 1.1.1 + + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@sec-ant/readable-stream@0.4.1': {} '@simple-dom/document@1.4.0': @@ -7141,6 +7609,10 @@ snapshots: dependencies: '@types/node': 25.0.7 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/eslint@8.56.12': dependencies: '@types/estree': 1.0.8 @@ -7160,12 +7632,22 @@ snapshots: dependencies: glob: 13.0.0 + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/minimatch@3.0.5': {} '@types/minimatch@5.1.2': {} + '@types/ms@2.1.0': {} + '@types/node@20.14.0': dependencies: undici-types: 5.26.5 @@ -7185,6 +7667,8 @@ snapshots: '@types/tv4@1.2.33': {} + '@types/unist@3.0.3': {} + '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -7327,6 +7811,85 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + applesauce-content@4.0.0(typescript@5.9.3): + dependencies: + '@cashu/cashu-ts': 2.9.0 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + applesauce-core: 4.4.2(typescript@5.9.3) + mdast-util-find-and-replace: 3.0.2 + nostr-tools: 2.17.4(typescript@5.9.3) + remark: 15.0.1 + remark-parse: 11.0.0 + unified: 11.0.5 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + - typescript + + applesauce-core@4.4.2(typescript@5.9.3): + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + debug: 4.4.3 + fast-deep-equal: 3.1.3 + hash-sum: 2.0.0 + light-bolt11-decoder: 3.2.0 + nanoid: 5.1.9 + nostr-tools: 2.17.4(typescript@5.9.3) + rxjs: 7.8.2 + transitivePeerDependencies: + - supports-color + - typescript + + applesauce-core@5.2.0(typescript@5.9.3): + dependencies: + debug: 4.4.3 + fast-deep-equal: 3.1.3 + hash-sum: 2.0.0 + nanoid: 5.1.9 + nostr-tools: 2.19.4(typescript@5.9.3) + rxjs: 7.8.2 + transitivePeerDependencies: + - supports-color + - typescript + + applesauce-factory@4.0.0(typescript@5.9.3): + dependencies: + applesauce-content: 4.0.0(typescript@5.9.3) + applesauce-core: 4.4.2(typescript@5.9.3) + nanoid: 5.1.9 + nostr-tools: 2.23.3(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + + applesauce-relay@5.2.0(typescript@5.9.3): + dependencies: + '@noble/hashes': 1.8.0 + applesauce-core: 5.2.0(typescript@5.9.3) + nanoid: 5.1.9 + nostr-tools: 2.19.4(typescript@5.9.3) + rxjs: 7.8.2 + transitivePeerDependencies: + - supports-color + - typescript + + applesauce-signers@5.2.0(@capacitor/core@7.6.2)(typescript@5.9.3): + dependencies: + '@noble/secp256k1': 1.7.2 + applesauce-core: 5.2.0(typescript@5.9.3) + debug: 4.4.3 + nanoid: 5.1.9 + rxjs: 7.8.2 + optionalDependencies: + nostr-signer-capacitor-plugin: 0.0.5(@capacitor/core@7.6.2) + transitivePeerDependencies: + - '@capacitor/core' + - supports-color + - typescript + aproba@2.1.0: {} are-we-there-yet@3.0.1: @@ -7484,6 +8047,8 @@ snapshots: backburner.js@2.8.0: {} + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@2.0.0: {} @@ -7506,6 +8071,8 @@ snapshots: bluebird@3.7.2: {} + blurhash@2.0.5: {} + body-parser@1.20.4: dependencies: bytes: 3.1.2 @@ -7882,6 +8449,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + can-symlink@1.0.0: dependencies: tmp: 0.0.28 @@ -7910,6 +8479,8 @@ snapshots: chalk@5.6.2: {} + character-entities@2.0.2: {} + chardet@0.7.0: {} chardet@2.1.1: {} @@ -7942,6 +8513,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -8150,8 +8727,14 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + decorator-transforms@1.2.1(@babel/core@7.28.6): dependencies: '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.28.6) @@ -8186,6 +8769,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destroy@1.2.0: {} detect-file@1.0.0: {} @@ -8194,10 +8779,16 @@ snapshots: detect-newline@4.0.1: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + diff@7.0.0: {} diff@8.0.3: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -8762,6 +9353,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-compat-utils@0.5.1(eslint@9.39.2): dependencies: eslint: 9.39.2 @@ -9040,6 +9633,8 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + external-editor@3.1.0: dependencies: chardet: 0.7.0 @@ -9183,6 +9778,11 @@ snapshots: dependencies: locate-path: 3.0.0 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -9583,6 +10183,8 @@ snapshots: transitivePeerDependencies: - supports-color + hash-sum@2.0.0: {} + hashery@1.4.0: dependencies: hookified: 1.15.0 @@ -9693,6 +10295,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb@8.0.3: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -9958,6 +10562,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + light-bolt11-decoder@3.2.0: + dependencies: + '@scure/base': 1.1.1 + lines-and-columns@1.2.4: {} linkify-it@5.0.0: @@ -9976,6 +10584,10 @@ snapshots: p-locate: 3.0.0 path-exists: 3.0.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -10026,6 +10638,8 @@ snapshots: dependencies: chalk: 2.4.2 + longest-streak@3.1.0: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -10078,6 +10692,51 @@ snapshots: mathml-tag-names@2.1.3: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdn-data@2.12.2: {} mdurl@2.0.0: {} @@ -10114,6 +10773,139 @@ snapshots: methods@1.1.2: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -10200,6 +10992,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.9: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -10238,6 +11032,56 @@ snapshots: normalize-path@3.0.0: {} + nostr-idb@5.0.0: + dependencies: + debug: 4.4.3 + idb: 8.0.3 + transitivePeerDependencies: + - supports-color + + nostr-signer-capacitor-plugin@0.0.5(@capacitor/core@7.6.2): + dependencies: + '@capacitor/core': 7.6.2 + optional: true + + nostr-tools@2.17.4(typescript@5.9.3): + dependencies: + '@noble/ciphers': 0.5.3 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@scure/bip32': 1.3.1 + '@scure/bip39': 1.2.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.9.3 + + nostr-tools@2.19.4(typescript@5.9.3): + dependencies: + '@noble/ciphers': 0.5.3 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@scure/bip32': 1.3.1 + '@scure/bip39': 1.2.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.9.3 + + nostr-tools@2.23.3(typescript@5.9.3): + dependencies: + '@noble/ciphers': 2.1.1 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.9.3 + + nostr-wasm@0.1.0: {} + npm-package-arg@13.0.2: dependencies: hosted-git-info: 9.0.2 @@ -10367,6 +11211,10 @@ snapshots: dependencies: p-limit: 2.3.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -10464,6 +11312,8 @@ snapshots: dependencies: find-up: 3.0.0 + pngjs@5.0.0: {} + portfinder@1.0.38: dependencies: async: 3.2.6 @@ -10548,6 +11398,12 @@ snapshots: dependencies: hookified: 1.15.0 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -10648,6 +11504,30 @@ snapshots: dependencies: jsesc: 3.1.0 + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remotestorage-widget@1.8.1: {} remotestoragejs@2.0.0-beta.8: @@ -10674,6 +11554,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requireindex@1.2.0: {} requires-port@1.0.0: {} @@ -11480,6 +12362,8 @@ snapshots: transitivePeerDependencies: - supports-color + trough@2.2.0: {} + ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.3 @@ -11549,6 +12433,35 @@ snapshots: unicorn-magic@0.3.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} universalify@2.0.1: {} @@ -11579,6 +12492,16 @@ snapshots: vary@1.1.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@7.3.1(@types/node@25.0.7)(terser@5.44.1): dependencies: esbuild: 0.27.2 @@ -11671,6 +12594,8 @@ snapshots: when-exit@2.1.5: {} + which-module@2.0.1: {} + which@1.3.1: dependencies: isexe: 2.0.0 @@ -11699,6 +12624,12 @@ snapshots: workerpool@6.5.1: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -11738,6 +12669,8 @@ snapshots: xmlchars@2.2.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -11747,8 +12680,27 @@ snapshots: fs-extra: 4.0.3 lodash.merge: 4.6.2 + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -11766,3 +12718,5 @@ snapshots: yoctocolors@2.1.2: {} zstddec@0.1.0: {} + + zwitch@2.0.4: {} diff --git a/tests/helpers/index.js b/tests/helpers/index.js index ab04c16..7727daa 100644 --- a/tests/helpers/index.js +++ b/tests/helpers/index.js @@ -3,6 +3,7 @@ import { setupRenderingTest as upstreamSetupRenderingTest, setupTest as upstreamSetupTest, } from 'ember-qunit'; +import { setupNostrMocks } from './mock-nostr'; // This file exists to provide wrappers around ember-qunit's // test setup functions. This way, you can easily extend the setup that is @@ -10,6 +11,7 @@ import { function setupApplicationTest(hooks, options) { upstreamSetupApplicationTest(hooks, options); + setupNostrMocks(hooks); // Additional setup for application tests can be done here. // @@ -29,12 +31,14 @@ function setupApplicationTest(hooks, options) { function setupRenderingTest(hooks, options) { upstreamSetupRenderingTest(hooks, options); + setupNostrMocks(hooks); // Additional setup for rendering tests can be done here. } function setupTest(hooks, options) { upstreamSetupTest(hooks, options); + setupNostrMocks(hooks); // Additional setup for unit tests can be done here. } diff --git a/tests/helpers/mock-nostr.js b/tests/helpers/mock-nostr.js new file mode 100644 index 0000000..00d9a5a --- /dev/null +++ b/tests/helpers/mock-nostr.js @@ -0,0 +1,96 @@ +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { Promise } from 'rsvp'; + +export class MockNostrAuthService extends Service { + @tracked pubkey = null; + @tracked signerType = null; + @tracked connectStatus = null; + @tracked connectUri = null; + + get isConnected() { + return false; + } + + get isMobile() { + return false; + } + + get signer() { + return null; + } + + async connectWithExtension() { + return Promise.resolve(); + } + + async connectWithApp() { + return Promise.resolve(); + } + + disconnect() {} +} + +export class MockNostrDataService extends Service { + @tracked profile = null; + @tracked mailboxes = null; + @tracked blossomServers = []; + @tracked placePhotos = []; + + store = { + add: () => {}, + }; + + get activeReadRelays() { + return []; + } + + get activeWriteRelays() { + return []; + } + + get defaultReadRelays() { + return []; + } + + get defaultWriteRelays() { + return []; + } + + get userDisplayName() { + return 'Mock User'; + } + + loadPlacesInBounds() { + return Promise.resolve(); + } + + loadPhotosForPlace() { + return Promise.resolve(); + } + + loadPlacePhotos() { + return Promise.resolve(); + } +} + +export class MockNostrRelayService extends Service { + pool = { + publish: () => Promise.resolve([{ ok: true }]), + subscribe: () => {}, + unsubscribe: () => {}, + close: () => {}, + }; + + async publish() { + return [{ ok: true }]; + } +} + +export function setupNostrMocks(hooks) { + hooks.beforeEach(function () { + this.owner.register('service:nostrAuth', MockNostrAuthService); + this.owner.register('service:nostrData', MockNostrDataService); + this.owner.register('service:nostrRelay', MockNostrRelayService); + }); +} diff --git a/tests/integration/components/place-photos-carousel-test.gjs b/tests/integration/components/place-photos-carousel-test.gjs new file mode 100644 index 0000000..167f48e --- /dev/null +++ b/tests/integration/components/place-photos-carousel-test.gjs @@ -0,0 +1,114 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'marco/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import PlacePhotosCarousel from 'marco/components/place-photos-carousel'; + +module('Integration | Component | place-photos-carousel', function (hooks) { + setupRenderingTest(hooks); + + test('it renders gracefully with no photos', async function (assert) { + this.photos = []; + + await render( + + ); + + assert + .dom('.place-photos-carousel-wrapper') + .doesNotExist('it does not render the wrapper when there are no photos'); + }); + + test('it renders a single photo without navigation chevrons', async function (assert) { + this.photos = [ + { + url: 'photo1.jpg', + thumbUrl: 'thumb1.jpg', + blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH', + ratio: 1.5, + isLandscape: true, + }, + ]; + + await render( + + ); + + assert + .dom('.place-photos-carousel-wrapper') + .exists('it renders the wrapper'); + assert + .dom('.carousel-slide:not(.carousel-placeholder)') + .exists({ count: 1 }, 'it renders one real photo slide'); + assert + .dom('.carousel-placeholder') + .exists({ count: 1 }, 'it renders one placeholder'); + assert + .dom('img.place-header-photo') + .hasAttribute('data-src', 'photo1.jpg', 'it sets the data-src correctly'); + + // There should be no chevrons when there's only 1 photo + assert + .dom('.carousel-nav-btn') + .doesNotExist('it does not render chevrons for a single photo'); + }); + + test('it renders multiple photos and shows chevrons', async function (assert) { + this.photos = [ + { + url: 'photo1.jpg', + thumbUrl: 'thumb1.jpg', + blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH', + ratio: 1.5, + isLandscape: true, + }, + { + url: 'photo2.jpg', + thumbUrl: 'thumb2.jpg', + blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH', + ratio: 1.0, + isLandscape: false, + }, + { + url: 'photo3.jpg', + thumbUrl: 'thumb3.jpg', + blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH', + ratio: 0.8, + isLandscape: false, + }, + ]; + + await render( + + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.dom('.carousel-slide').exists({ count: 3 }, 'it renders all slides'); + assert + .dom('.carousel-nav-btn') + .exists({ count: 2 }, 'it renders both chevrons'); + + // Initially, it shouldn't be able to scroll left + assert + .dom('.carousel-nav-btn.prev') + .hasClass('disabled', 'the prev button is disabled initially'); + assert + .dom('.carousel-nav-btn.next') + .doesNotHaveClass('disabled', 'the next button is enabled initially'); + + // We can't perfectly test native scroll behavior easily in JSDOM/QUnit without mocking the DOM elements' scroll properties, + // but we can test that clicking the next button triggers the scrolling method. + // However, since we mock scrollLeft in the component logic implicitly via template action, let's at least ensure clicking doesn't throw. + await click('.carousel-nav-btn.next'); + + assert.ok(true, 'clicking next button does not throw'); + }); +});