fix(journey): stop the entry views from shifting while photos and providers load

Opening an entry on the share page showed the hero at no height until the
original arrived, then pushed the story down, and the lightbox stayed black
until the full photo had loaded. The hero now reserves the photo's proportions
(4:3 when none were recorded) with its thumbnail behind it, and the lightbox
shows the thumbnail and a spinner until the original is in.

The phone entry sheet probed the photo providers again on every open and added
the External photos button once the answer came, squeezing the icons of the
other two buttons. The journey screen already knows the connected providers, so
it hands them to the sheet, and the icons keep their size.

The picked neutral mood was a grey tint on a grey sheet; a picked mood is now
framed in its full colour and announced as pressed.

Closes #2907
Closes #2908
Closes #2905
This commit is contained in:
Maurice
2026-10-30 21:15:38 +01:00
parent 94a9cbf070
commit b953ece4b3
12 changed files with 180 additions and 111 deletions
+2 -2
View File
@@ -4,12 +4,12 @@
"@typescript-eslint/no-misused-promises": 847,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 146,
"@typescript-eslint/no-explicit-any": 144,
"trek/no-swallowed-catch": 129,
"max-params": 32,
"trek/store-without-selector": 67,
"react-hooks/refs": 555,
"react-hooks/set-state-in-effect": 194,
"react-hooks/set-state-in-effect": 193,
"@typescript-eslint/no-floating-promises": 164,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
@@ -1,4 +1,4 @@
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-022
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -136,6 +136,18 @@ describe('MobileEntryView', () => {
expect(container.querySelector('svg.lucide-camera')).toBeNull()
})
it('FE-COMP-JENTRYVIEW-021: the hero holds the proportions of the photo and shows its thumbnail before the original arrives (#2907)', () => {
const { container } = renderView(buildEntry({ photos: [{ ...buildPhoto(4), width: 3000, height: 2000 }] }))
const hero = container.querySelector('img') as HTMLImageElement
expect(hero.style.aspectRatio).toBe('3000 / 2000')
expect(hero.style.backgroundImage).toContain('/api/photos/4/thumbnail')
})
it('FE-COMP-JENTRYVIEW-022: a photo without recorded dimensions still reserves a 4:3 hero', () => {
const { container } = renderView(buildEntry({ photos: [buildPhoto(4)] }))
expect((container.querySelector('img') as HTMLImageElement).style.aspectRatio).toBe('4 / 3')
})
it('FE-COMP-JENTRYVIEW-008: several photos add a counter and a thumbnail strip', () => {
const { container } = renderView(buildEntry({ photos: [buildPhoto(4), buildPhoto(5), buildPhoto(6)] }))
expect(screen.getByText('3 photos')).toBeInTheDocument()
@@ -4,6 +4,7 @@ import {
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown,
} from 'lucide-react'
import type { CSSProperties } from 'react'
import JournalBody from './JournalBody'
import { useTranslation } from '../../i18n'
import { formatLocationName } from '../../utils/formatters'
@@ -39,6 +40,22 @@ function photoUrl(
return `/api/photos/${p.photo_id}/${size}`
}
/**
* The hero's box before its picture arrives (#2907): the photo's own proportions
* when the upload recorded them, else 4:3, so the story below does not jump down
* once the original has loaded. The thumbnail, usually cached by the strip or the
* feed already, fills that box meanwhile.
*/
function heroPlaceholderStyle(photo: JourneyPhoto, thumbUrl: string): CSSProperties {
const ratio = photo.width && photo.height ? `${photo.width} / ${photo.height}` : '4 / 3'
return {
aspectRatio: ratio,
backgroundImage: `url("${encodeURI(thumbUrl)}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
}
interface Props {
entry: JourneyEntry
readOnly?: boolean
@@ -114,8 +131,10 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
<img
src={photoUrl(photos[0], photos[0].media_type === 'video' ? 'thumbnail' : 'original', publicPhotoUrl)}
alt=""
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
decoding="async"
className={`block w-full max-h-[50vh] bg-surface-secondary ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
}`}
style={heroPlaceholderStyle(photos[0], photoUrl(photos[0], 'thumbnail', publicPhotoUrl))}
/>
)}
@@ -143,7 +162,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
key={p.id || i}
type="button"
aria-label={p.caption || t('journey.photos')}
className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-transparent'
className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-surface-secondary'
}`}
onClick={() => onPhotoClick(photos, i)}
>
@@ -1,4 +1,4 @@
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-027
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-029
// Plyr needs a real media pipeline, so the player is stubbed here.
vi.mock('./VideoPlayer', () => ({
@@ -326,4 +326,28 @@ describe('PhotoLightbox', () => {
expect(screen.getByText('Google', { selector: 'p' })).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-028: the thumbnail and a spinner stand in until the original has loaded (#2907)', () => {
const { container } = render(
<PhotoLightbox photos={[{ id: 'a', src: '/a/original', thumbSrc: '/a/thumbnail' }]} onClose={vi.fn()} />,
);
const placeholder = screen.getByTestId('lightbox-loading');
expect(placeholder.querySelector('img')).toHaveAttribute('src', '/a/thumbnail');
expect(placeholder.querySelector('svg.animate-spin')).toBeInTheDocument();
fireEvent.load(container.ownerDocument.querySelector('img[src="/a/original"]') as HTMLImageElement);
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-029: the next photo waits for its own original, and a failed one drops the spinner too', () => {
render(
<PhotoLightbox photos={[{ id: 'a', src: '/a.jpg' }, { id: 'b', src: '/b.jpg' }]} onClose={vi.fn()} />,
);
fireEvent.load(document.querySelector('img[src="/a.jpg"]') as HTMLImageElement);
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByTestId('lightbox-loading')).toBeInTheDocument();
fireEvent.error(document.querySelector('img[src="/b.jpg"]') as HTMLImageElement);
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react'
import { ChevronLeft, ChevronRight, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react'
import VideoPlayer from './VideoPlayerLazy'
import { usePhotoZoom } from './usePhotoZoom'
import { Tooltip } from '../shared/Tooltip'
@@ -8,6 +8,12 @@ import { isRtlLanguage, useTranslation } from '../../i18n'
interface LightboxPhoto {
id: string
src: string
/**
* A small rendition of the same photo, usually cached already by the grid that
* opened the lightbox. It stands in while the original loads, which on a slow
* server or connection can take seconds (#2907).
*/
thumbSrc?: string
caption?: string | null
/** Where the caption's credit points to (a photo's source page), opened in a new tab. */
captionHref?: string | null
@@ -52,6 +58,9 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
// Zoom starts over on every photo (#1484).
const zoom = usePhotoZoom(photo?.id)
const isImage = !!photo && photo.mediaType !== 'video'
// The id whose original has finished (or failed) loading; any other photo is still on its way.
const [loadedId, setLoadedId] = useState<string | null>(null)
const loading = isImage && loadedId !== photo.id
const hasPrev = idx > 0
const hasNext = idx < photos.length - 1
@@ -187,6 +196,8 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
src={photo.src}
alt={photo.caption || ''}
draggable={false}
onLoad={() => setLoadedId(photo.id)}
onError={() => setLoadedId(photo.id)}
{...zoom.mouse}
style={{
maxWidth: '92vw', maxHeight: '92vh',
@@ -198,6 +209,17 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
/>
)}
{loading && (
<div data-testid="lightbox-loading" aria-hidden="true" style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
{photo.thumbSrc && (
<img src={photo.thumbSrc} alt="" draggable={false} style={{ width: '92vw', height: '92vh', objectFit: 'contain', borderRadius: 4 }} />
)}
<span style={{ ...ROUND_BTN, position: 'absolute', cursor: 'default' }}>
<Loader2 size={18} className="animate-spin" />
</span>
</div>
)}
{/* Next button */}
{hasNext && (
<button type="button" onClick={next} aria-label={t('journey.lightbox.next')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{
@@ -258,6 +258,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
photos: photos.map(p => ({
id: p.id,
src: photoUrl(p, 'original'),
thumbSrc: photoUrl(p, 'thumbnail'),
caption: p.caption ?? null,
provider: p.provider,
asset_id: p.asset_id,
@@ -590,6 +591,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
showWeather={current.show_weather !== 0}
userId={useAuthStore.getState().user?.id || 0}
trips={current.trips}
photoProviders={availableProviders}
onMoveEarlier={
canEditEntries && editingEntry.id !== 0 && editingEntry.type !== 'skeleton' && dayNeighbours(editingEntry).canMoveUp
? () => { void moveWithinDay(editingEntry, -1) }
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useRef, useState, type CSSProperties } from 'react'
import { Briefcase, Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
@@ -9,13 +9,23 @@ import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicke
import { isVideoFile } from '../../../utils/videoPoster'
import type { ResilientResult, UploadProgress } from '../../../utils/uploadQueue'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../../store/journeyStore'
import { useAddonStore } from '../../../store/addonStore'
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import JournalBody from '../../../components/Journey/JournalBody'
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
import { MOBILE_MOODS, MOBILE_WEATHERS } from './mobileJourneyMeta'
import { useJourneyEntryForm, type PendingProviderGroup } from '../../../components/Journey/useJourneyEntryForm'
const NO_PROVIDERS: { id: string; name: string }[] = []
/**
* A picked mood chip: its colour as the frame and the label, a light wash of it
* behind. The frame is the full colour because a tint alone left the grey neutral
* mood looking like the chips at rest on a dark sheet (#2905).
*/
function moodChipActiveStyle(color: string): CSSProperties {
return { background: `${color}33`, color, borderColor: color }
}
const PRO_COLOR = '#2FA37A'
const CON_COLOR = '#D6273B'
@@ -38,6 +48,12 @@ interface MJourneyEntrySheetProps {
readOnly?: boolean
userId?: number
trips?: JourneyTrip[]
/**
* The photo providers (Immich, Synology Photos) connected for this user. The
* journey screen asks once when it opens, so the sheet has the answer the
* moment it appears instead of probing again and growing a button later (#2908).
*/
photoProviders?: { id: string; name: string }[]
/** The optional fields this journey still keeps (discussion #2299). Values are never cleared. */
showVerdict?: boolean
showMood?: boolean
@@ -68,7 +84,7 @@ interface MJourneyEntrySheetProps {
* search, mood (4), weather (6) and tags. Read-only for viewer contributors.
*/
export default function MJourneyEntrySheet({
entry, galleryPhotos, quickCapture = false, readOnly = false, userId = 0, trips = [],
entry, galleryPhotos, quickCapture = false, readOnly = false, userId = 0, trips = [], photoProviders: providers = NO_PROVIDERS,
showVerdict = true, showMood = true, showWeather = true, onMoveEarlier, onMoveLater, onDismiss,
onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDelete, onDone,
}: MJourneyEntrySheetProps) {
@@ -88,41 +104,10 @@ export default function MJourneyEntrySheet({
})
const [showExternal, setShowExternal] = useState(false)
const [externalProvider, setExternalProvider] = useState<string | null>(null)
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
const [captureOnly, setCaptureOnly] = useState(quickCapture)
const fileRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
// The addon list is already in the store — this only probes which of the
// photo providers is actually connected for this user.
const addons = useAddonStore(s => s.addons)
const addonsLoaded = useAddonStore(s => s.loaded)
const photoProviders = useMemo(
() => addons.filter(a => a.type === 'photo_provider' && a.enabled).map(a => ({ id: a.id, name: a.name })),
[addons],
)
useEffect(() => {
if (readOnly) return
// App.tsx loads the addon list on boot, so this only reacts to it. Kicking off
// a second load from here would race the other consumers, and loadAddons
// overwrites the list unconditionally when it lands.
if (!addonsLoaded) return
if (photoProviders.length === 0) { setProviders([]); return }
let active = true
;void (async () => {
const connected: { id: string; name: string }[] = []
for (const provider of photoProviders) {
try {
const res = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' })
if (res.ok && (await res.json()).connected) connected.push(provider)
} catch { /* provider stays hidden */ }
}
if (active) setProviders(connected)
})()
return () => { active = false }
}, [readOnly, addonsLoaded, photoProviders])
const activeProvider = externalProvider || providers[0]?.id || null
const providerExistingAssetIds = providerAssetIds(activeProvider)
@@ -191,13 +176,13 @@ export default function MJourneyEntrySheet({
>
{uploadProgress ? (
<>
<span className="h-[14px] w-[14px] animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-muted" />
{t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}
<span className="h-[14px] w-[14px] shrink-0 animate-spin rounded-full border-2 border-[color:var(--m-rowbr)] border-t-m-muted" />
<span className="truncate">{t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</span>
</>
) : (
<>
{captureOnly ? <Camera size={14} strokeWidth={2.2} /> : <Plus size={14} strokeWidth={2.2} />}
{captureOnly ? t('journey.photo.add') : t('journey.editor.uploadPhotos')}
{captureOnly ? <Camera size={14} strokeWidth={2.2} className="shrink-0" /> : <Plus size={14} strokeWidth={2.2} className="shrink-0" />}
<span className="truncate">{captureOnly ? t('journey.photo.add') : t('journey.editor.uploadPhotos')}</span>
</>
)}
</button>
@@ -215,12 +200,14 @@ export default function MJourneyEntrySheet({
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
}`}
>
<Image size={14} strokeWidth={2} />
{captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')}
<Image size={14} strokeWidth={2} className="shrink-0" />
<span className="truncate">{captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')}</span>
</button>
{/* Immich/Synology, the same source the desktop editor offers (#1808).
Only shown once a provider is actually connected — on a phone a
button that can only say "nothing connected" is not worth its width. */}
Only shown once a provider is actually connected: on a phone a
button that can only say "nothing connected" is not worth its width.
The icons keep their size and the labels truncate, so a third
button never squeezes the other two (#2908). */}
{!captureOnly && providers.length > 0 && (
<button
type="button"
@@ -232,8 +219,8 @@ export default function MJourneyEntrySheet({
: 'border-dashed border-[color:var(--m-rowbr)] text-m-muted'
}`}
>
<Images size={14} strokeWidth={2} />
{t('journey.editor.externalPhotos')}
<Images size={14} strokeWidth={2} className="shrink-0" />
<span className="truncate">{t('journey.editor.externalPhotos')}</span>
</button>
)}
</div>
@@ -627,11 +614,12 @@ export default function MJourneyEntrySheet({
key={m.id}
type="button"
disabled={readOnly}
aria-pressed={active}
onClick={() => setMood(active ? '' : m.id)}
className={`flex items-center gap-[5px] rounded-full border px-3 py-[7px] text-[0.71875rem] font-semibold ${
active ? '' : 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
}`}
style={active ? { background: `${m.color}24`, color: m.color, borderColor: `${m.color}4D` } : undefined}
style={active ? moodChipActiveStyle(m.color) : undefined}
>
<m.icon size={13} strokeWidth={2.2} />
{t(m.labelKey)}
+4 -4
View File
@@ -213,7 +213,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
onClose={() => setViewingEntry(null)}
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
/>
)}
@@ -602,7 +602,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
readOnly={!canEditEntries}
onEdit={() => setEditingEntry(entry)}
onDelete={() => setDeleteTarget(entry)}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
/>
)}
</div>
@@ -656,7 +656,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
userId={useAuthStore.getState().user?.id || 0}
trips={current.trips}
canEdit={canEditEntries}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })}
onRefresh={() => loadJourney(Number(id))}
onAddProviderPhotos={addPickedProviderPhotos}
/>
@@ -735,7 +735,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe
const entry = current.entries.find(e => String(e.id) === entryId)
if (!entry?.photos?.length) return
setLightbox({
photos: entry.photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
photos: entry.photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
index,
})
}}
+18 -24
View File
@@ -72,6 +72,20 @@ function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail'
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`;
}
type SharedPhoto = { id: number | string; photo_id: number; caption?: string | null; media_type?: string | null };
// The lightbox opens on the original and shows the thumbnail the page already
// loaded until it arrives, instead of a black screen (#2907).
function lightboxPhoto(p: SharedPhoto, shareToken: string) {
return {
id: String(p.id),
src: photoUrl(p, shareToken, 'original'),
thumbSrc: photoUrl(p, shareToken, 'thumbnail'),
caption: p.caption ?? null,
mediaType: p.media_type,
};
}
// The share route answers 404 for the thumbnail of a clip that has no poster, and
// an <img> pointed at it draws the broken-image glyph (#2341). The black ground
// the layouts give a video stands in for the picture.
@@ -172,12 +186,7 @@ export default function JourneyPublicPage() {
const picked = allPhotos.filter((p) => wanted.has(String(p.id)));
if (!token || picked.length === 0) return;
setLightbox({
photos: picked.map((p) => ({
id: String(p.id),
src: photoUrl(p, token, 'original'),
caption: p.caption,
mediaType: p.media_type,
})),
photos: picked.map((p) => lightboxPhoto(p, token)),
index: 0,
});
};
@@ -243,12 +252,7 @@ export default function JourneyPublicPage() {
const prosArr = entry.pros_cons?.pros ?? [];
const consArr = entry.pros_cons?.cons ?? [];
const hasProscons = prosArr.length > 0 || consArr.length > 0;
const lightboxPhotos = photos.map((p) => ({
id: String(p.id),
src: photoUrl(p, token!, 'original'),
caption: p.caption,
mediaType: p.media_type,
}));
const lightboxPhotos = photos.map((p) => lightboxPhoto(p, token!));
const isActive = activeEntryId === String(entry.id);
return (
@@ -559,12 +563,7 @@ export default function JourneyPublicPage() {
className="relative block aspect-square w-full cursor-pointer overflow-hidden rounded-lg"
onClick={() =>
setLightbox({
photos: allPhotos.map((p) => ({
id: String(p.id),
src: photoUrl(p, token!, 'original'),
caption: p.caption,
mediaType: p.media_type,
})),
photos: allPhotos.map((p) => lightboxPhoto(p, token!)),
index: idx,
})
}
@@ -760,12 +759,7 @@ export default function JourneyPublicPage() {
onDelete={() => { }}
onPhotoClick={(photos, idx) =>
setLightbox({
photos: photos.map((p) => ({
id: String(p.id),
src: photoUrl(p as any, token!, 'original'),
caption: (p as any).caption ?? null,
mediaType: p.media_type,
})),
photos: photos.map((p) => lightboxPhoto(p, token!)),
index: idx,
})
}
@@ -470,7 +470,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
const photos = (current?.gallery ?? []).filter(p => wanted.has(String(p.id)))
if (!photos.length) return
setLightbox({
photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })),
index: 0,
})
}, [current?.gallery])
@@ -26,7 +26,7 @@ export function useJourneyPublic() {
const [error, setError] = useState(false)
const isMobile = useIsMobile()
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; thumbSrc?: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null)
const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
@@ -4,7 +4,6 @@ import { http, HttpResponse } from 'msw';
import { localIsoDate } from '../../../../src/utils/localDate';
import userEvent from '@testing-library/user-event';
import { mapsApi, weatherApi } from '../../../../src/api/client';
import { useAddonStore } from '../../../../src/store/addonStore';
import MJourneyEntrySheet from '../../../../src/mobile/screens/journey/MJourneyEntrySheet';
import type { GalleryPhoto, JourneyEntry, JourneyPhoto } from '../../../../src/store/journeyStore';
import type { ResilientResult, UploadProgress } from '../../../../src/utils/uploadQueue';
@@ -252,6 +251,7 @@ interface MountOptions {
withProviderHook?: boolean;
upload?: UploadFn;
strict?: boolean;
photoProviders?: { id: string; name: string }[];
}
const defaultUpload: UploadFn = async (_entryId, files, cbs) => {
@@ -274,6 +274,7 @@ function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) {
readOnly={opts.readOnly ?? false}
userId={42}
trips={[]}
photoProviders={opts.photoProviders}
onClose={onClose}
onSave={onSave}
onUploadPhotos={onUploadPhotos}
@@ -745,6 +746,18 @@ describe('MJourneyEntrySheet full editor', () => {
expect(onSave.mock.calls[0][0]).toMatchObject({ mood: null, weather: null });
});
it('FE-MOB-JENTRY-2905: the picked neutral mood is framed in its own colour and pressed, unlike the chips at rest', async () => {
const user = userEvent.setup();
mountSheet(buildEntry({ id: 5 }));
await user.click(screen.getByRole('button', { name: 'Neutral' }));
const neutral = screen.getByRole('button', { name: 'Neutral' });
expect(neutral).toHaveAttribute('aria-pressed', 'true');
expect(neutral.className).not.toContain('text-m-muted');
expect(neutral.style.borderColor).toBe('rgb(154, 154, 161)');
expect(screen.getByRole('button', { name: 'Good' })).toHaveAttribute('aria-pressed', 'false');
});
it('FE-MOB-JENTRY-022: picks a searched location and stores its coordinates', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({
places: [
@@ -1161,15 +1174,10 @@ describe('MJourneyEntrySheet quick capture edge cases', () => {
// FE-MOB-JENTRY-041 to FE-MOB-JENTRY-047 — external photo providers in the phone
// editor (#1808), mirroring the desktop EntryEditor mechanics.
const immichAddon = { id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true };
function seedAddons(addons: Array<Record<string, unknown>>) {
useAddonStore.setState({ addons: addons as never, loaded: true });
}
const immich = [{ id: 'immich', name: 'Immich' }];
function useConnectedImmich() {
server.use(
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({
assets: [{ id: 'asset-1', takenAt: '2026-03-15T09:00:00.000Z', mediaType: 'image' }],
hasMore: false,
@@ -1185,27 +1193,32 @@ describe('MJourneyEntrySheet external photos', () => {
afterEach(() => {
delete window.__addToast;
useAddonStore.setState({ addons: [], loaded: false });
});
it('FE-MOB-JENTRY-041: hides the button while no provider is connected', async () => {
let probes = 0;
seedAddons([immichAddon]);
server.use(http.get('/api/integrations/memories/immich/status', () => {
probes += 1;
return HttpResponse.json({ connected: false });
}));
it('FE-MOB-JENTRY-041: hides the button while no provider is connected', () => {
mountSheet(buildEntry({ id: 5 }));
await waitFor(() => expect(probes).toBe(1));
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument();
});
it('FE-MOB-JENTRY-2908: a connected provider is there on the first render, without a probe of its own', () => {
let probes = 0;
server.use(http.get('/api/integrations/memories/immich/status', () => {
probes += 1;
return HttpResponse.json({ connected: true });
}));
mountSheet(buildEntry({ id: 5 }), { photoProviders: immich });
const external = screen.getByRole('button', { name: 'External photos' });
// The icons keep their width when a third button shares the row.
expect(external.querySelector('svg')).toHaveClass('shrink-0');
expect(screen.getByRole('button', { name: 'From Gallery' }).querySelector('svg')).toHaveClass('shrink-0');
expect(probes).toBe(0);
});
it('FE-MOB-JENTRY-042: opens the embedded picker for a connected provider', async () => {
seedAddons([immichAddon, { id: 'budget', name: 'Budget', type: 'feature', icon: 'wallet', enabled: true }]);
useConnectedImmich();
const user = userEvent.setup();
mountSheet(buildEntry({ id: 5, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }));
mountSheet(buildEntry({ id: 5, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }), { photoProviders: immich });
await user.click(await screen.findByRole('button', { name: 'External photos' }));
@@ -1216,20 +1229,18 @@ describe('MJourneyEntrySheet external photos', () => {
});
it('FE-MOB-JENTRY-043: stays out of the quick capture sheet', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
Object.defineProperty(navigator, 'geolocation', { configurable: true, value: undefined });
mountSheet(buildEntry(), { quickCapture: true });
mountSheet(buildEntry(), { quickCapture: true, photoProviders: immich });
expect(await screen.findByRole('button', { name: 'Gallery' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument();
});
it('FE-MOB-JENTRY-044: queues a picked photo and sends it after the save', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry({ id: 5 }));
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry({ id: 5 }), { photoProviders: immich });
await user.click(await screen.findByRole('button', { name: 'External photos' }));
await user.click(await screen.findByAltText(''));
@@ -1249,10 +1260,9 @@ describe('MJourneyEntrySheet external photos', () => {
});
it('FE-MOB-JENTRY-045: drops the queue again from the panel header', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
const { onAddProviderPhotos } = mountSheet(buildEntry({ id: 5 }));
const { onAddProviderPhotos } = mountSheet(buildEntry({ id: 5 }), { photoProviders: immich });
await user.click(await screen.findByRole('button', { name: 'External photos' }));
await user.click(await screen.findByAltText(''));
@@ -1265,11 +1275,10 @@ describe('MJourneyEntrySheet external photos', () => {
});
it('FE-MOB-JENTRY-046: a failed provider add keeps the queue and does not create a second entry', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
// A brand new entry: the first save creates it, the provider write then fails.
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry());
const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry(), { photoProviders: immich });
onAddProviderPhotos.mockRejectedValueOnce(new Error('immich down'));
await user.click(await screen.findByRole('button', { name: 'External photos' }));
@@ -1293,10 +1302,9 @@ describe('MJourneyEntrySheet external photos', () => {
});
it('FE-MOB-JENTRY-047: a skeleton with only provider photos is promoted to an entry', async () => {
seedAddons([immichAddon]);
useConnectedImmich();
const user = userEvent.setup();
const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' }));
const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' }), { photoProviders: immich });
await user.click(await screen.findByRole('button', { name: 'External photos' }));
await user.click(await screen.findByAltText(''));