Compare commits

..

5 Commits

Author SHA1 Message Date
bwees 7b68949502 fix: don't show loading dots when errored 2026-06-13 12:48:14 -05:00
bwees f86cb94736 fix: "error loading image" state 2026-06-13 12:43:00 -05:00
maxinegardenas b21af78454 fix(web): correctly handle person search with more than 100 results (#29002) 2026-06-12 20:04:52 +00:00
Santo Shakil abd62d9295 fix(mobile): show like and comment options on album photo deep links (#29020) 2026-06-12 14:55:26 -05:00
Jason Rasmussen e31d4aa909 fix: prerelease draft (#29034) 2026-06-12 12:43:19 -04:00
16 changed files with 170 additions and 46 deletions
+9 -1
View File
@@ -50,6 +50,7 @@ jobs:
outputs:
ref: ${{ steps.push-tag.outputs.commit_long_sha }}
version: ${{ steps.output.outputs.version }}
rc: ${{ steps.output.outputs.rc }}
permissions: {} # No job-level permissions are needed because it uses the app-token
steps:
- id: token
@@ -81,7 +82,13 @@ jobs:
run: pnpm --silent release -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
- id: output
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
run: |
echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
if [[ "$IMMICH_VERSION" =~ -rc\.[0-9]+$ ]]; then
echo "rc=true" >> $GITHUB_OUTPUT
else
echo "rc=false" >> $GITHUB_OUTPUT
fi
- name: Commit and tag
id: push-tag
@@ -145,6 +152,7 @@ jobs:
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
draft: true
prerelease: ${{ needs.bump_version.outputs.rc }}
tag_name: ${{ needs.bump_version.outputs.version }}
token: ${{ steps.generate-token.outputs.token }}
generate_release_notes: true
+8 -2
View File
@@ -70,7 +70,10 @@ class DeepLinkService {
if (assetRegex.hasMatch(path)) {
final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
return _buildAssetDeepLink(assetId, ref);
// /albums/<albumId>/photos/<assetId> links carry the album context,
// which drives the like/comment UI in the viewer
final albumId = albumRegex.firstMatch(path)?.group(1);
return _buildAssetDeepLink(assetId, ref, albumId: albumId);
}
if (albumRegex.hasMatch(path)) {
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
@@ -107,16 +110,19 @@ class DeepLinkService {
return DriftMemoryRoute(memories: memories, memoryIndex: 0);
}
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref) async {
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref, {String? albumId}) async {
final asset = await _betaAssetService.getRemoteAsset(assetId);
if (asset == null) {
return null;
}
final album = albumId != null ? await _betaRemoteAlbumService.get(albumId) : null;
AssetViewer.setAsset(ref, asset);
return AssetViewerRoute(
initialIndex: 0,
timelineService: _betaTimelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
currentAlbum: album,
);
}
@@ -0,0 +1,140 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/memory.service.dart';
import 'package:immich_mobile/domain/services/people.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/deep_link.service.dart';
import 'package:mocktail/mocktail.dart';
class MockTimelineFactory extends Mock implements TimelineFactory {}
class MockAssetService extends Mock implements AssetService {}
class MockRemoteAlbumService extends Mock implements RemoteAlbumService {}
class MockDriftMemoryService extends Mock implements DriftMemoryService {}
class MockDriftPeopleService extends Mock implements DriftPeopleService {}
class MockPlatformDeepLink extends Mock implements PlatformDeepLink {}
class MockWidgetRef extends Mock implements WidgetRef {}
class MockAssetViewerStateNotifier extends Mock implements AssetViewerStateNotifier {}
const _assetId = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb';
const _albumId = 'cccccccc-4444-5555-6666-dddddddddddd';
final _asset = RemoteAsset(
id: _assetId,
name: 'photo.jpg',
ownerId: 'user-1',
checksum: 'checksum-1',
type: AssetType.image,
createdAt: DateTime(2026, 6, 12),
updatedAt: DateTime(2026, 6, 12),
isEdited: false,
);
final _album = RemoteAlbum(
id: _albumId,
name: 'Shared Album',
ownerId: 'user-1',
description: '',
createdAt: DateTime(2026, 6, 12),
updatedAt: DateTime(2026, 6, 12),
isActivityEnabled: true,
isShared: true,
order: AlbumAssetOrder.asc,
assetCount: 1,
ownerName: 'Owner',
);
void main() {
late MockTimelineFactory timelineFactory;
late MockAssetService assetService;
late MockRemoteAlbumService remoteAlbumService;
late MockWidgetRef ref;
late List<TimelineService> createdTimelineServices;
late DeepLinkService sut;
setUp(() {
timelineFactory = MockTimelineFactory();
assetService = MockAssetService();
remoteAlbumService = MockRemoteAlbumService();
ref = MockWidgetRef();
createdTimelineServices = [];
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
final timelineService = TimelineService((
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
origin: TimelineOrigin.deepLink,
));
createdTimelineServices.add(timelineService);
return timelineService;
});
when(() => ref.read(assetViewerProvider.notifier)).thenReturn(MockAssetViewerStateNotifier());
sut = DeepLinkService(
timelineFactory,
assetService,
remoteAlbumService,
MockDriftMemoryService(),
MockDriftPeopleService(),
null,
);
addTearDown(() async {
for (final timelineService in createdTimelineServices) {
await timelineService.dispose();
}
});
});
PlatformDeepLink link(String path) {
final deepLink = MockPlatformDeepLink();
when(() => deepLink.uri).thenReturn(Uri.parse('https://my.immich.app$path'));
return deepLink;
}
test('album photo link carries the album into the viewer route', () async {
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => _album);
final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref);
expect(route, isA<AssetViewerRoute>());
expect((route!.args as AssetViewerRouteArgs).currentAlbum, _album);
});
test('still opens the viewer when the album cannot be resolved', () async {
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => null);
final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref);
expect(route, isA<AssetViewerRoute>());
expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull);
});
test('plain photo link has no album', () async {
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
final route = await sut.handleMyImmichApp(link('/photos/$_assetId'), ref);
expect(route, isA<AssetViewerRoute>());
expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull);
verifyNever(() => remoteAlbumService.get(any()));
});
}
-1
View File
@@ -51,7 +51,6 @@ export default typescriptEslint.config([
'unicorn/no-array-sort': 'off',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/switch-exhaustiveness-check': ['error', { considerDefaultExhaustiveForUnions: true }],
'require-await': 'off',
'@typescript-eslint/require-await': 'error',
curly: 2,
+1 -5
View File
@@ -28,13 +28,9 @@ export class SchemaCheck extends CommandRunner {
}
case 'missing': {
console.log(` - ${migration.name} exists on disk, but has not been applied to the database`);
console.log(` - ${migration.name} exists, but has not been applied to the database`);
break;
}
case 'applied': {
break; // happy path, do nothing
}
}
}
}
+1 -8
View File
@@ -9,7 +9,6 @@ import {
PersonPathType,
RawExtractedFormat,
StorageFolder,
UserPathType,
} from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
@@ -328,19 +327,13 @@ export class StorageCore {
case AssetFileType.EncodedVideo:
case AssetFileType.Thumbnail:
case AssetFileType.Preview:
case AssetFileType.Sidecar:
case AssetPathType.EncodedVideo: {
case AssetFileType.Sidecar: {
return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath });
}
case PersonPathType.Face: {
return this.personRepository.update({ id, thumbnailPath: newPath });
}
case UserPathType.Profile: {
this.logger.warn('Unexpected path type:', pathType);
return;
}
}
}
@@ -281,20 +281,17 @@ export class MaintenanceWorkerService {
async runAction(action: SetMaintenanceModeDto) {
switch (action.action) {
case MaintenanceAction.Start:
case MaintenanceAction.SelectDatabaseRestore: {
case MaintenanceAction.Start: {
return;
}
case MaintenanceAction.End: {
return this.endMaintenance();
}
case MaintenanceAction.RestoreDatabase: {
return this.runRestoreDatabase(action);
case MaintenanceAction.SelectDatabaseRestore: {
return;
}
}
}
async runRestoreDatabase(action: SetMaintenanceModeDto) {
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
if (!lock) {
return;
@@ -252,9 +252,6 @@ const getEnv = (): EnvData => {
vectorExtension = DatabaseExtension.VectorChord;
break;
}
case undefined: {
break;
}
}
return {
+1 -3
View File
@@ -478,10 +478,8 @@ export class MediaRepository {
case 'av1': {
return this.parseEnum(Av1Profile, profile);
}
default: {
return null;
}
}
return null;
}
private compareStreams(a: FfprobeStream, b: FfprobeStream): number {
@@ -163,9 +163,6 @@ export class DatabaseBackupService {
);
switch (bin) {
case 'pg_dump': {
break;
}
case 'pg_dumpall': {
args.push('--database');
break;
-2
View File
@@ -257,8 +257,6 @@ export class JobService extends BaseService {
}
break;
}
// no default
}
}
}
-3
View File
@@ -498,9 +498,6 @@ export class LibraryService extends BaseService {
const stat = stats[i];
const action = this.checkExistingAsset(asset, stat);
switch (action) {
case AssetSyncResult.DO_NOTHING: {
break;
}
case AssetSyncResult.OFFLINE: {
if (asset.status === AssetStatus.Trashed) {
trashedAssetIdsToOffline.push(asset.id);
+1 -3
View File
@@ -1138,9 +1138,7 @@ export class MetadataService extends BaseService {
case 3: {
return ExifOrientation.Rotate90CW;
}
default: {
return null;
}
}
return null;
}
}
+4 -4
View File
@@ -261,10 +261,6 @@
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
@@ -290,6 +286,10 @@
{/if}
</div>
{#if show.brokenAsset}
<BrokenAsset class="absolute inset-0 z-10 size-full text-xl" />
{/if}
{#if overlays}
<div class="pointer-events-none absolute inset-0">
{@render overlays()}
+1 -1
View File
@@ -69,7 +69,7 @@ export enum OpenQueryParam {
PURCHASE_SETTINGS = 'user-purchase-settings',
}
export const maximumLengthSearchPeople = 1000;
export const maximumLengthSearchPeople = 100;
// time to load the map before displaying the loading spinner
export const timeToLoadTheMap: number = 100;
@@ -44,7 +44,7 @@ class AssetViewerManager extends BaseEventManager<Events> {
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
#isImageLoading = $derived.by(() => {
const quality = this.imageLoaderStatus?.quality;
if (!quality) {
if (!quality || this.imageLoaderStatus?.hasError) {
return false;
}
const previewOrOriginalReady = quality.preview === 'success' || quality.original === 'success';