Compare commits

..

1 Commits

Author SHA1 Message Date
Mees Frensel 698b96d597 refactor(web): stack-related service and actions 2026-06-30 20:14:56 +02:00
58 changed files with 624 additions and 1236 deletions
+3 -1
View File
@@ -33,7 +33,7 @@
"add_to_albums": "Add to albums",
"add_to_albums_count": "Add to albums ({count})",
"add_to_bottom_bar": "Add to",
"add_upload_to_stack": "Add upload to stack",
"add_upload_to_stack": "Upload and {isStack, select, true {add to stack} other {create stack}}",
"add_url": "Add URL",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
@@ -1733,6 +1733,7 @@
"removed_from_archive": "Removed from archive",
"removed_from_favorites": "Removed from favorites",
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
"removed_from_stack": "Removed asset from stack",
"removed_memory": "Removed memory",
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename",
@@ -2008,6 +2009,7 @@
"source": "Source",
"stack": "Stack",
"stack_action_prompt": "{count} stacked",
"stack_created": "Stack created",
"stack_duplicates": "Stack duplicates",
"stack_selected_photos": "Stack selected photos",
"stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",
+14 -25
View File
@@ -3,35 +3,33 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
class AssetService {
final RemoteAssetRepository _remoteRepository;
final DriftLocalAssetRepository _localRepository;
final AssetApiRepository _apiRepository;
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
const AssetService({required this._remoteRepository, required this._localRepository, required this._apiRepository});
const AssetService({required this._remoteAssetRepository, required this._localAssetRepository});
Future<BaseAsset?> getAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
return asset is LocalAsset ? _localRepository.get(id) : _remoteRepository.get(id);
return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id);
}
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
return asset is LocalAsset ? _localRepository.watch(id) : _remoteRepository.watch(id);
return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id);
}
Future<List<LocalAsset?>> getLocalAssetsByChecksum(String checksum) {
return _localRepository.getByChecksum(checksum);
return _localAssetRepository.getByChecksum(checksum);
}
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
return _remoteRepository.getByChecksum(checksum);
return _remoteAssetRepository.getByChecksum(checksum);
}
Future<RemoteAsset?> getRemoteAsset(String id) {
return _remoteRepository.get(id);
return _remoteAssetRepository.get(id);
}
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
@@ -39,7 +37,7 @@ class AssetService {
return const [];
}
final stack = await _remoteRepository.getStackChildren(asset);
final stack = await _remoteAssetRepository.getStackChildren(asset);
// Include the primary asset in the stack as the first item
return [asset, ...stack];
}
@@ -50,31 +48,22 @@ class AssetService {
}
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
return _remoteRepository.getExif(id);
return _remoteAssetRepository.getExif(id);
}
Future<List<(String, String)>> getPlaces(String userId) {
return _remoteRepository.getPlaces(userId);
return _remoteAssetRepository.getPlaces(userId);
}
Future<(int local, int remote)> getAssetCounts() async {
return (await _localRepository.getCount(), await _remoteRepository.getCount());
return (await _localAssetRepository.getCount(), await _remoteAssetRepository.getCount());
}
Future<int> getLocalHashedCount() {
return _localRepository.getHashedCount();
return _localAssetRepository.getHashedCount();
}
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
Future<void> updateFavorite(List<String> remoteIds, bool isFavorite) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
}
@@ -181,8 +181,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchRemoteAlbumBucket(albumId, groupBy: groupBy),
assetSource: (offset, count) =>
_getRemoteAlbumBucketAssets(albumId, groupBy: groupBy, offset: offset, count: count),
assetSource: (offset, count) => _getRemoteAlbumBucketAssets(albumId, offset: offset, count: count),
origin: TimelineOrigin.remoteAlbum,
);
@@ -236,12 +235,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
.handleError((error) => const <Bucket>[]);
}
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(
String albumId, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) async {
Future<List<BaseAsset>> _getRemoteAlbumBucketAssets(String albumId, {required int offset, required int count}) async {
final albumData = await (_db.remoteAlbumEntity.select()..where((row) => row.id.equals(albumId))).getSingleOrNull();
// If album doesn't exist (was deleted), return empty list
@@ -268,14 +262,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
),
])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId));
// Order assets by the same effective date the buckets group by, otherwise offset
// paging puts an asset whose localDateTime differs from createdAt under the wrong
// date header (#28852). createdAt is the within-day tiebreak.
OrderingTerm ord(Expression<Object> exp) => isAscending ? OrderingTerm.asc(exp) : OrderingTerm.desc(exp);
query.orderBy([
if (groupBy != GroupAssetsBy.none) ord(_db.remoteAssetEntity.effectiveCreatedAt(groupBy)),
ord(_db.remoteAssetEntity.createdAt),
]);
if (isAscending) {
query.orderBy([OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
} else {
query.orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]);
}
query.limit(count, offset: offset);
@@ -382,14 +373,13 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
TimelineQuery place(String place, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPlaceBucket(place, groupBy: groupBy),
assetSource: (offset, count) => _getPlaceBucketAssets(place, groupBy: groupBy, offset: offset, count: count),
assetSource: (offset, count) => _getPlaceBucketAssets(place, offset: offset, count: count),
origin: TimelineOrigin.place,
);
TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy),
assetSource: (offset, count) =>
_getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count),
assetSource: (offset, count) => _getPersonBucketAssets(userId, personId, offset: offset, count: count),
origin: TimelineOrigin.person,
);
@@ -426,12 +416,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}).watch();
}
Future<List<BaseAsset>> _getPlaceBucketAssets(
String place, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
Future<List<BaseAsset>> _getPlaceBucketAssets(String place, {required int offset, required int count}) {
final query =
_db.remoteAssetEntity.select().join([
innerJoin(
@@ -445,11 +430,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteExifEntity.city.equals(place),
)
// Match the bucket grouping (#28852); place buckets are always date-grouped.
..orderBy([
OrderingTerm.desc(_db.remoteAssetEntity.effectiveCreatedAt(groupBy)),
OrderingTerm.desc(_db.remoteAssetEntity.createdAt),
])
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset);
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
@@ -505,7 +486,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
String personId, {
required int offset,
required int count,
GroupAssetsBy groupBy = GroupAssetsBy.day,
}) {
final idQuery = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId])
@@ -523,11 +503,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
)
// Match the bucket grouping (#28852); createdAt is the within-day tiebreak.
..orderBy([
if (groupBy != GroupAssetsBy.none) (row) => OrderingTerm.desc(row.effectiveCreatedAt(groupBy)),
(row) => OrderingTerm.desc(row.createdAt),
])
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
class ActionScope {
@@ -22,11 +21,3 @@ abstract class BaseAction {
Future<void> onAction(ActionScope scope);
}
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
final Iterable<BaseAsset> assets;
const AssetAction({required this.assets});
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
}
@@ -1,27 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AssetDebugAction extends AssetAction<BaseAsset> {
const AssetDebugAction({required super.assets});
@override
IconData get icon => Icons.help_outline_rounded;
@override
String label(ActionScope scope) => scope.context.t.troubleshoot;
@override
bool isVisible(ActionScope scope) =>
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
@override
Future<void> onAction(ActionScope scope) async =>
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
}
@@ -1,40 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_ui/immich_ui.dart';
class FavoriteAction extends AssetAction<RemoteAsset> {
final bool shouldFavorite;
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
@override
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
@override
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => assets
.where(
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
)
.cast<RemoteAsset>();
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
final message = shouldFavorite
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
snackbar.success(message);
}
}
@@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class TimelineAction extends BaseAction {
final BaseAction action;
const TimelineAction({required this.action});
@override
IconData get icon => action.icon;
@override
String label(ActionScope scope) => action.label(scope);
@override
bool isVisible(ActionScope scope) => action.isVisible(scope);
@override
Future<void> onAction(ActionScope scope) async {
await action.onAction(scope);
scope.ref.read(multiSelectProvider.notifier).reset();
}
}
@@ -0,0 +1,36 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
class AdvancedInfoActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const AdvancedInfoActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
unawaited(ref.read(actionProvider.notifier).troubleshoot(source, context));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 115.0,
iconData: Icons.help_outline_rounded,
label: "troubleshoot".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -12,7 +12,6 @@ import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_ui/immich_ui.dart';
class ViewerKebabMenu extends ConsumerWidget {
const ViewerKebabMenu({super.key, this.originalTheme});
@@ -50,9 +49,9 @@ class ViewerKebabMenu extends ConsumerWidget {
timelineOrigin: timelineOrigin,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
return ImmichMenu(
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
@@ -63,7 +62,7 @@ class ViewerKebabMenu extends ConsumerWidget {
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
children: [
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Theme(
@@ -2,11 +2,12 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -14,9 +15,9 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provid
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_ui/immich_ui.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key});
@@ -30,6 +31,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final album = ref.watch(currentRemoteAlbumProvider);
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
@@ -43,7 +46,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
final originalTheme = context.themeData;
final assetForAction = [asset];
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
@@ -61,7 +63,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),
];
@@ -102,13 +107,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
leading: const _AppBarBackButton(),
middle: showingDetails ? null : _AssetInfoTitle(asset: asset),
trailing: !showingDetails && !isReadonlyModeEnabled
? ImmichColorOverride(
color: Colors.white,
child: Row(
mainAxisSize: MainAxisSize.min,
children: isInLockedView ? lockedViewActions : actions,
),
)
? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions)
: null,
),
),
@@ -3,14 +3,12 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
@@ -76,9 +74,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
@@ -89,7 +84,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const FavoriteActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
@@ -4,9 +4,6 @@ import 'package:immich_mobile/constants/enums.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/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
@@ -18,6 +15,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -67,9 +65,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
ref.read(multiSelectProvider.notifier).reset();
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
initialChildSize: 0.4,
maxChildSize: 0.7,
@@ -78,7 +73,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const UnFavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
@@ -3,9 +3,8 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
@@ -25,6 +24,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@@ -56,6 +56,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
@@ -83,9 +84,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [AssetDebugAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: widget.minChildSize ?? 0.15,
@@ -93,7 +91,9 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.selectedAssets.length == 1 && advancedTroubleshooting) ...[
const AdvancedInfoActionButton(source: ActionSource.timeline),
],
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
@@ -3,15 +3,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
@@ -85,9 +83,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.22,
@@ -101,7 +96,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const FavoriteActionButton(source: ActionSource.timeline),
],
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
@@ -5,7 +5,6 @@ import 'package:immich_mobile/infrastructure/repositories/remote_asset.repositor
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
final localAssetRepository = Provider<DriftLocalAssetRepository>(
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
@@ -21,9 +20,8 @@ final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
final assetServiceProvider = Provider(
(ref) => AssetService(
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
localRepository: ref.watch(localAssetRepository),
apiRepository: ref.watch(assetApiRepositoryProvider),
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
),
);
+10 -6
View File
@@ -1,14 +1,14 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.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/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
@@ -185,14 +185,18 @@ enum ActionButtonType {
};
}
Widget buildButton(
ConsumerWidget buildButton(
ActionButtonContext context, [
BuildContext? buildContext,
bool iconOnly = false,
bool menuItem = false,
]) {
return switch (this) {
ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])),
ActionButtonType.advancedInfo => AdvancedInfoActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.shareLink => ShareLinkActionButton(
source: context.source,
@@ -330,7 +334,7 @@ class ActionButtonBuilder {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
}
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = defaultViewerKebabMenuOrder
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
.toList();
@@ -346,7 +350,7 @@ class ActionButtonBuilder {
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext, false, true));
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
lastGroup = type.kebabMenuGroup;
}
-1
View File
@@ -1,4 +1,3 @@
export 'src/color_override.dart';
export 'src/components/close_button.dart';
export 'src/components/column_button.dart';
export 'src/components/form.dart';
@@ -46,40 +46,6 @@ void main() {
expect((assets.first as RemoteAsset).id, remoteAsset.id);
expect([localAsset1.id, localAsset2.id], contains((assets.first as RemoteAsset).localId));
});
test('orders assets by effective date so they land under the correct date bucket (#28852)', () async {
// Buckets group by the effective date = coalesce(localDateTime, createdAt). The asset
// list must use the same ordering, otherwise offset paging puts an asset whose
// localDateTime differs from createdAt under the wrong date header.
final user = await ctx.newUser();
final album = await ctx.newRemoteAlbum(ownerId: user.id, order: .desc);
// A: shown on Sep 3 (localDateTime) but only has the earlier Sep 2 createdAt.
final assetA = await ctx.newRemoteAsset(
ownerId: user.id,
createdAt: DateTime.utc(2024, 9, 2, 12),
localDateTime: DateTime.utc(2024, 9, 3, 12),
);
// B: the inverse shown on Sep 2 but has the later Sep 3 createdAt.
final assetB = await ctx.newRemoteAsset(
ownerId: user.id,
createdAt: DateTime.utc(2024, 9, 3, 12),
localDateTime: DateTime.utc(2024, 9, 2, 12),
);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: assetA.id);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: assetB.id);
final query = sut.remoteAlbum(album.id, .day);
final buckets = await query.bucketSource().first;
expect(buckets, hasLength(2));
expect(buckets.every((b) => b.assetCount == 1), isTrue);
// Buckets are ordered by effective date desc (Sep 3 then Sep 2), so the asset list
// must be A then B. The pre-fix raw-createdAt ordering returned B first (Sep 3 createdAt),
// which slotted B under the Sep 3 header and A under Sep 2.
final assets = await query.assetSource(0, 10);
expect(assets.map((a) => (a as RemoteAsset).id).toList(), [assetA.id, assetB.id]);
});
});
group('person assets', () {
@@ -103,33 +69,5 @@ void main() {
expect(assets, hasLength(1));
expect((assets.first as RemoteAsset).id, asset.id);
});
test('orders assets by effective date so they land under the correct date bucket (#28852)', () async {
final user = await ctx.newUser();
final person = await ctx.newPerson(ownerId: user.id);
// A shown on Sep 3 (localDateTime) with the earlier Sep 2 createdAt; B is the inverse.
final assetA = await ctx.newRemoteAsset(
ownerId: user.id,
createdAt: DateTime.utc(2024, 9, 2, 12),
localDateTime: DateTime.utc(2024, 9, 3, 12),
);
final assetB = await ctx.newRemoteAsset(
ownerId: user.id,
createdAt: DateTime.utc(2024, 9, 3, 12),
localDateTime: DateTime.utc(2024, 9, 2, 12),
);
await ctx.newFace(assetId: assetA.id, personId: person.id);
await ctx.newFace(assetId: assetB.id, personId: person.id);
final query = sut.person(user.id, person.id, .day);
final buckets = await query.bucketSource().first;
expect(buckets, hasLength(2));
// Buckets are date-desc (Sep 3 then Sep 2); the asset list must match (A then B).
// The pre-fix raw-createdAt order returned B first.
final assets = await query.assetSource(0, 10);
expect(assets.map((a) => (a as RemoteAsset).id).toList(), [assetA.id, assetB.id]);
});
});
}
+3 -4
View File
@@ -102,7 +102,6 @@ class MediumRepositoryContext {
String? stackId,
String? thumbHash,
String? libraryId,
DateTime? localDateTime,
}) async {
id ??= TestUtils.uuid();
createdAt ??= TestUtils.date();
@@ -126,7 +125,7 @@ class MediumRepositoryContext {
isEdited: .new(isEdited ?? false),
livePhotoVideoId: .new(livePhotoVideoId),
stackId: .new(stackId),
localDateTime: .new(localDateTime ?? createdAt.toLocal()),
localDateTime: .new(createdAt.toLocal()),
thumbHash: .new(TestUtils.uuid(thumbHash)),
libraryId: .new(TestUtils.uuid(libraryId)),
),
@@ -218,8 +217,8 @@ class MediumRepositoryContext {
}
Future<AssetFaceEntityData> newFace({String? assetId, String? personId, int? imageWidth, int? imageHeight}) {
imageWidth ??= TestUtils.randInt(999) + 2;
imageHeight ??= TestUtils.randInt(999) + 2;
imageWidth ??= TestUtils.randInt(999) + 1;
imageHeight ??= TestUtils.randInt(999) + 1;
final x1 = TestUtils.randInt(imageWidth - 1);
final y1 = TestUtils.randInt(imageHeight - 1);
-12
View File
@@ -34,7 +34,6 @@ class RepositoryMocks {
class ServiceMocks {
final PartnerStub partner = PartnerStub(MockPartnerService());
final UserStub user = UserStub(MockUserService());
final asset = AssetStub(MockAssetService());
ServiceMocks() {
resetAll();
@@ -44,10 +43,8 @@ class ServiceMocks {
_registerFallbacks();
partner.reset();
user.reset();
asset.reset();
_stubUserService();
_stubPartnerService();
_stubAssetService();
}
void _stubUserService() {
@@ -66,10 +63,6 @@ class ServiceMocks {
when(partner.create).thenAnswer((_) async {});
when(partner.delete).thenAnswer((_) async {});
}
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
}
}
void _registerFallbacks() {
@@ -126,8 +119,3 @@ extension type const UserStub(MockUserService service) implements Stub<MockUserS
Future<String?> Function() get createProfileImage =>
() => service.createProfileImage(any(), any());
}
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get updateFavorite =>
() => service.updateFavorite(any(), any());
}
@@ -1,54 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_ui/immich_ui.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
await StoreService.I.put(StoreKey.advancedTroubleshooting, true);
});
tearDown(() {
context.dispose();
});
group('AssetDebugAction', () {
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
await tester.pumpTestWidget(
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('hidden for multiple assets', (tester) async {
await tester.pumpTestWidget(
ActionIconButtonWidget(
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsNothing);
});
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
await tester.pumpTestWidget(
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsNothing);
});
});
}
@@ -1,82 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
List<Override> overrides() => [
...context.overrides,
assetServiceProvider.overrideWithValue(context.mocks.asset.service),
];
RemoteAsset owned({bool isFavorite = false}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
group('FavoriteAction', () {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
});
});
}
@@ -5,25 +5,32 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../factories/user_factory.dart';
import '../../mocks.dart';
import '../../presentation_context.dart';
void main() {
late PresentationContext context;
late UserDto currentUser;
final mocks = ServiceMocks();
setUp(() async {
currentUser = UserFactory.createDto();
context = await PresentationContext.create();
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
});
tearDown(() {
context.dispose();
tearDown(() async {
mocks.resetAll();
await context.dispose();
});
List<Override> overrides({List<User> candidates = const []}) => [
...context.overrides,
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service)),
partnerServiceProvider.overrideWithValue(mocks.partner.service),
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
@@ -36,9 +43,7 @@ void main() {
await tester.tap(find.text(candidate.name));
await tester.pumpAndSettle();
verify(
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
).called(1);
verify(() => mocks.partner.service.create(sharedById: currentUser.id, sharedWithId: candidate.id)).called(1);
});
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
@@ -46,7 +51,7 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
await tester.pumpAndSettle();
verifyNever(context.mocks.partner.create);
verifyNever(mocks.partner.create);
});
});
@@ -60,9 +65,7 @@ void main() {
await tester.tap(find.byType(TextButton).last); // confirm
await tester.pumpAndSettle();
verify(
() => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id),
).called(1);
verify(() => mocks.partner.service.delete(sharedById: currentUser.id, sharedWithId: partner.id)).called(1);
});
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
@@ -74,7 +77,7 @@ void main() {
await tester.tap(find.byType(TextButton).first); // cancel
await tester.pumpAndSettle();
verifyNever(context.mocks.partner.delete);
verifyNever(mocks.partner.delete);
});
});
}
@@ -1,108 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
class _FakeAction extends BaseAction {
_FakeAction({this.visible = true, this.error});
final bool visible;
final Object? error;
bool ran = false;
bool? selectionDuringOnAction;
@override
IconData get icon => Icons.bolt;
@override
String label(ActionScope scope) => 'fake';
@override
bool isVisible(ActionScope scope) => visible;
@override
Future<void> onAction(ActionScope scope) async {
ran = true;
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (error != null) {
throw error!;
}
}
}
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
List<Override> seededOverrides() => [
...context.overrides,
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
),
),
];
Future<(ActionScope, ProviderContainer)> pumpScope(WidgetTester tester) async {
late ActionScope scope;
late ProviderContainer container;
await tester.pumpTestWidget(
Consumer(
builder: (innerContext, ref, _) {
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
container = ProviderScope.containerOf(innerContext, listen: false);
return const SizedBox.shrink();
},
),
overrides: seededOverrides(),
);
return (scope, container);
}
group('TimelineAction', () {
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
final inner = _FakeAction();
final (scope, container) = await pumpScope(tester);
await TimelineAction(action: inner).onAction(scope);
expect(inner.ran, isTrue);
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
expect(container.read(multiSelectProvider).isEnabled, isFalse);
});
testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async {
final error = Exception('boom');
final inner = _FakeAction(error: error);
final (scope, container) = await pumpScope(tester);
await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error)));
expect(inner.ran, isTrue);
expect(container.read(multiSelectProvider).isEnabled, isTrue);
});
testWidgets('delegates visibility to the wrapped action', (tester) async {
await tester.pumpTestWidget(
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
overrides: context.overrides,
);
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
expect(find.byIcon(Icons.bolt), findsNothing);
});
});
}
@@ -13,7 +13,7 @@ void main() {
late PresentationContext context;
setUp(() async => context = await PresentationContext.create());
tearDown(() => context.dispose());
tearDown(() async => await context.dispose());
group('PartnerSharedByList', () {
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
+10 -11
View File
@@ -23,7 +23,7 @@ import 'mocks.dart';
class PresentationContext {
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
setup();
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
}
static const String serverEndpoint = 'http://localhost:3000';
@@ -46,14 +46,10 @@ class PresentationContext {
return PresentationContext._(user: UserFactory.createDto());
}
void setup() {
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
}
void dispose() {
addTearDown(() {
mocks.resetAll();
});
Future<void> dispose() async {
// TODO: Dispose the store and database after each test.
// This is currently not possible because the store is a singleton and is used across tests.
// Refactor the store to be created per test to allow proper disposal.
}
}
@@ -77,7 +73,7 @@ extension PumpPresentationWidget on WidgetTester {
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: Scaffold(body: widget),
home: Material(child: widget),
),
),
),
@@ -87,7 +83,10 @@ extension PumpPresentationWidget on WidgetTester {
}
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
await pumpTestWidget(
Scaffold(body: ActionIconButtonWidget(action: action)),
overrides: overrides,
);
await tap(find.byType(ImmichIconButton));
await pump();
}
+2 -2
View File
@@ -5,8 +5,8 @@
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "plugin-sdk prepareBuild && tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i dist/index.d.ts -o dist/plugin.wasm"
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
"author": "",
+27
View File
@@ -0,0 +1,27 @@
// keep in sync with plugin-sdk/host-functions.ts';
declare module 'extism:host' {
interface user {
searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
}
}
// keep in sync with manifest.json
declare module 'main' {
// filters
export function assetFileFilter(): I32;
export function assetMissingTimeZoneFilter(): I32;
export function assetLocationFilter(): I32;
export function assetTypeFilter(): I32;
// updates
export function assetFavorite(): I32;
export function assetVisibility(): I32;
export function assetArchive(): I32;
export function assetLock(): I32;
export function assetTimeline(): I32;
// export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
}
+144 -126
View File
@@ -1,157 +1,175 @@
import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type { Manifest } from '../dist/index.d.ts';
import type manifestType from '../dist/manifest';
const wrapper = getWrapper<Manifest>();
const wrapper = getWrapper<manifestType>();
export const assetFileFilter = wrapper<'assetFileFilter'>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
export const assetFileFilter = () => {
return wrapper<'assetFileFilter'>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data;
const { asset } = data;
const fileName = asset.originalFileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
const fileName = asset.originalFileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
}
default: {
return {};
}
}
});
};
export const assetMissingTimeZoneFilter = () => {
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
});
};
export const assetLocationFilter = () => {
return wrapper<'assetLocationFilter'>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
) {
return { workflow: { continue: false } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
return { workflow: { continue: true } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
const assetLat = data.asset.exifInfo?.latitude;
const assetLon = data.asset.exifInfo?.longitude;
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
return { workflow: { continue: false } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
const earthDiameter = 12742;
const deg = Math.PI / 180;
const delta = Math.asin(
Math.sqrt(
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
Math.cos(assetLat * deg) *
Math.cos(configLat * deg) *
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
),
);
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
});
};
export const assetTypeFilter = () => {
return wrapper<'assetTypeFilter'>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
};
export const assetFavorite = () => {
return wrapper<'assetFavorite'>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
changes: {
asset: { isFavorite: target },
},
};
}
});
};
export const assetVisibility = () => {
return wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
}));
};
export const assetArchive = () => {
return wrapper<'assetArchive'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
default: {
return {};
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
}
});
export const assetMissingTimeZoneFilter = wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
});
return {};
});
};
export const assetLocationFilter = wrapper<'assetLocationFilter'>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
) {
return { workflow: { continue: false } };
}
export const assetLock = () => {
return wrapper<'assetLock'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
if (config.inverse && data.asset.visibility === AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
return { workflow: { continue: true } };
}
const assetLat = data.asset.exifInfo?.latitude;
const assetLon = data.asset.exifInfo?.longitude;
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
return { workflow: { continue: false } };
}
const earthDiameter = 12742;
const deg = Math.PI / 180;
const delta = Math.asin(
Math.sqrt(
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
Math.cos(assetLat * deg) *
Math.cos(configLat * deg) *
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
),
);
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
});
export const assetTypeFilter = wrapper<'assetTypeFilter'>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
export const assetFavorite = wrapper<'assetFavorite'>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
changes: {
asset: { isFavorite: target },
},
};
}
});
export const assetVisibility = wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
}));
export const assetArchive = wrapper<'assetArchive'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
});
export const assetLock = wrapper<'assetLock'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
});
return {};
});
};
// export const assetTrash = () => {
// // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// };
export const assetAddToAlbums = wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
const assetId = data.asset.id;
export const assetAddToAlbums = () => {
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
if (!config.albumName) {
if (config.albumIds.length === 0) {
if (!config.albumName) {
return {};
}
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
config.albumIds.push(existing.id);
}
if (config.albumIds.length === 1) {
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
return {};
}
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
config.albumIds.push(existing.id);
}
if (config.albumIds.length === 1) {
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
return {};
}
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
return {};
});
});
};
+1 -1
View File
@@ -13,7 +13,7 @@
"skipLibCheck": true, // Skip type checking of declaration files
"strict": true, // Enable all strict type-checking options
"target": "es2020", // Specify ECMAScript target version
"types": ["./dist/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
"types": ["./src/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
},
"exclude": [
"node_modules" // Exclude the node_modules directory
+1 -2
View File
@@ -1,12 +1,11 @@
import esbuild from 'esbuild';
esbuild.build({
entryPoints: ['src/index.ts', 'src/cli.ts'],
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
sourcemap: false,
minify: false,
format: 'esm',
platform: 'node',
target: ['es2020'],
});
-6
View File
@@ -21,9 +21,6 @@
"files": [
"dist"
],
"bin": {
"plugin-sdk": "./plugin-sdk.mjs"
},
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
@@ -38,8 +35,5 @@
},
"peerDependencies": {
"@extism/js-pdk": "^1.1.1"
},
"dependencies": {
"commander": "^15.0.0"
}
}
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env node
import "./dist/cli.js";
-43
View File
@@ -1,43 +0,0 @@
import { Command } from 'commander';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { availableFunctions } from 'src/host-functions.js';
const program = new Command('plugin-sdk');
program
.command('prepareBuild')
.description('Generate .d.ts file required for extism')
.argument(
'[manifest]',
"Path to the plugins's manifest file",
'manifest.json',
)
.option('-o --output', 'Output file for generated types', 'dist/index.d.ts')
.action((manifest: string, { output }) => {
const content = readFileSync(manifest, { encoding: 'utf-8' });
const methods = (
JSON.parse(content) as { methods: { name: string }[] }
).methods.map(({ name }) => name);
mkdirSync(dirname(output), { recursive: true });
writeFileSync(
output,
`
declare module 'extism:host' {
interface user {
${availableFunctions.map((functionName) => ` ${functionName}(ptr: PTR): I64;`).join('\n')}
}
}
declare module 'main' {
${methods.map((method) => ` export function ${method}(): I32;`).join('\n')}
}
export type Manifest = ${content};
`,
);
});
program.parse();
+8 -12
View File
@@ -6,11 +6,14 @@ import {
type CreateAlbumDto,
} from '@immich/sdk';
// keep in sync with plugin-core/src/index.d.ts';
declare module 'extism:host' {
interface user extends Record<
(typeof availableFunctions)[number],
(ptr: PTR) => I64
> {}
interface user {
searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
}
}
type AlbumsToAssets = {
@@ -31,13 +34,6 @@ type HostFunctionResult<T> =
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
export const availableFunctions = [
'searchAlbums',
'createAlbum',
'addAssetsToAlbum',
'addAssetsToAlbums',
] as const;
export const hostFunctions = (authToken: string) => {
const host = Host.getFunctions();
type HostFunctionName = keyof typeof host;
@@ -79,5 +75,5 @@ export const hostFunctions = (authToken: string) => {
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
} satisfies Record<(typeof availableFunctions)[number], unknown>;
};
};
+1 -2
View File
@@ -67,8 +67,7 @@ export const getWrapper =
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<L> | undefined,
) =>
() => {
) => {
const input = Host.inputString();
try {
-4
View File
@@ -336,10 +336,6 @@ importers:
version: 6.0.3
packages/plugin-sdk:
dependencies:
commander:
specifier: ^15.0.0
version: 15.0.0
devDependencies:
'@extism/js-pdk':
specifier: ^1.1.1
@@ -102,7 +102,7 @@
const stackSelectedThumbnailSize = 65;
let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null);
let stack: StackResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
@@ -127,7 +127,7 @@
}
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
stack = undefined;
}
};
@@ -149,6 +149,22 @@
}
};
const onStackCreate = (createdStack: StackResponseDto) => {
if (createdStack.assets.map((a) => a.id).includes(asset.id)) {
stack = createdStack;
}
};
const onStackUpdate = (updatedStack: StackResponseDto) => {
if (stack?.id === updatedStack.id) {
stack = updatedStack;
if (!stack.assets.map((a) => a.id).includes(asset.id)) {
// current asset was removed from stack, go to primary
cursor.current = stack.assets[0];
}
}
};
onMount(() => {
syncAssetViewerOpenClass(true);
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
@@ -319,18 +335,6 @@
eventManager.emit('AssetsDelete', [asset.id]);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (stack) {
cursor.current = stack.assets[0];
}
break;
}
case AssetAction.STACK:
case AssetAction.SET_STACK_PRIMARY_ASSET: {
stack = action.stack;
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
const assetInfo = await getAssetInfo({ id: asset.id });
cursor.current = { ...asset, people: assetInfo.people };
@@ -347,10 +351,6 @@
};
break;
}
case AssetAction.UNSTACK: {
closeViewer();
break;
}
// no default
}
@@ -475,7 +475,7 @@
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
<OnEvents {onAssetUpdate} />
<OnEvents {onAssetUpdate} {onStackCreate} onStackDelete={() => closeViewer()} {onStackUpdate} />
<svelte:document
bind:fullscreenElement
@@ -1,17 +1,12 @@
<script lang="ts">
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/ArchiveAction.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/DeleteAction.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/KeepThisDeleteOthers.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/RatingAction.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/RestoreAction.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/SetVisibilityAction.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/UnstackAction.svelte';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
@@ -21,6 +16,7 @@
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getStackActions } from '$lib/services/stack.service';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -40,7 +36,7 @@
asset: AssetResponseDto;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
stack?: StackResponseDto;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
@@ -54,7 +50,7 @@
asset,
album = null,
person = null,
stack = null,
stack,
preAction,
onAction,
onUndoDelete = undefined,
@@ -86,6 +82,7 @@
});
const Actions = $derived(getAssetActions($t, { ...asset, stackPrimaryAssetId: stack?.primaryAssetId }));
const StackActions = $derived(getStackActions($t, stack, asset));
const sharedLink = getSharedLink();
</script>
@@ -150,19 +147,12 @@
<RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem />
{/if}
{#if isOwner}
<AddToStackAction {asset} {stack} {onAction} />
{#if stack}
<UnstackAction {stack} {onAction} />
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
{#if stack?.primaryAssetId !== asset.id}
<SetStackPrimaryAsset {stack} {asset} {onAction} />
{#if stack?.assets?.length > 2}
<RemoveAssetFromStack {asset} {stack} {onAction} />
{/if}
{/if}
{/if}
{/if}
<ActionMenuItem action={StackActions.AddUploads} />
<ActionMenuItem action={StackActions.Unstack} />
<ActionMenuItem action={StackActions.KeepThisDeleteOthers} />
<ActionMenuItem action={StackActions.SetPrimaryAsset} />
<ActionMenuItem action={StackActions.RemoveAsset} />
{#if album}
{@const { SetCover } = getAlbumAssetActions($t, album, asset)}
<ActionMenuItem action={SetCover} />
@@ -1,37 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { createStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { mdiUploadMultiple } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
stack: StackResponseDto | null;
onAction: OnAction;
}
let { asset, stack, onAction }: Props = $props();
const handleAddUploadToStack = async () => {
const newAssetIds = await openFileUploadDialog({ multiple: true });
// Including the old stacks primary asset ID ensures that all assets of the
// old stack are automatically included in the new stack.
const primaryAssetId = stack?.primaryAssetId ?? asset.id;
// First asset in the list will become the new primary asset.
const assetIds = [primaryAssetId, ...newAssetIds];
const newStack = await createStack({
stackCreateDto: {
assetIds,
},
});
onAction({ type: AssetAction.STACK, stack: newStack });
};
</script>
<MenuOption icon={mdiUploadMultiple} onClick={handleAddUploadToStack} text={$t('add_upload_to_stack')} />
@@ -1,38 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiPinOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
stack: StackResponseDto;
asset: AssetResponseDto;
onAction: OnAction;
}
let { stack, asset, onAction }: Props = $props();
const handleKeepThisDeleteOthers = async () => {
const isConfirmed = await modalManager.showDialog({
title: $t('keep_this_delete_others'),
prompt: $t('confirm_keep_this_delete_others'),
confirmText: $t('delete_others'),
});
if (!isConfirmed) {
return;
}
const keptAsset = await keepThisDeleteOthers(asset, stack);
if (keptAsset) {
onAction({ type: AssetAction.UNSTACK, assets: [toTimelineAsset(keptAsset)] });
}
};
</script>
<MenuOption icon={mdiPinOutline} onClick={handleKeepThisDeleteOthers} text={$t('keep_this_delete_others')} />
@@ -1,31 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { removeAssetFromStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { mdiImageMinusOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
stack: StackResponseDto;
onAction: OnAction;
}
let { asset, stack, onAction }: Props = $props();
const handleRemoveFromStack = async () => {
await removeAssetFromStack({
id: stack.id,
assetId: asset.id,
});
const updatedStack = {
...stack,
assets: stack.assets.filter((a) => a.id !== asset.id),
};
onAction({ type: AssetAction.REMOVE_ASSET_FROM_STACK, stack: updatedStack, asset });
};
</script>
<MenuOption icon={mdiImageMinusOutline} onClick={handleRemoveFromStack} text={$t('viewer_remove_from_stack')} />
@@ -1,26 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { updateStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { mdiImageCheckOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
stack: StackResponseDto;
asset: AssetResponseDto;
onAction: OnAction;
}
let { stack, asset, onAction }: Props = $props();
const handleSetPrimaryAsset = async () => {
const updatedStack = await updateStack({ id: stack.id, stackUpdateDto: { primaryAssetId: asset.id } });
if (updatedStack) {
onAction({ type: AssetAction.SET_STACK_PRIMARY_ASSET, stack: updatedStack });
}
};
</script>
<MenuOption icon={mdiImageCheckOutline} onClick={handleSetPrimaryAsset} text={$t('set_stack_primary_asset')} />
@@ -1,26 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { deleteStack } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { StackResponseDto } from '@immich/sdk';
import { mdiImageOffOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
stack: StackResponseDto;
onAction: OnAction;
}
let { stack, onAction }: Props = $props();
const handleUnstack = async () => {
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets.map((asset) => toTimelineAsset(asset)) });
}
};
</script>
<MenuOption icon={mdiImageOffOutline} onClick={handleUnstack} text={$t('unstack')} />
@@ -1,4 +1,4 @@
import type { AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
import type { AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import type { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -8,10 +8,6 @@ type ActionMap = {
[AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.STACK]: { stack: StackResponseDto };
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
@@ -9,7 +9,6 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleErrorAsync } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
@@ -150,52 +149,6 @@
timelineManager.upsertAssets([action.asset]);
break;
}
case AssetAction.STACK: {
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack.primaryAssetId)
.map((asset) => asset.id),
});
break;
}
case AssetAction.UNSTACK: {
updateUnstackedAssetInTimeline(timelineManager, action.assets);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
timelineManager.upsertAssets([toTimelineAsset(action.asset)]);
if (action.stack) {
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack?.primaryAssetId)
.map((asset) => asset.id),
});
}
break;
}
case AssetAction.SET_STACK_PRIMARY_ASSET: {
//Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack.primaryAssetId)
.map((asset) => asset.id),
});
break;
}
// no default
}
};
@@ -1,45 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import type { OnStack, OnUnstack } from '$lib/utils/actions';
import { deleteStack, stackAssets } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { mdiImageMultipleOutline, mdiImageOffOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
unstack?: boolean;
onStack: OnStack | undefined;
onUnstack: OnUnstack | undefined;
}
let { unstack = false, onStack, onUnstack }: Props = $props();
const handleStack = async () => {
const result = await stackAssets(assetMultiSelectManager.ownedAssets);
onStack?.(result);
assetMultiSelectManager.clear();
};
const handleUnstack = async () => {
const selectedAssets = assetMultiSelectManager.ownedAssets;
if (selectedAssets.length !== 1) {
return;
}
const { stack } = selectedAssets[0];
if (!stack) {
return;
}
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
}
assetMultiSelectManager.clear();
};
</script>
{#if unstack}
<MenuOption text={$t('unstack')} icon={mdiImageOffOutline} onClick={handleUnstack} />
{:else}
<MenuOption text={$t('stack')} icon={mdiImageMultipleOutline} onClick={handleStack} />
{/if}
@@ -15,12 +15,13 @@
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import { handleStack } from '$lib/services/stack.service';
import { keyboardManager } from '$lib/stores/keyboard-manager.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, selectAllAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { isModalOpen, modalManager } from '@immich/ui';
@@ -59,10 +60,7 @@
};
const onStackAssets = async () => {
const result = await stackAssets(assetInteraction.assets);
updateStackedAssetInTimeline(timelineManager, result);
await handleStack(assetInteraction.assets.map((asset) => asset.id));
onEscape?.();
};
-4
View File
@@ -6,10 +6,6 @@ export enum AssetAction {
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
STACK = 'stack',
UNSTACK = 'unstack',
SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack',
SET_VISIBILITY_LOCKED = 'set-visibility-locked',
SET_VISIBILITY_TIMELINE = 'set-visibility-timeline',
SET_PERSON_FEATURED_PHOTO = 'set-person-featured-photo',
@@ -11,6 +11,7 @@ import type {
QueueResponseDto,
ReleaseEventV1,
SharedLinkResponseDto,
StackResponseDto,
SystemConfigDto,
TagResponseDto,
UserAdminResponseDto,
@@ -61,6 +62,11 @@ export type Events = {
SharedLinkUpdate: [SharedLinkResponseDto];
SharedLinkDelete: [SharedLinkResponseDto];
StackCreate: [StackResponseDto];
/** Unstacked, with assets to handle */
StackDelete: [{ id: string; assets: AssetResponseDto[] }];
StackUpdate: [StackResponseDto];
TagCreate: [TagResponseDto];
TagUpdate: [TagResponseDto];
TagDelete: [TreeNode];
@@ -17,6 +17,7 @@ import {
retrieveRange as retrieveRangeUtil,
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { updateStackedAssetInTimeline } from '$lib/utils/actions';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import {
@@ -115,6 +116,23 @@ export class TimelineManager extends VirtualScrollManager {
this.#unsubscribes.push(
eventManager.on({
AssetUpdate: (asset: AssetResponseDto) => this.#updateAssets([toTimelineAsset(asset)]),
StackCreate: (stack) => updateStackedAssetInTimeline(this, stack),
StackDelete: ({ assets }) => {
this.update(
assets.map((asset) => asset.id),
(asset) => (asset.stack = null),
);
this.upsertAssets(assets.map((asset) => toTimelineAsset(asset)));
},
StackUpdate: (stack) => {
// unstack and re-stack
this.update(
stack.assets.map((asset) => asset.id),
(asset) => (asset.stack = null),
);
this.upsertAssets(stack.assets.map((asset) => toTimelineAsset(asset)));
updateStackedAssetInTimeline(this, stack);
},
}),
);
}
+216
View File
@@ -0,0 +1,216 @@
import {
createStack,
deleteAssets,
deleteStacks,
getStack,
removeAssetFromStack,
updateStack,
type AssetResponseDto,
type AssetStackResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiImageCheckOutline,
mdiImageMinusOutline,
mdiImageMultipleOutline,
mdiImageOffOutline,
mdiPinOutline,
mdiUploadMultiple,
} from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
export const getStackBulkActions = ($t: MessageFormatter) => {
const Stack: ActionItem = {
title: $t('stack'),
icon: mdiImageMultipleOutline,
$if: () => assetMultiSelectManager.ownedAssets.length > 1,
onAction: () => handleStack(assetMultiSelectManager.ownedAssets.map((asset) => asset.id)),
};
const Unstack: ActionItem = {
title: $t('unstack'),
icon: mdiImageOffOutline,
$if: () => assetMultiSelectManager.ownedAssets.every((asset) => !!asset.stack),
onAction: () => handleDeleteStacks(assetMultiSelectManager.ownedAssets.map(({ stack }) => stack!)),
};
return { Stack, Unstack };
};
export const getStackActions = ($t: MessageFormatter, stack: StackResponseDto | undefined, asset: AssetResponseDto) => {
const authUser = authManager.authenticated ? authManager.user : undefined;
const isAssetOwner = !!(authUser && authUser.id === asset.ownerId);
const validStack = !!stack && isAssetOwner;
const AddUploads: ActionItem = {
title: $t('add_upload_to_stack', { values: { isStack: !!stack } }),
icon: mdiUploadMultiple,
$if: () => isAssetOwner,
onAction: () => handleAddUploadToStack(stack, asset),
};
const KeepThisDeleteOthers: ActionItem = {
title: $t('keep_this_delete_others'),
icon: mdiPinOutline,
$if: () => validStack,
onAction: () => handleKeepThisDeleteOthers(stack!, asset),
};
const RemoveAsset: ActionItem = {
title: $t('viewer_remove_from_stack'),
icon: mdiImageMinusOutline,
$if: () => validStack && stack?.primaryAssetId !== asset.id && stack?.assets?.length > 2,
onAction: () => handleRemoveFromStack(stack!, asset),
};
const SetPrimaryAsset: ActionItem = {
title: $t('set_stack_primary_asset'),
icon: mdiImageCheckOutline,
$if: () => validStack && stack!.primaryAssetId !== asset.id,
onAction: () => handleSetPrimaryAsset(stack!, asset),
};
const Unstack: ActionItem = {
title: $t('unstack'),
icon: mdiImageOffOutline,
$if: () => validStack,
onAction: () => handleDeleteStack(stack!),
};
return { AddUploads, KeepThisDeleteOthers, RemoveAsset, SetPrimaryAsset, Unstack };
};
const handleAddUploadToStack = async (stack: StackResponseDto | undefined, asset: AssetResponseDto) => {
const $t = await getFormatter();
const newAssetIds = await openFileUploadDialog({ multiple: true });
// Including the old stack's primary asset ID ensures that all assets of the
// old stack are automatically included in the new stack.
const primaryAssetId = stack?.primaryAssetId ?? asset.id;
// First asset in the list will become the new primary asset.
const assetIds = [primaryAssetId, ...newAssetIds];
try {
const stack = await createStack({ stackCreateDto: { assetIds } });
toastManager.primary($t('stack_created'));
eventManager.emit('StackCreate', stack);
} catch (error) {
handleError(error, $t('errors.failed_to_stack_assets'));
}
};
const handleKeepThisDeleteOthers = async (stack: StackResponseDto, asset: AssetResponseDto) => {
const $t = await getFormatter();
const isConfirmed = await modalManager.showDialog({
title: $t('keep_this_delete_others'),
prompt: $t('confirm_keep_this_delete_others'),
confirmText: $t('delete_others'),
});
if (!isConfirmed) {
return;
}
try {
const assetsToDeleteIds = stack.assets.filter((a) => a.id !== asset.id).map((asset) => asset.id);
await deleteAssets({ assetBulkDeleteDto: { ids: assetsToDeleteIds } });
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
toastManager.primary($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
eventManager.emit('StackDelete', { id: stack.id, assets: [asset] });
eventManager.emit('AssetUpdate', { ...asset, stack: null });
} catch (error) {
handleError(error, $t('errors.failed_to_keep_this_delete_others'));
}
};
const handleRemoveFromStack = async (stack: StackResponseDto, asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
await removeAssetFromStack({ id: stack.id, assetId: asset.id });
const updatedStack = {
...stack,
assets: stack.assets.filter((a) => a.id !== asset.id),
};
toastManager.primary($t('removed_from_stack'));
eventManager.emit('AssetUpdate', asset); // todo: check if this re-inserts into timeline
eventManager.emit('StackUpdate', updatedStack);
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
};
const handleSetPrimaryAsset = async (stack: StackResponseDto, asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
const updatedStack = await updateStack({ id: stack.id, stackUpdateDto: { primaryAssetId: asset.id } });
// todo: toast?
eventManager.emit('StackUpdate', updatedStack);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
export const handleStack = async (assetIds: string[]) => {
const $t = await getFormatter();
try {
const stack = await createStack({ stackCreateDto: { assetIds } });
toastManager.primary({
description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
button: {
label: $t('view_stack'),
onclick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
},
});
eventManager.emit('StackCreate', stack);
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.failed_to_stack_assets'));
}
};
export const handleDeleteStack = async (stack: StackResponseDto) => {
const $t = await getFormatter();
try {
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
const assetIds = stack.assets.map((asset) => asset.id);
toastManager.primary($t('unstacked_assets_count', { values: { count: assetIds.length } }));
eventManager.emit('StackDelete', stack);
return assetIds;
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
};
const handleDeleteStacks = async (assetStacks: AssetStackResponseDto[]) => {
const $t = await getFormatter();
try {
const stacks = await Promise.all(assetStacks.map((stack) => getStack(stack)));
await Promise.all(stacks.map((stack) => handleDeleteStack(stack)));
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
assetMultiSelectManager.clear();
};
+12 -40
View File
@@ -1,10 +1,9 @@
import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets } from '@immich/sdk';
import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets, type StackResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { StackResponse } from '$lib/utils/asset-utils';
import { handleError } from './handle-error';
export type OnDelete = (assetIds: string[]) => void;
@@ -15,8 +14,6 @@ export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset })
export type OnAddToAlbum = (ids: string[], albumId: string) => void;
export type OnArchive = (ids: string[], visibility: AssetVisibility) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void;
export type OnStack = (result: StackResponse) => void;
export type OnUnstack = (assets: TimelineAsset[]) => void;
export type OnSetVisibility = (ids: string[]) => void;
export const deleteAssets = async (
@@ -62,43 +59,18 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse
/**
* Update the asset stack state in the asset store based on the provided stack response.
* This function updates the stack information so that the icon is shown for the primary asset
* and removes any assets from the timeline that are marked for deletion.
*
* @param {TimelineManager} timelineManager - The timeline manager to update.
* @param {StackResponse} stackResponse - The stack response containing the stack and assets to delete.
* and removes the non-primary assets from the timeline.
*/
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) {
if (stack != undefined) {
timelineManager.update(
[stack.primaryAssetId],
(asset) =>
(asset.stack = {
id: stack.id,
primaryAssetId: stack.primaryAssetId,
assetCount: stack.assets.length,
}),
);
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, stack: StackResponseDto) {
timelineManager.update([stack.primaryAssetId], (asset) => {
asset.stack = {
id: stack.id,
primaryAssetId: stack.primaryAssetId,
assetCount: stack.assets.length,
};
});
timelineManager.removeAssets(toDeleteIds);
}
}
/**
* Update the timeline manager to reflect the unstacked state of assets.
* This function updates the stack property of each asset to undefined, effectively unstacking them.
* It also adds the unstacked assets back to the timeline manager.
*
* @param timelineManager - The timeline manager to update.
* @param assets - The array of asset response DTOs to update in the timeline manager.
*/
export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) {
timelineManager.update(
assets.map((asset) => asset.id),
(asset) => {
asset.stack = null;
return { remove: false };
},
timelineManager.removeAssets(
stack.assets.filter((asset) => asset.id !== stack.primaryAssetId).map((asset) => asset.id),
);
timelineManager.upsertAssets(assets);
}
-83
View File
@@ -1,12 +1,8 @@
import {
AssetVisibility,
bulkTagAssets,
createStack,
deleteAssets,
deleteStacks,
getBaseUrl,
getDownloadInfo,
getStack,
untagAssets,
updateAsset,
updateAssets,
@@ -14,7 +10,6 @@ import {
type AssetTypeEnum,
type DownloadInfoDto,
type ExifResponseDto,
type StackResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
@@ -281,84 +276,6 @@ export const getOwnedAssetsWithWarning = (assets: TimelineAsset[], user: UserRes
return ids;
};
export type StackResponse = {
stack?: StackResponseDto;
toDeleteIds: string[];
};
export const stackAssets = async (assets: { id: string }[], showNotification = true): Promise<StackResponse> => {
if (assets.length < 2) {
return { stack: undefined, toDeleteIds: [] };
}
const $t = get(t);
try {
const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } });
if (showNotification) {
toastManager.primary({
description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
button: {
label: $t('view_stack'),
onclick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
},
});
}
return {
stack,
toDeleteIds: assets.slice(1).map((asset) => asset.id),
};
} catch (error) {
handleError(error, $t('errors.failed_to_stack_assets'));
return { stack: undefined, toDeleteIds: [] };
}
};
export const deleteStack = async (stackIds: string[]) => {
const ids = [...new Set(stackIds)];
if (ids.length === 0) {
return;
}
const $t = get(t);
try {
const stacks = await Promise.all(ids.map((id) => getStack({ id })));
const count = stacks.reduce((sum, stack) => sum + stack.assets.length, 0);
await deleteStacks({ bulkIdsDto: { ids: [...ids] } });
toastManager.primary($t('unstacked_assets_count', { values: { count } }));
const assets = stacks.flatMap((stack) => stack.assets);
for (const asset of assets) {
asset.stack = null;
}
return assets;
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
};
export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: StackResponseDto) => {
const $t = get(t);
try {
const assetsToDeleteIds = stack.assets.filter((asset) => asset.id !== keepAsset.id).map((asset) => asset.id);
await deleteAssets({ assetBulkDeleteDto: { ids: assetsToDeleteIds } });
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
toastManager.primary($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
keepAsset.stack = null;
return keepAsset;
} catch (error) {
handleError(error, $t('errors.failed_to_keep_this_delete_others'));
}
};
export const selectAllAssets = async (timelineManager: TimelineManager, assetInteraction: AssetMultiSelectManager) => {
if (assetInteraction.selectAll) {
// Selection is already ongoing
@@ -13,7 +13,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -22,13 +21,9 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { getStackBulkActions } from '$lib/services/stack.service';
import { mapSettings } from '$lib/stores/preferences.store';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon } from '@immich/ui';
import { mdiDotsVertical, mdiImageMultiple } from '@mdi/js';
@@ -46,7 +41,6 @@
let timelineManager = $state<TimelineManager>() as TimelineManager;
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -85,6 +79,7 @@
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
isFavorite: $mapSettings.onlyFavorites || undefined,
withPartners: $mapSettings.withPartners || undefined,
withStacked: true,
assetFilter: selectedClusterIds,
});
@@ -113,12 +108,14 @@
onEscape={handleEscape}
assetInteraction={assetMultiSelectManager}
showArchiveIcon
withStacked
/>
</div>
</aside>
{#if assetMultiSelectManager.selectionActive}
{@const Actions = getAssetBulkActions($t)}
{@const StackActions = getStackBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<Portal target="body">
@@ -135,13 +132,8 @@
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
<ActionMenuItem action={StackActions.Stack} />
<ActionMenuItem action={StackActions.Unstack} />
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
@@ -481,7 +481,10 @@
<ArchiveAction
menuItem
unarchive={assetMultiSelectManager.isAllArchived}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
onArchive={(ids, visibility) =>
timelineManager.update(ids, (asset) => {
asset.visibility = visibility;
})}
/>
{#if authManager.preferences.tags.enabled && assetMultiSelectManager.isAllUserOwned}
<TagAction menuItem />
@@ -14,7 +14,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -26,13 +25,9 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { getStackBulkActions } from '$lib/services/stack.service';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -45,7 +40,6 @@
const options = { visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true };
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -114,6 +108,7 @@
{#if assetMultiSelectManager.selectionActive}
<AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)}
{@const StackActions = getStackBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
@@ -128,13 +123,8 @@
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
<ActionMenuItem action={StackActions.Stack} />
<ActionMenuItem action={StackActions.Unstack} />
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
@@ -14,7 +14,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -24,18 +23,14 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetVisibility, AssetOrderBy } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { getStackBulkActions } from '$lib/services/stack.service';
type Props = {
data: PageData;
@@ -52,7 +47,6 @@
};
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -108,6 +102,7 @@
{#if assetMultiSelectManager.selectionActive}
<AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)}
{@const StackActions = getStackBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
@@ -122,13 +117,8 @@
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
<ActionMenuItem action={StackActions.Stack} />
<ActionMenuItem action={StackActions.Unstack} />
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem