Compare commits

...
12 Commits
Author SHA1 Message Date
raucao 2d547dde87 1.28.0
CI / Lint (push) Successful in 58s
CI / Test (push) Successful in 1m11s
2026-08-19 19:11:00 -06:00
raucao 85334ec15d Merge pull request 'Render data and uploader name for current photo in gallery' (#80) from feature/photo_details into master
CI / Lint (push) Successful in 59s
CI / Test (push) Successful in 1m11s
Reviewed-on: #80
2026-08-20 01:06:13 +00:00
raucao bfcf855020 Render data and uploader name for current photo in gallery
CI / Lint (pull_request) Successful in 1m0s
CI / Test (pull_request) Successful in 1m12s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-08-19 18:05:52 -06:00
raucao 0f0874a07a Fetch, cache, and update profile data 2026-08-19 15:11:31 -06:00
raucao c9a6304926 1.27.1
CI / Lint (push) Successful in 1m0s
CI / Test (push) Successful in 1m13s
2026-08-19 12:44:47 -06:00
raucao 6fb4ecede3 Merge pull request 'Fix WhatsApp links/numbers' (#79) from bugfix/whatsapp_links into master
CI / Lint (push) Successful in 59s
CI / Test (push) Successful in 1m13s
Reviewed-on: #79
2026-08-19 18:42:26 +00:00
raucao 77db625b11 Fix WhatsApp links/numbers
CI / Lint (pull_request) Successful in 1m3s
CI / Test (pull_request) Successful in 1m14s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
Our links would work after opening WhatsApp, but not render e.g. known
business names associated with the number on the intermediate page that
you open the app or web app from
2026-08-19 12:39:50 -06:00
raucao 6351cfbd07 Merge pull request 'Connect Nostr account from My Contributions' (#78) from feature/connect_nostr_from_contributions_timeline into master
CI / Lint (push) Successful in 1m2s
CI / Test (push) Successful in 1m12s
Reviewed-on: #78
2026-08-19 18:36:11 +00:00
raucao b7e46da3e9 Add test for new Nostr connect location
CI / Lint (pull_request) Successful in 1m2s
CI / Test (pull_request) Successful in 1m14s
Release Drafter / Update release notes draft (pull_request) Successful in 7s
2026-08-19 12:32:21 -06:00
raucao 3233150cc4 Connect Nostr account from My Contributions
And upon connection, immediately fetch contributions and render them
2026-08-19 12:24:50 -06:00
raucao 822472a0e6 Merge pull request 'App content/copy/links' (#77) from ui/copy into master
CI / Lint (push) Successful in 1m1s
CI / Test (push) Successful in 1m13s
Reviewed-on: #77
2026-08-19 18:03:58 +00:00
raucao 7ede81fe5f Add hint to empty contributions list
CI / Lint (pull_request) Successful in 1m2s
CI / Test (pull_request) Successful in 1m14s
Release Drafter / Update release notes draft (pull_request) Successful in 5s
2026-08-19 12:02:02 -06:00
23 changed files with 569 additions and 79 deletions
+77 -38
View File
@@ -1,47 +1,86 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import Icon from './icon'; import Icon from './icon';
import ContributionPhoto from './contribution-photo'; import ContributionPhoto from './contribution-photo';
import Modal from './modal';
import NostrConnect from './nostr-connect';
import eq from 'ember-truth-helpers/helpers/eq'; import eq from 'ember-truth-helpers/helpers/eq';
import not from 'ember-truth-helpers/helpers/not'; import not from 'ember-truth-helpers/helpers/not';
import restoreScroll from '../modifiers/restore-scroll'; import restoreScroll from '../modifiers/restore-scroll';
<template> export default class ContributionsTimelineComponent extends Component {
<div class="sidebar"> @tracked isNostrConnectModalOpen = false;
<div class="sidebar-header has-back-btn">
<button type="button" class="back-btn" {{on "click" @onBack}}> @action
<Icon @name="arrow-left" @size={{20}} @color="#333" /> openNostrConnectModal(event) {
</button> event.preventDefault();
<h2 class="sidebar-header-text-centered"> this.isNostrConnectModalOpen = true;
<span class="sidebar-header-icon-wrapper"> }
<Icon @name="activity" @size={{20}} @color="#898989" />
</span> @action
My Contributions closeNostrConnectModal() {
</h2> this.isNostrConnectModalOpen = false;
<button type="button" class="close-btn" {{on "click" @onClose}}> }
<Icon @name="x" @size={{20}} @color="#333" />
</button> @action
onNostrConnected() {
this.closeNostrConnectModal();
this.args.onNostrConnected?.();
}
<template>
<div class="sidebar">
<div class="sidebar-header has-back-btn">
<button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" />
</button>
<h2 class="sidebar-header-text-centered">
<span class="sidebar-header-icon-wrapper">
<Icon @name="activity" @size={{20}} @color="#898989" />
</span>
My Contributions
</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" />
</button>
</div>
<div class="sidebar-content" {{restoreScroll @scrollTop}}>
{{#if @isLoading}}
<div class="sidebar-loading">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{else if (not @isConnected)}}
<p class="empty-state">
<a
href="#"
role="button"
tabindex="0"
{{on "click" this.openNostrConnectModal}}
>Connect your Nostr account</a>
to see your contributions.
</p>
{{else if (not @items.length)}}
<p class="empty-state">No contributions yet. Start by adding photos to
places.</p>
{{else}}
<ul class="contributions-list">
{{#each @items as |item|}}
{{#if (eq item.type "photo")}}
<ContributionPhoto @item={{item}} @onSelect={{@onSelect}} />
{{/if}}
{{/each}}
</ul>
{{/if}}
</div>
</div> </div>
<div class="sidebar-content" {{restoreScroll @scrollTop}}> {{#if this.isNostrConnectModalOpen}}
{{#if @isLoading}} <Modal @onClose={{this.closeNostrConnectModal}}>
<div class="sidebar-loading"> <NostrConnect @onConnect={{this.onNostrConnected}} />
<Icon @name="loading-ring" @size={{24}} @color="#898989" /> </Modal>
</div> {{/if}}
{{else if (not @isConnected)}} </template>
<p class="empty-state"> }
Connect your Nostr account to see your contributions.
</p>
{{else if (not @items.length)}}
<p class="empty-state">No contributions yet.</p>
{{else}}
<ul class="contributions-list">
{{#each @items as |item|}}
{{#if (eq item.type "photo")}}
<ContributionPhoto @item={{item}} @onSelect={{@onSelect}} />
{{/if}}
{{/each}}
</ul>
{{/if}}
</div>
</div>
</template>
+62 -19
View File
@@ -7,10 +7,12 @@ import { service } from '@ember/service';
import { modifier } from 'ember-modifier'; import { modifier } from 'ember-modifier';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import { EventFactory } from 'applesauce-core'; import { EventFactory } from 'applesauce-core';
import or from 'ember-truth-helpers/helpers/or';
import config from 'marco/config/environment'; import config from 'marco/config/environment';
import DropdownMenu from './dropdown-menu'; import DropdownMenu from './dropdown-menu';
import PhotoCarousel from './photo-carousel'; import PhotoCarousel from './photo-carousel';
import Icon from './icon'; import Icon from './icon';
import formatRelativeDate from '../helpers/format-relative-date';
const GalleryContent = <template> const GalleryContent = <template>
<div <div
@@ -25,26 +27,41 @@ const GalleryContent = <template>
class="photo-gallery-content" class="photo-gallery-content"
data-current-event-id={{@currentPhoto.eventId}} data-current-event-id={{@currentPhoto.eventId}}
> >
<div class="actions-btn-container"> <div class="photo-gallery-header">
<DropdownMenu <div class="actions-btn-container">
@iconSize={{24}} <DropdownMenu
@triggerIcon="more-horizontal" @iconSize={{24}}
@iconColor="white" @triggerIcon={{@triggerIcon}}
as |closeMenu| @iconColor="white"
> as |closeMenu|
<button >
class="dropdown-item"
type="button"
{{on "click" (fn @copyEventId closeMenu)}}
>Copy Photo Event ID</button>
{{#if @canDeletePhoto}}
<button <button
class="dropdown-item text-danger" class="dropdown-item"
type="button" type="button"
{{on "click" (fn @deletePhotoTask.perform closeMenu)}} {{on "click" (fn @copyEventId closeMenu)}}
>Delete Photo</button> >Copy Photo Event ID</button>
{{/if}} {{#if @canDeletePhoto}}
</DropdownMenu> <button
class="dropdown-item text-danger"
type="button"
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
>Delete Photo</button>
{{/if}}
</DropdownMenu>
</div>
{{#if (or @uploaderName @photoDate)}}
<div class="photo-gallery-uploader-info">
{{#if @uploaderName}}
<span class="photo-gallery-uploader-name">{{@uploaderName}}</span>
{{/if}}
{{#if @photoDate}}
<span class="photo-gallery-uploader-date">
{{formatRelativeDate @photoDate}}
</span>
{{/if}}
</div>
{{/if}}
</div> </div>
<button <button
@@ -98,6 +115,13 @@ export default class PhotoGallery extends Component {
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0]; @tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
get triggerIcon() {
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
return 'more-vertical';
}
return 'more-horizontal';
}
get isCreator() { get isCreator() {
return ( return (
this.currentPhoto?.pubkey && this.currentPhoto?.pubkey &&
@@ -112,6 +136,19 @@ export default class PhotoGallery extends Component {
); );
} }
get uploaderName() {
const pubkey = this.currentPhoto?.pubkey;
if (!pubkey) return null;
const profile = this.nostrData.getProfile?.(pubkey);
if (!profile) return null;
return profile.displayName || profile.display_name || profile.name || null;
}
get photoDate() {
const ts = this.currentPhoto?.publishedAt || this.currentPhoto?.createdAt;
return ts || null;
}
bindKeyboard = modifier((element, [handler]) => { bindKeyboard = modifier((element, [handler]) => {
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
@@ -131,7 +168,7 @@ export default class PhotoGallery extends Component {
e.target.closest('.thumbnail-strip-container') || e.target.closest('.thumbnail-strip-container') ||
e.target.closest('.carousel-nav-btn') || e.target.closest('.carousel-nav-btn') ||
e.target.closest('.close-btn') || e.target.closest('.close-btn') ||
e.target.closest('.actions-btn-container') e.target.closest('.photo-gallery-header')
) { ) {
return; return;
} }
@@ -268,6 +305,9 @@ export default class PhotoGallery extends Component {
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}} @handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@placeName={{@placeName}} @placeName={{@placeName}}
@selectPhoto={{this.selectPhoto}} @selectPhoto={{this.selectPhoto}}
@uploaderName={{this.uploaderName}}
@photoDate={{this.photoDate}}
@triggerIcon={{this.triggerIcon}}
/> />
{{else}} {{else}}
{{#in-element this.destinationElement}} {{#in-element this.destinationElement}}
@@ -284,6 +324,9 @@ export default class PhotoGallery extends Component {
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}} @handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@placeName={{@placeName}} @placeName={{@placeName}}
@selectPhoto={{this.selectPhoto}} @selectPhoto={{this.selectPhoto}}
@uploaderName={{this.uploaderName}}
@photoDate={{this.photoDate}}
@triggerIcon={{this.triggerIcon}}
/> />
{{/in-element}} {{/in-element}}
{{/if}} {{/if}}
+1 -1
View File
@@ -232,7 +232,7 @@ export default class PlaceDetails extends Component {
return htmlSafe( return htmlSafe(
parts parts
.map((p) => { .map((p) => {
const safeTel = p.replace(/[\s-]+/g, ''); const safeTel = p.replace(/[\s()+.-]/g, '');
return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`; return `<a href="https://wa.me/${safeTel}" target="_blank" rel="noopener noreferrer">${p}</a>`;
}) })
.join('<br>') .join('<br>')
+5
View File
@@ -43,6 +43,11 @@ export default class ContributionsController extends Controller {
this.router.transitionTo(`/place/${item.placeIdentifier}`); this.router.transitionTo(`/place/${item.placeIdentifier}`);
} }
@action
onNostrConnected() {
this.loadContributionsTask.perform(this.nostrAuth.pubkey);
}
@action @action
backToMenu() { backToMenu() {
this.router.transitionTo('menu'); this.router.transitionTo('menu');
+65 -5
View File
@@ -1,10 +1,12 @@
import Service, { service } from '@ember/service'; import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { EMPTY, from } from 'rxjs';
import { EventStore } from 'applesauce-core/event-store'; import { EventStore } from 'applesauce-core/event-store';
import { ProfileModel } from 'applesauce-core/models/profile'; import { ProfileModel } from 'applesauce-core/models/profile';
import { MailboxesModel } from 'applesauce-core/models/mailboxes'; import { MailboxesModel } from 'applesauce-core/models/mailboxes';
import { npubEncode } from 'applesauce-core/helpers/pointers'; import { npubEncode } from 'applesauce-core/helpers/pointers';
import { persistEventsToCache } from 'applesauce-core/helpers/event-cache'; import { persistEventsToCache } from 'applesauce-core/helpers/event-cache';
import { createEventLoaderForStore } from 'applesauce-loaders/loaders';
import { NostrIDB, openDB } from 'nostr-idb'; import { NostrIDB, openDB } from 'nostr-idb';
import { import {
excludeRequiredRelays, excludeRequiredRelays,
@@ -15,9 +17,9 @@ import {
import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage'; import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
const DIRECTORY_RELAYS = [ const DIRECTORY_RELAYS = [
'wss://purplepag.es', 'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.damus.io',
]; ];
const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org']; const DEFAULT_READ_RELAYS = ['wss://nostr.kosmos.org'];
@@ -35,20 +37,36 @@ export default class NostrDataService extends Service {
@tracked blossomServers = []; @tracked blossomServers = [];
@tracked placePhotos = []; @tracked placePhotos = [];
@tracked myContributionEvents = []; @tracked myContributionEvents = [];
@tracked profiles = {};
_profileSub = null; _profileSub = null;
_mailboxesSub = null; _mailboxesSub = null;
_blossomSub = null; _blossomSub = null;
_photosSub = null; _photosSub = null;
_contributionsSub = null; _contributionsSub = null;
_profileModelSubs = new Map();
_requestSub = null; _requestSub = null;
_cachePromise = null; _cachePromise = null;
_currentPlaceEntityId = null;
loadedGeohashPrefixes = new Set(); loadedGeohashPrefixes = new Set();
constructor() { constructor() {
super(...arguments); super(...arguments);
// Set up the event loader synchronously so that any subscription
// (e.g. loadProfiles from a route's afterModel) can auto-fetch even
// before the IndexedDB cache has finished opening. The cacheRequest
// is lazy — it returns EMPTY until `this.cache` is available, so the
// loader falls through to relay hints → lookup relays in the meantime.
createEventLoaderForStore(this.store, this.nostrRelay.pool, {
cacheRequest: (filters) => {
if (!this.cache) return EMPTY;
return from(this.cache.query(filters));
},
lookupRelays: DIRECTORY_RELAYS,
});
// Initialize the IndexedDB cache // Initialize the IndexedDB cache
this._cachePromise = openDB('applesauce-events').then(async (db) => { this._cachePromise = openDB('applesauce-events').then(async (db) => {
this.cache = new NostrIDB(db, { this.cache = new NostrIDB(db, {
@@ -212,19 +230,31 @@ export default class NostrDataService extends Service {
} }
async loadPhotosForPlace(place) { async loadPhotosForPlace(place) {
const entityId =
place && place.osmId && place.osmType
? `osm:${place.osmType}:${place.osmId}`
: null;
// Skip the full reset if we're loading the same place again (e.g. from
// checkUpdates calling selectPlace a second time). This prevents tearing
// down timeline and profile subscriptions that are still in-flight.
if (entityId && entityId === this._currentPlaceEntityId) {
return;
}
if (this._photosSub) { if (this._photosSub) {
this._photosSub.unsubscribe(); this._photosSub.unsubscribe();
this._photosSub = null; this._photosSub = null;
} }
this.placePhotos = []; this.placePhotos = [];
this._clearProfileSubs();
this._currentPlaceEntityId = entityId;
if (!place || !place.osmId || !place.osmType) { if (!entityId) {
return; return;
} }
const entityId = `osm:${place.osmType}:${place.osmId}`;
// Setup reactive store query // Setup reactive store query
this._photosSub = this.store this._photosSub = this.store
.timeline([ .timeline([
@@ -235,6 +265,8 @@ export default class NostrDataService extends Service {
]) ])
.subscribe((events) => { .subscribe((events) => {
this.placePhotos = events; this.placePhotos = events;
const pubkeys = [...new Set(events.map((e) => e.pubkey))];
this.loadProfiles(pubkeys);
}); });
try { try {
@@ -331,6 +363,33 @@ export default class NostrDataService extends Service {
}); });
} }
loadProfiles(pubkeys) {
const newPubkeys = pubkeys.filter(
(pk) => pk && !this._profileModelSubs.has(pk)
);
for (const pubkey of newPubkeys) {
const sub = this.store
.model(ProfileModel, pubkey)
.subscribe((profileContent) => {
this.profiles = { ...this.profiles, [pubkey]: profileContent };
});
this._profileModelSubs.set(pubkey, sub);
}
}
getProfile(pubkey) {
return this.profiles[pubkey];
}
_clearProfileSubs() {
for (const sub of this._profileModelSubs.values()) {
sub.unsubscribe();
}
this._profileModelSubs.clear();
this.profiles = {};
}
async loadProfile(pubkey) { async loadProfile(pubkey) {
if (!pubkey) return; if (!pubkey) return;
@@ -471,6 +530,7 @@ export default class NostrDataService extends Service {
willDestroy() { willDestroy() {
super.willDestroy(...arguments); super.willDestroy(...arguments);
this._cleanupSubscriptions(); this._cleanupSubscriptions();
this._clearProfileSubs();
if (this._stopPersisting) { if (this._stopPersisting) {
this._stopPersisting(); this._stopPersisting();
+62 -6
View File
@@ -2151,17 +2151,73 @@ button.create-place {
background: #f0f0f0; background: #f0f0f0;
} }
/* Actions button in photo gallery */ /* Photo gallery header (actions button + uploader info) */
.photo-gallery-overlay .actions-btn-container { .photo-gallery-overlay .photo-gallery-header {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
left: 0.5rem; left: 1rem;
width: 48px;
height: 48px;
z-index: 10; z-index: 10;
display: flex; display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.photo-gallery-overlay .photo-gallery-header .actions-btn-container {
position: relative;
z-index: 2;
height: 48px;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
flex-shrink: 0;
}
/* Uploader info (name + date) in photo gallery */
.photo-gallery-overlay .photo-gallery-uploader-info {
color: rgb(255 255 255 / 90%);
display: flex;
flex-direction: column;
gap: 0.1rem;
pointer-events: none;
text-shadow: 0 1px 2px rgb(0 0 0 / 60%);
max-width: calc(100vw - 4rem);
overflow: hidden;
}
.photo-gallery-overlay .photo-gallery-uploader-name {
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.photo-gallery-overlay .photo-gallery-uploader-date {
font-size: 0.8rem;
color: rgb(255 255 255 / 70%);
white-space: nowrap;
}
@media (width <= 768px) {
.photo-gallery-overlay .photo-gallery-header {
flex-direction: row;
align-items: flex-start;
gap: 0.5rem;
left: 0.5rem;
}
.photo-gallery-overlay .photo-gallery-header .actions-btn-container {
width: 48px;
justify-content: center;
}
.photo-gallery-overlay .photo-gallery-uploader-info {
flex-direction: row;
align-items: center;
gap: 0.35rem;
align-self: center;
}
} }
/* Snappy slide-in from left (Desktop) */ /* Snappy slide-in from left (Desktop) */
+1
View File
@@ -10,6 +10,7 @@ import ContributionsTimeline from '#components/contributions-timeline';
@onSelect={{@controller.selectContribution}} @onSelect={{@controller.selectContribution}}
@onBack={{@controller.backToMenu}} @onBack={{@controller.backToMenu}}
@onClose={{@controller.close}} @onClose={{@controller.close}}
@onNostrConnected={{@controller.onNostrConnected}}
/> />
{{/if}} {{/if}}
</template> </template>
+3
View File
@@ -94,11 +94,14 @@ function parsePhotoFromEvent(event) {
if (!url) return null; if (!url) return null;
const placeIdentifier = tags.find((t) => t[0] === 'i')?.[1]; const placeIdentifier = tags.find((t) => t[0] === 'i')?.[1];
const publishedAtRaw = tags.find((t) => t[0] === 'published_at')?.[1];
const publishedAt = publishedAtRaw ? Number(publishedAtRaw) : null;
return { return {
eventId: event.id, eventId: event.id,
pubkey: event.pubkey, pubkey: event.pubkey,
createdAt: event.created_at, createdAt: event.created_at,
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
url, url,
thumbUrl, thumbUrl,
blurhash, blurhash,
+5
View File
@@ -69,6 +69,10 @@ export function parsePlacePhotos(events) {
let aspectRatio = 16 / 9; // default let aspectRatio = 16 / 9; // default
let altText = null; let altText = null;
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1]; let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
const publishedAtRaw = event.tags.find(
(t) => t[0] === 'published_at'
)?.[1];
const publishedAt = publishedAtRaw ? Number(publishedAtRaw) : null;
for (const tag of imeta.slice(1)) { for (const tag of imeta.slice(1)) {
if (tag.startsWith('url ')) { if (tag.startsWith('url ')) {
@@ -98,6 +102,7 @@ export function parsePlacePhotos(events) {
eventId: event.id, eventId: event.id,
pubkey: event.pubkey, pubkey: event.pubkey,
createdAt: event.created_at, createdAt: event.created_at,
publishedAt: publishedAt && publishedAt > 0 ? publishedAt : null,
url, url,
thumbUrl, thumbUrl,
blurhash, blurhash,
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.27.0", "version": "1.28.0",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "repository": {
@@ -105,6 +105,7 @@
"@noble/hashes": "^2.3.0", "@noble/hashes": "^2.3.0",
"@waysidemapping/pinhead": "^15.25.0", "@waysidemapping/pinhead": "^15.25.0",
"applesauce-core": "^6.2.0", "applesauce-core": "^6.2.0",
"applesauce-loaders": "^6.2.0",
"applesauce-relay": "^6.2.1", "applesauce-relay": "^6.2.1",
"applesauce-signers": "^6.2.2", "applesauce-signers": "^6.2.2",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
+15
View File
@@ -20,6 +20,9 @@ importers:
applesauce-core: applesauce-core:
specifier: ^6.2.0 specifier: ^6.2.0
version: 6.2.0(supports-color@10.2.2)(typescript@5.9.3) version: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
applesauce-loaders:
specifier: ^6.2.0
version: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
applesauce-relay: applesauce-relay:
specifier: ^6.2.1 specifier: ^6.2.1
version: 6.2.1(supports-color@10.2.2)(typescript@5.9.3) version: 6.2.1(supports-color@10.2.2)(typescript@5.9.3)
@@ -2636,6 +2639,9 @@ packages:
applesauce-core@6.2.0: applesauce-core@6.2.0:
resolution: {integrity: sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==} resolution: {integrity: sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==}
applesauce-loaders@6.2.0:
resolution: {integrity: sha512-isU2BuoVFhugv4WxdbUFrqm7mCNgsGrMU4BYcNqMm8qAVpVb3L7K3I1hlkIZWwOvNTkhoYO/u0utgwnkQj4Hrg==}
applesauce-relay@6.2.1: applesauce-relay@6.2.1:
resolution: {integrity: sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg==} resolution: {integrity: sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg==}
@@ -9354,6 +9360,15 @@ snapshots:
- supports-color - supports-color
- typescript - typescript
applesauce-loaders@6.2.0(supports-color@10.2.2)(typescript@5.9.3):
dependencies:
applesauce-core: 6.2.0(supports-color@10.2.2)(typescript@5.9.3)
nanoid: 5.1.9
rxjs: 7.8.2
transitivePeerDependencies:
- supports-color
- typescript
applesauce-relay@6.2.1(supports-color@10.2.2)(typescript@5.9.3): applesauce-relay@6.2.1(supports-color@10.2.2)(typescript@5.9.3):
dependencies: dependencies:
'@noble/hashes': 2.3.0 '@noble/hashes': 2.3.0
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6"> <meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png"> <meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-MT6QR2E9.js"></script> <script type="module" crossorigin src="/assets/main-C4QF_Hr3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-sIiovt6q.css"> <link rel="stylesheet" crossorigin href="/assets/main-Cal_p7lY.css">
</head> </head>
<body> <body>
<div id="modal-portal"></div> <div id="modal-portal"></div>
+5
View File
@@ -36,11 +36,16 @@ export class MockNostrDataService extends Service {
@tracked mailboxes = null; @tracked mailboxes = null;
@tracked blossomServers = []; @tracked blossomServers = [];
@tracked placePhotos = []; @tracked placePhotos = [];
@tracked profiles = {};
store = { store = {
add: () => {}, add: () => {},
}; };
getProfile(pubkey) {
return this.profiles[pubkey];
}
get activeReadRelays() { get activeReadRelays() {
return []; return [];
} }
@@ -1,14 +1,23 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers'; import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers'; import { render, click } from '@ember/test-helpers';
import Service from '@ember/service';
import ContributionsTimeline from 'marco/components/contributions-timeline'; import ContributionsTimeline from 'marco/components/contributions-timeline';
import { setupNostrMocks } from 'marco/tests/helpers/mock-nostr';
function noop() {} function noop() {}
class MockToastService extends Service {
show() {}
}
module('Integration | Component | contributions-timeline', function (hooks) { module('Integration | Component | contributions-timeline', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
setupNostrMocks(hooks);
hooks.beforeEach(function () { hooks.beforeEach(function () {
this.owner.register('service:toast', MockToastService);
this.noop = noop; this.noop = noop;
this.emptyItems = []; this.emptyItems = [];
}); });
@@ -48,6 +57,28 @@ module('Integration | Component | contributions-timeline', function (hooks) {
assert.dom('.empty-state').includesText('Connect your Nostr account'); assert.dom('.empty-state').includesText('Connect your Nostr account');
}); });
test('clicking "Connect your Nostr account" opens the Nostr connect modal', async function (assert) {
await render(
<template>
<div id="modal-portal"></div>
<ContributionsTimeline
@items={{this.emptyItems}}
@isLoading={{false}}
@isConnected={{false}}
@onBack={{this.noop}}
@onClose={{this.noop}}
@onSelect={{this.noop}}
/>
</template>
);
assert.dom('.nostr-connect-modal').doesNotExist();
await click('.empty-state a');
assert.dom('.nostr-connect-modal').exists();
});
test('it renders an empty state when connected but no contributions', async function (assert) { test('it renders an empty state when connected but no contributions', async function (assert) {
await render( await render(
<template> <template>
@@ -339,4 +339,143 @@ module('Integration | Component | photo-gallery', function (hooks) {
await triggerKeyEvent(document, 'keydown', 'Escape'); await triggerKeyEvent(document, 'keydown', 'Escape');
assert.ok(closed, 'gallery was closed on escape key'); assert.ok(closed, 'gallery was closed on escape key');
}); });
test('it renders uploader name and date when profile is loaded', async function (assert) {
const displayName = 'Alice';
const publishedAt = Math.floor(Date.now() / 1000) - 60 * 60 * 24; // 1 day ago
this.nostrData.profiles = {
[USER_A]: { displayName, display_name: 'ignored', name: 'ignored' },
};
this.photos = [
{
eventId: 'event1',
pubkey: USER_A,
placeIdentifier: 'osm:node:12345',
url: 'https://example.com/photo.jpg',
publishedAt,
createdAt: publishedAt + 10,
},
];
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
assert
.dom('.photo-gallery-uploader-name')
.hasText(displayName, 'uploader name is rendered');
assert
.dom('.photo-gallery-uploader-date')
.exists('date element is rendered');
});
test('it prefers published_at over created_at for the date', async function (assert) {
this.nostrData.profiles = {
[USER_A]: { displayName: 'Alice' },
};
const publishedAt = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 3; // 3 days ago
const createdAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
this.photos = [
{
eventId: 'event1',
pubkey: USER_A,
url: 'https://example.com/photo.jpg',
publishedAt,
createdAt,
},
];
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
// Should show "3 days ago" (publishedAt), not "just now" (createdAt)
assert.dom('.photo-gallery-uploader-date').hasText('3 days ago');
});
test('it does not render uploader info when profile is missing', async function (assert) {
this.nostrData.profiles = {};
this.photos = [
{
eventId: 'event1',
pubkey: USER_A,
url: 'https://example.com/photo.jpg',
createdAt: Math.floor(Date.now() / 1000),
},
];
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
assert
.dom('.photo-gallery-uploader-name')
.doesNotExist('uploader name is not rendered without profile');
// Date should still render since createdAt is present
assert.dom('.photo-gallery-uploader-date').exists('date is still rendered');
});
test('it falls back through displayName -> display_name -> name', async function (assert) {
this.nostrData.profiles = {
[USER_A]: { display_name: 'Bob', name: 'Robert' },
};
this.photos = [
{
eventId: 'event1',
pubkey: USER_A,
url: 'https://example.com/photo.jpg',
createdAt: Math.floor(Date.now() / 1000) - 60,
},
];
this.selectedPhoto = this.photos[0];
await render(
<template>
<div id="test-container">
<div id="modal-portal"></div>
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedPhoto}}
/>
</div>
</template>
);
assert
.dom('.photo-gallery-uploader-name')
.hasText('Bob', 'falls back to display_name when displayName is missing');
});
}); });
@@ -320,14 +320,14 @@ module('Integration | Component | place-details', function (hooks) {
const links = whatsappBlock.querySelectorAll('a[href^="https://wa.me/"]'); const links = whatsappBlock.querySelectorAll('a[href^="https://wa.me/"]');
assert.strictEqual(links.length, 2, 'Rendered exactly 2 WhatsApp links'); assert.strictEqual(links.length, 2, 'Rendered exactly 2 WhatsApp links');
// Verify it stripped the dashes and spaces for the wa.me URL // Verify it stripped the dashes, spaces and leading plus for the wa.me URL
assert.strictEqual( assert.strictEqual(
links[0].getAttribute('href'), links[0].getAttribute('href'),
'https://wa.me/+44987654321' 'https://wa.me/44987654321'
); );
assert.strictEqual( assert.strictEqual(
links[1].getAttribute('href'), links[1].getAttribute('href'),
'https://wa.me/+12345678900' 'https://wa.me/12345678900'
); );
// Verify it kept the dashes and spaces for the visible text // Verify it kept the dashes and spaces for the visible text
@@ -335,6 +335,33 @@ module('Integration | Component | place-details', function (hooks) {
assert.dom(links[1]).hasText('+1 234-567 8900'); assert.dom(links[1]).hasText('+1 234-567 8900');
}); });
test('it strips parentheses, dots and the leading plus from whatsapp hrefs', async function (assert) {
const place = {
title: 'Chat Shop',
osmTags: {
whatsapp: '+504-9850-3802;(504) 9850.3802;+1.234.567.8900',
},
};
await render(<template><PlaceDetails @place={{place}} /></template>);
const links = this.element.querySelectorAll('a[href^="https://wa.me/"]');
assert.strictEqual(links.length, 3, 'Rendered exactly 3 WhatsApp links');
assert.strictEqual(
links[0].getAttribute('href'),
'https://wa.me/50498503802'
);
assert.strictEqual(
links[1].getAttribute('href'),
'https://wa.me/50498503802'
);
assert.strictEqual(
links[2].getAttribute('href'),
'https://wa.me/12345678900'
);
});
test('it renders correct OpenStreetMap link for an OSM place', async function (assert) { test('it renders correct OpenStreetMap link for an OSM place', async function (assert) {
const place = { const place = {
title: 'OSM Place', title: 'OSM Place',
+60
View File
@@ -210,6 +210,66 @@ module('Unit | Utility | nostr', function () {
assert.strictEqual(photos[0].alt, null); assert.strictEqual(photos[0].alt, null);
}); });
test('parsePlacePhotos extracts published_at when present', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 200,
tags: [
['i', 'osm:node:123'],
['published_at', '100'],
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].publishedAt, 100);
assert.strictEqual(photos[0].createdAt, 200);
});
test('parsePlacePhotos sets publishedAt to null when not present', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 200,
tags: [
['i', 'osm:node:123'],
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].publishedAt, null);
});
test('parsePlacePhotos ignores invalid published_at values', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 200,
tags: [
['i', 'osm:node:123'],
['published_at', 'not-a-number'],
['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].publishedAt, null);
});
test('uniqNormalizedRelays returns normalized unique relays', function (assert) { test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
const relays = uniqNormalizedRelays([ const relays = uniqNormalizedRelays([
'Relay.example.com', 'Relay.example.com',