Compare commits

..

1 Commits

Author SHA1 Message Date
Mees Frensel de9abf8ce9 fix(web): URI encode slug and reduce confusion for users 2026-07-10 12:24:06 +02:00
16 changed files with 76 additions and 55 deletions
+1 -1
View File
@@ -4,4 +4,4 @@
/web/ @danieldietzler
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil @agg23
/mobile/ @shenlong-tanwen @santoshakil
+3 -2
View File
@@ -814,7 +814,7 @@
"custom_date": "Custom date",
"custom_locale": "Custom locale",
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
"custom_url": "Custom URL",
"custom_url_name": "Custom URL name",
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
@@ -1948,7 +1948,8 @@
"shared_link_app_bar_title": "Shared Links",
"shared_link_clipboard_copied_massage": "Copied to clipboard",
"shared_link_create_error": "Error while creating shared link",
"shared_link_custom_url_description": "Access this shared link with a custom URL",
"shared_link_custom_url_description": "Access this shared link with a custom URL name",
"shared_link_custom_url_warning": "Warning: a custom URL name with a forward slash may not behave as you expect.",
"shared_link_edit_description_hint": "Enter the share description",
"shared_link_edit_expire_after_option_day": "1 day",
"shared_link_edit_expire_after_option_days": "{count} days",
@@ -1,6 +1,6 @@
<script lang="ts">
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { Field, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { Field, HelperText, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
@@ -32,8 +32,11 @@
<div class="mt-4 flex flex-col gap-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Field label={$t('custom_url_name')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
{#if slug.includes('/')}
<HelperText class="text-warning">{$t('shared_link_custom_url_warning')}</HelperText>
{/if}
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
@@ -7,16 +7,6 @@ import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
import AssetViewerNavBar from './AssetViewerNavBar.svelte';
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true },
} as never,
};
});
describe('AssetViewerNavBar component', () => {
const additionalProps = {
preAction: () => {},
@@ -34,6 +24,15 @@ describe('AssetViewerNavBar component', () => {
};
});
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true },
} as never,
};
});
});
afterEach(() => {
@@ -4,14 +4,16 @@ import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });
@@ -4,11 +4,6 @@ import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/navigation', () => ({
currentUrlReplaceAssetId: vi.fn(),
isSharedLinkRoute: vi.fn().mockReturnValue(false),
}));
vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
@@ -31,6 +26,10 @@ vi.hoisted(() => {
describe('Thumbnail component', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.mock('$lib/utils/navigation', () => ({
currentUrlReplaceAssetId: vi.fn(),
isSharedLinkRoute: vi.fn().mockReturnValue(false),
}));
});
it('should only contain a single tabbable element (the container)', () => {
@@ -16,7 +16,7 @@ describe('RecentAlbums component', () => {
sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]);
render(RecentAlbums);
expect(sdkMock.getAllAlbums).toHaveBeenCalledOnce();
expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
// wtf
await tick();
@@ -35,7 +35,7 @@ describe('FormatMessage component', () => {
it('throws an error when locale is empty', async () => {
await locale.set(undefined);
expect(() => render(FormatMessage, { key: '' as Translations })).toThrow();
expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError();
await locale.set('en');
});
@@ -70,7 +70,7 @@ describe('TimelineManager', () => {
});
it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
});
@@ -133,13 +133,13 @@ describe('TimelineManager', () => {
it('loads a month', async () => {
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('ignores invalid months', async () => {
await timelineManager.loadTimelineMonth({ year: 2023, month: 1 });
expect(sdkMock.getTimeBucket).not.toHaveBeenCalled();
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
});
it('cancels month loading', async () => {
@@ -147,7 +147,7 @@ describe('TimelineManager', () => {
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel();
expect(abortSpy).toHaveBeenCalledOnce();
expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
@@ -157,10 +157,10 @@ describe('TimelineManager', () => {
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
]);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
});
it('allows loading a canceled month', async () => {
@@ -283,7 +283,7 @@ describe('TimelineManager', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.upsertAssets([asset]);
expect(updateAssetsSpy).toHaveBeenCalledWith([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
@@ -642,8 +642,8 @@ describe('TimelineManager', () => {
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadTimelineMonthSpy).not.toHaveBeenCalled();
expect(previousMonthSpy).not.toHaveBeenCalled();
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {
+14
View File
@@ -24,6 +24,20 @@ describe('Route', () => {
});
});
describe(Route.viewSharedLink.name, () => {
it('should work with key', () => {
expect(Route.viewSharedLink({ key: 'uuid-key' })).toBe('/share/uuid-key');
});
it('should work with key and slug', () => {
expect(Route.viewSharedLink({ key: 'uuid-key', slug: 'custom-slug' })).toBe('/s/custom-slug');
});
it('should URI encode slug', () => {
expect(Route.viewSharedLink({ key: 'uuid-key', slug: 'albums/the-moon?' })).toBe('/s/albums%2Fthe-moon%3F');
});
});
describe(Route.tags.name, () => {
it('should work', () => {
expect(Route.tags()).toBe('/tags');
+2 -1
View File
@@ -120,7 +120,8 @@ export const Route = {
// shared links
sharedLinks: (params?: { filter?: SharedLinkTab }) => '/shared-links' + asQueryString(params),
editSharedLink: ({ id }: { id: string }) => `/shared-links/${id}/edit`,
viewSharedLink: ({ slug, key }: { slug?: string | null; key: string }) => (slug ? `/s/${slug}` : `/share/${key}`),
viewSharedLink: ({ slug, key }: { slug?: string | null; key: string }) =>
slug ? `/s/${encodeURIComponent(slug)}` : `/share/${key}`,
// settings
userSettings: (params?: { isOpen?: OpenQueryParam }) => '/user-settings' + asQueryString(params),
@@ -2,15 +2,17 @@ import type { ServerConfigDto } from '@immich/sdk';
import { asUrl } from '$lib/services/shared-link.service';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
}));
describe('SharedLinkService', () => {
beforeAll(() => {
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
}));
});
describe('asUrl', () => {
it('should properly encode characters in slug', () => {
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');
+1 -3
View File
@@ -60,9 +60,7 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
};
export const asUrl = (sharedLink: SharedLinkResponseDto) => {
const path = sharedLink.slug
? `s/${encodeURIComponent(sharedLink.slug)}`
: `share/${encodeURIComponent(sharedLink.key)}`;
const path = Route.viewSharedLink(sharedLink);
return new URL(path, serverConfigManager.value.externalDomain || globalThis.location.origin).href;
};
+4 -4
View File
@@ -1,10 +1,6 @@
import { writable } from 'svelte/store';
import { getAlbumDateRange, getShortDateRange } from './date-time';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
describe('getShortDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
@@ -45,6 +41,10 @@ describe('getShortDateRange', () => {
describe('getAlbumDate', () => {
beforeAll(() => {
process.env.TZ = 'UTC';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
});
it('should work with only a start date', () => {
+1 -1
View File
@@ -34,7 +34,7 @@ describe('Executor Queue test', function () {
// The last task will be executed after 200ms and will finish at 400ms
void eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
expect(finished).not.toHaveBeenCalled();
expect(finished).not.toBeCalled();
expect(started).toHaveBeenCalledTimes(3);
vi.advanceTimersByTime(100);
@@ -56,8 +56,10 @@
{#if shareType === SharedLinkType.Individual}
<div class="text-sm">
{$t('individual_share')} |
<span class="text-primary">{sharedLink.description || ''}</span>
{$t('individual_share')}
{#if description !== ''}
| <span class="text-primary">{description}</span>
{/if}
</div>
{/if}