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 && (
+
+ {photo.thumbSrc && (
+

+ )}
+
+
+
+
+ )}
+
{/* 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(''));