From b953ece4b33ceaf8db28b57e2e616aecb6cadc20 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 30 Oct 2026 21:15:38 +0100 Subject: [PATCH] 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 --- client/scripts/eslint-baseline.json | 4 +- .../Journey/MobileEntryView.test.tsx | 14 +++- .../components/Journey/MobileEntryView.tsx | 23 +++++- .../components/Journey/PhotoLightbox.test.tsx | 26 ++++++- .../src/components/Journey/PhotoLightbox.tsx | 24 +++++- .../mobile/screens/journey/MJourneyDetail.tsx | 2 + .../screens/journey/MJourneyEntrySheet.tsx | 78 ++++++++----------- client/src/pages/JourneyDetailPage.tsx | 8 +- client/src/pages/JourneyPublicPage.tsx | 42 +++++----- .../pages/journeyDetail/useJourneyDetail.ts | 2 +- .../pages/journeyPublic/useJourneyPublic.ts | 2 +- .../journey/MJourneyEntrySheet.test.tsx | 66 +++++++++------- 12 files changed, 180 insertions(+), 111 deletions(-) diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 6fca38f6f..a54f8e616 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -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, diff --git a/client/src/components/Journey/MobileEntryView.test.tsx b/client/src/components/Journey/MobileEntryView.test.tsx index d1eb3a2fc..22f64c229 100644 --- a/client/src/components/Journey/MobileEntryView.test.tsx +++ b/client/src/components/Journey/MobileEntryView.test.tsx @@ -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() diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index d8c6bbed8..7640fb8a4 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -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 )} @@ -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)} > diff --git a/client/src/components/Journey/PhotoLightbox.test.tsx b/client/src/components/Journey/PhotoLightbox.test.tsx index 89d6ee1a7..9cd898883 100644 --- a/client/src/components/Journey/PhotoLightbox.test.tsx +++ b/client/src/components/Journey/PhotoLightbox.test.tsx @@ -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( + , + ); + 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( + , + ); + 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(); + }); }); diff --git a/client/src/components/Journey/PhotoLightbox.tsx b/client/src/components/Journey/PhotoLightbox.tsx index 96c4e4b2a..0c890afec 100644 --- a/client/src/components/Journey/PhotoLightbox.tsx +++ b/client/src/components/Journey/PhotoLightbox.tsx @@ -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(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 && ( + + )} + {/* Next button */} {hasNext && ( @@ -215,12 +200,14 @@ export default function MJourneyEntrySheet({ : 'border-dashed border-[color:var(--m-rowbr)] text-m-muted' }`} > - - {captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')} + + {captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')} {/* 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 && ( )} @@ -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} > {t(m.labelKey)} diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index a372cc0b1..59724c202 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -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 })} /> )} @@ -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, }) }} diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index b0b25d90c..8a1e7690f 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -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 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, }) } diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index 3476cf0eb..ab39a56d8 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -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]) diff --git a/client/src/pages/journeyPublic/useJourneyPublic.ts b/client/src/pages/journeyPublic/useJourneyPublic.ts index 94ac85249..f09fd72ae 100644 --- a/client/src/pages/journeyPublic/useJourneyPublic.ts +++ b/client/src/pages/journeyPublic/useJourneyPublic.ts @@ -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(null) diff --git a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx index e27d96aab..9ea08562a 100644 --- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx +++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx @@ -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>) { - 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(''));