mirror of
https://github.com/immich-app/immich.git
synced 2025-12-10 01:10:21 +03:00
Compare commits
5 Commits
fix/asset-
...
push-nklmv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c107284b | ||
|
|
1109c32891 | ||
|
|
3c80049192 | ||
|
|
8f1669efbe | ||
|
|
146bf65d02 |
@@ -58,10 +58,6 @@ services:
|
||||
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
||||
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
||||
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 1048576
|
||||
hard: 1048576
|
||||
ports:
|
||||
- 9230:9230
|
||||
- 9231:9231
|
||||
@@ -100,10 +96,6 @@ services:
|
||||
- app-node_modules:/usr/src/app/node_modules
|
||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 1048576
|
||||
hard: 1048576
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
immich-server:
|
||||
|
||||
@@ -32,8 +32,6 @@ server {
|
||||
|
||||
# enable websockets: http://nginx.org/en/docs/http/websocket.html
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_redirect off;
|
||||
|
||||
# set timeout
|
||||
@@ -43,6 +41,8 @@ server {
|
||||
|
||||
location / {
|
||||
proxy_pass http://<backend_url>:2283;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# useful when using Let's Encrypt http-01 challenge
|
||||
|
||||
@@ -37,7 +37,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(album.name),
|
||||
actions: [const LikeActivityActionButton(menuItem: true)],
|
||||
actions: [const LikeActivityActionButton(iconOnly: true)],
|
||||
actionsPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
body: activities.widgetWhen(
|
||||
|
||||
@@ -21,12 +21,34 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
|
||||
|
||||
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
||||
|
||||
class AddActionButton extends ConsumerWidget {
|
||||
class AddActionButton extends ConsumerStatefulWidget {
|
||||
const AddActionButton({super.key});
|
||||
|
||||
Future<void> _showAddOptions(BuildContext context, WidgetRef ref) async {
|
||||
@override
|
||||
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
|
||||
}
|
||||
|
||||
class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
void _handleMenuSelection(AddToMenuItem selected) {
|
||||
switch (selected) {
|
||||
case AddToMenuItem.album:
|
||||
_openAlbumSelector();
|
||||
break;
|
||||
case AddToMenuItem.archive:
|
||||
performArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.unarchive:
|
||||
performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.lockedFolder:
|
||||
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildMenuChildren() {
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) return;
|
||||
if (asset == null) return [];
|
||||
|
||||
final user = ref.read(currentUserProvider);
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
@@ -35,93 +57,57 @@ class AddActionButton extends ConsumerWidget {
|
||||
final hasRemote = asset is RemoteAsset;
|
||||
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
|
||||
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
|
||||
final menuItemHeight = 30.0;
|
||||
|
||||
final List<PopupMenuEntry<AddToMenuItem>> items = [
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
textStyle: context.textTheme.labelMedium,
|
||||
height: 40,
|
||||
child: Text("add_to_bottom_bar".tr()),
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium),
|
||||
),
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.album,
|
||||
child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())),
|
||||
BaseActionButton(
|
||||
iconData: Icons.photo_album_outlined,
|
||||
label: "album".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.album),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())),
|
||||
|
||||
if (isOwner) ...[
|
||||
const PopupMenuDivider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
|
||||
),
|
||||
if (showArchive)
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.archive,
|
||||
child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())),
|
||||
BaseActionButton(
|
||||
iconData: Icons.archive_outlined,
|
||||
label: "archive".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
|
||||
),
|
||||
if (showUnarchive)
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.unarchive,
|
||||
child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())),
|
||||
BaseActionButton(
|
||||
iconData: Icons.unarchive_outlined,
|
||||
label: "unarchive".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
|
||||
),
|
||||
PopupMenuItem(
|
||||
height: menuItemHeight,
|
||||
value: AddToMenuItem.lockedFolder,
|
||||
child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())),
|
||||
BaseActionButton(
|
||||
iconData: Icons.lock_outline,
|
||||
label: "locked_folder".tr(),
|
||||
menuItem: true,
|
||||
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
final AddToMenuItem? selected = await showMenu<AddToMenuItem>(
|
||||
context: context,
|
||||
color: context.themeData.scaffoldBackgroundColor,
|
||||
position: _menuPosition(context),
|
||||
items: items,
|
||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
||||
);
|
||||
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (selected) {
|
||||
case AddToMenuItem.album:
|
||||
_openAlbumSelector(context, ref);
|
||||
break;
|
||||
case AddToMenuItem.archive:
|
||||
await performArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.unarchive:
|
||||
await performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
case AddToMenuItem.lockedFolder:
|
||||
await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
RelativeRect _menuPosition(BuildContext context) {
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject is! RenderBox) {
|
||||
return RelativeRect.fill;
|
||||
}
|
||||
|
||||
final size = renderObject.size;
|
||||
final position = renderObject.localToGlobal(Offset.zero);
|
||||
|
||||
return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy);
|
||||
}
|
||||
|
||||
void _openAlbumSelector(BuildContext context, WidgetRef ref) {
|
||||
void _openAlbumSelector() {
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
if (currentAsset == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
final List<Widget> slivers = [
|
||||
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)),
|
||||
];
|
||||
final List<Widget> slivers = [AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album))];
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -141,7 +127,7 @@ class AddActionButton extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async {
|
||||
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
|
||||
final latest = ref.read(currentAssetNotifier);
|
||||
|
||||
if (latest == null) {
|
||||
@@ -174,17 +160,27 @@ class AddActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Builder(
|
||||
builder: (buttonContext) {
|
||||
|
||||
return MenuAnchor(
|
||||
consumeOutsideTap: true,
|
||||
style: MenuStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||
elevation: const WidgetStatePropertyAll(4),
|
||||
shape: const WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||
),
|
||||
),
|
||||
menuChildren: _buildMenuChildren(),
|
||||
builder: (context, controller, child) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.add,
|
||||
label: "add_to_bottom_bar".tr(),
|
||||
onPressed: () => _showAddOptions(buttonContext, ref),
|
||||
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ class BaseActionButton extends StatelessWidget {
|
||||
this.onLongPressed,
|
||||
this.maxWidth = 90.0,
|
||||
this.minWidth,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
});
|
||||
|
||||
@@ -19,6 +20,11 @@ class BaseActionButton extends StatelessWidget {
|
||||
final Color? iconColor;
|
||||
final double maxWidth;
|
||||
final double? minWidth;
|
||||
|
||||
/// When true, renders only an IconButton without text label
|
||||
final bool iconOnly;
|
||||
|
||||
/// When true, renders as a MenuItemButton for use in MenuAnchor menus
|
||||
final bool menuItem;
|
||||
final void Function()? onPressed;
|
||||
final void Function()? onLongPressed;
|
||||
@@ -31,13 +37,26 @@ class BaseActionButton extends StatelessWidget {
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
final textColor = context.themeData.textTheme.labelLarge?.color;
|
||||
|
||||
if (menuItem) {
|
||||
if (iconOnly) {
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||
);
|
||||
}
|
||||
|
||||
if (menuItem) {
|
||||
final theme = context.themeData;
|
||||
final effectiveStyle = theme.textTheme.labelLarge;
|
||||
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return MenuItemButton(
|
||||
style: MenuItemButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12)),
|
||||
leadingIcon: Icon(iconData, color: effectiveIconColor, size: 20),
|
||||
onPressed: onPressed,
|
||||
child: Text(label, style: effectiveStyle),
|
||||
);
|
||||
}
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: MaterialButton(
|
||||
|
||||
@@ -7,8 +7,9 @@ import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||
|
||||
class CastActionButton extends ConsumerWidget {
|
||||
const CastActionButton({super.key, this.menuItem = true});
|
||||
const CastActionButton({super.key, this.iconOnly = true, this.menuItem = false});
|
||||
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
@override
|
||||
@@ -22,6 +23,7 @@ class CastActionButton extends ConsumerWidget {
|
||||
onPressed: () {
|
||||
showDialog(context: context, builder: (context) => const CastDialog());
|
||||
},
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class DownloadActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
|
||||
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
||||
if (!context.mounted) {
|
||||
@@ -38,6 +39,7 @@ class DownloadActionButton extends ConsumerWidget {
|
||||
iconData: Icons.download,
|
||||
maxWidth: 95,
|
||||
label: "download".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||
);
|
||||
|
||||
@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class FavoriteActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const FavoriteActionButton({super.key, required this.source, this.menuItem = false});
|
||||
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
@@ -44,6 +45,7 @@ class FavoriteActionButton extends ConsumerWidget {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.favorite_border_rounded,
|
||||
label: "favorite".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
|
||||
@@ -12,8 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
class LikeActivityActionButton extends ConsumerWidget {
|
||||
const LikeActivityActionButton({super.key, this.menuItem = false});
|
||||
const LikeActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
@override
|
||||
@@ -49,6 +50,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
||||
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
|
||||
label: "like".t(context: context),
|
||||
onPressed: () => onTap(liked),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
);
|
||||
},
|
||||
@@ -57,6 +59,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
||||
loading: () => BaseActionButton(
|
||||
iconData: Icons.favorite_border,
|
||||
label: "like".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),
|
||||
|
||||
@@ -5,8 +5,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
|
||||
class MotionPhotoActionButton extends ConsumerWidget {
|
||||
const MotionPhotoActionButton({super.key, this.menuItem = true});
|
||||
const MotionPhotoActionButton({super.key, this.iconOnly = true, this.menuItem = false});
|
||||
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
@override
|
||||
@@ -17,6 +18,7 @@ class MotionPhotoActionButton extends ConsumerWidget {
|
||||
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
|
||||
label: "play_motion_photo".t(context: context),
|
||||
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class UnFavoriteActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const UnFavoriteActionButton({super.key, required this.source, this.menuItem = false});
|
||||
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
@@ -45,6 +46,7 @@ class UnFavoriteActionButton extends ConsumerWidget {
|
||||
iconData: Icons.favorite_rounded,
|
||||
label: "unfavorite".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_actio
|
||||
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/asset_viewer.state.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/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
@@ -65,8 +66,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
|
||||
final actions = <Widget>[
|
||||
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
@@ -85,16 +86,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
tooltip: 'view_in_timeline'.t(context: context),
|
||||
),
|
||||
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
||||
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
||||
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
if (asset.hasRemote && isOwner && asset.isFavorite)
|
||||
const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true),
|
||||
const _KebabMenu(),
|
||||
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
const ViewerKebabMenu(),
|
||||
];
|
||||
|
||||
final lockedViewActions = <Widget>[
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||
const _KebabMenu(),
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true),
|
||||
const ViewerKebabMenu(),
|
||||
];
|
||||
|
||||
return IgnorePointer(
|
||||
@@ -122,20 +123,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
Size get preferredSize => const Size.fromHeight(60.0);
|
||||
}
|
||||
|
||||
class _KebabMenu extends ConsumerWidget {
|
||||
const _KebabMenu();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
EventStream.shared.emit(const ViewerOpenBottomSheetEvent());
|
||||
},
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppBarBackButton extends ConsumerWidget {
|
||||
const _AppBarBackButton();
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
|
||||
class ViewerKebabMenu extends ConsumerWidget {
|
||||
const ViewerKebabMenu({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final menuChildren = <Widget>[
|
||||
BaseActionButton(
|
||||
label: 'about'.tr(),
|
||||
iconData: Icons.info_outline,
|
||||
menuItem: true,
|
||||
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
||||
),
|
||||
];
|
||||
|
||||
return MenuAnchor(
|
||||
consumeOutsideTap: true,
|
||||
style: MenuStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||
elevation: const WidgetStatePropertyAll(4),
|
||||
shape: const WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||
),
|
||||
),
|
||||
menuChildren: menuChildren,
|
||||
builder: (context, controller, child) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.more_vert_rounded),
|
||||
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: ChangePasswordRoute.page),
|
||||
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
|
||||
CustomRoute(
|
||||
AutoRoute(
|
||||
page: TabControllerRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
children: [
|
||||
@@ -176,9 +176,8 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
],
|
||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
),
|
||||
CustomRoute(
|
||||
AutoRoute(
|
||||
page: TabShellRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
children: [
|
||||
@@ -187,7 +186,6 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
],
|
||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
),
|
||||
CustomRoute(
|
||||
page: GalleryViewerRoute.page,
|
||||
|
||||
540
web/src/lib/utils/cancellable-task.spec.ts
Normal file
540
web/src/lib/utils/cancellable-task.spec.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
|
||||
describe('CancellableTask', () => {
|
||||
describe('execute', () => {
|
||||
it('should execute task successfully and return LOADED', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async (_: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(task.loading).toBe(false);
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call loadedCallback when task completes successfully', async () => {
|
||||
const loadedCallback = vi.fn();
|
||||
const task = new CancellableTask(loadedCallback);
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(loadedCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should wait if task is already running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = vi.fn(async () => {
|
||||
await taskPromise;
|
||||
});
|
||||
|
||||
const promise1 = task.execute(taskFn, true);
|
||||
const promise2 = task.execute(taskFn, true);
|
||||
|
||||
expect(task.loading).toBe(true);
|
||||
resolveTask!();
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
expect(result1).toBe('LOADED');
|
||||
expect(result2).toBe('WAITED');
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass AbortSignal to task function', async () => {
|
||||
const task = new CancellableTask();
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await Promise.resolve();
|
||||
capturedSignal = signal;
|
||||
};
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('should set cancellable flag correctly', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
expect(task.cancellable).toBe(true);
|
||||
const promise = task.execute(taskFn, false);
|
||||
expect(task.cancellable).toBe(false);
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should not allow transition from prevent cancel to allow cancel when task is running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = vi.fn(async () => {
|
||||
await taskPromise;
|
||||
});
|
||||
|
||||
const promise1 = task.execute(taskFn, false);
|
||||
expect(task.cancellable).toBe(false);
|
||||
|
||||
const promise2 = task.execute(taskFn, true);
|
||||
expect(task.cancellable).toBe(false);
|
||||
|
||||
resolveTask!();
|
||||
await Promise.all([promise1, promise2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should cancel a running task', async () => {
|
||||
const task = new CancellableTask();
|
||||
let taskStarted = false;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
taskStarted = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
|
||||
// Wait a bit to ensure task has started
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(taskStarted).toBe(true);
|
||||
|
||||
task.cancel();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should call canceledCallback when task is canceled', async () => {
|
||||
const canceledCallback = vi.fn();
|
||||
const task = new CancellableTask(undefined, canceledCallback);
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
await promise;
|
||||
|
||||
expect(canceledCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cancel if task is not cancellable', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
const promise = task.execute(taskFn, false);
|
||||
task.cancel();
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
|
||||
it('should not cancel if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
expect(task.executed).toBe(true);
|
||||
|
||||
task.cancel();
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset task to initial state', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
expect(task.executed).toBe(true);
|
||||
|
||||
await task.reset();
|
||||
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.cancelToken).toBe(null);
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should cancel running task before resetting', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const resetPromise = task.reset();
|
||||
|
||||
await promise;
|
||||
await resetPromise;
|
||||
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow re-execution after reset', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
await task.reset();
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(taskFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilCompletion', () => {
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.waitUntilCompletion();
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
});
|
||||
|
||||
it('should return WAITED if task completes while waiting', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilCompletion();
|
||||
|
||||
resolveTask!();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('WAITED');
|
||||
});
|
||||
|
||||
it('should return CANCELED if task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilCompletion();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilExecution', () => {
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.waitUntilExecution();
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
});
|
||||
|
||||
it('should return WAITED if task completes successfully', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilExecution();
|
||||
|
||||
resolveTask!();
|
||||
|
||||
const [, waitResult] = await Promise.all([executePromise, waitPromise]);
|
||||
|
||||
expect(waitResult).toBe('WAITED');
|
||||
});
|
||||
|
||||
it('should retry if task is canceled and wait for next execution', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const task = new CancellableTask();
|
||||
let attempt = 0;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
attempt++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted && attempt === 1) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
// Start first execution
|
||||
const executePromise1 = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilExecution();
|
||||
|
||||
// Cancel the first execution
|
||||
vi.advanceTimersByTime(10);
|
||||
task.cancel();
|
||||
vi.advanceTimersByTime(100);
|
||||
await executePromise1;
|
||||
|
||||
// Start second execution
|
||||
const executePromise2 = task.execute(taskFn, true);
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]);
|
||||
|
||||
expect(executeResult).toBe('LOADED');
|
||||
expect(waitResult).toBe('WAITED');
|
||||
expect(attempt).toBe(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return ERRORED when task throws non-abort error', async () => {
|
||||
const task = new CancellableTask();
|
||||
const error = new Error('Task failed');
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw error;
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('ERRORED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should call errorCallback when task throws non-abort error', async () => {
|
||||
const errorCallback = vi.fn();
|
||||
const task = new CancellableTask(undefined, undefined, errorCallback);
|
||||
const error = new Error('Task failed');
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw error;
|
||||
};
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
expect(errorCallback).toHaveBeenCalledTimes(1);
|
||||
expect(errorCallback).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('should return CANCELED when task throws AbortError', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow re-execution after error', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn1 = async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('Failed');
|
||||
};
|
||||
const taskFn2 = vi.fn(async () => {});
|
||||
|
||||
const result1 = await task.execute(taskFn1, true);
|
||||
expect(result1).toBe('ERRORED');
|
||||
|
||||
const result2 = await task.execute(taskFn2, true);
|
||||
expect(result2).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading property', () => {
|
||||
it('should return true when task is running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
expect(task.loading).toBe(false);
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
expect(task.loading).toBe(true);
|
||||
|
||||
resolveTask!();
|
||||
await promise;
|
||||
|
||||
expect(task.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete promise', () => {
|
||||
it('should resolve when task completes successfully', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
|
||||
const completePromise = task.complete;
|
||||
await task.execute(taskFn, true);
|
||||
await expect(completePromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject when task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const completePromise = task.complete;
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
await promise;
|
||||
|
||||
await expect(completePromise).rejects.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject when task errors', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('Failed');
|
||||
};
|
||||
|
||||
const completePromise = task.complete;
|
||||
await task.execute(taskFn, true);
|
||||
|
||||
await expect(completePromise).rejects.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort signal handling', () => {
|
||||
it('should automatically call abort() on signal when task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
capturedSignal = signal;
|
||||
// Simulate a long-running task
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
|
||||
// Wait a bit to ensure task has started
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(capturedSignal).not.toBeNull();
|
||||
expect(capturedSignal!.aborted).toBe(false);
|
||||
|
||||
// Cancel the task
|
||||
task.cancel();
|
||||
|
||||
// Verify the signal was aborted
|
||||
expect(capturedSignal!.aborted).toBe(true);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
});
|
||||
|
||||
it('should detect if signal was aborted after task completes', async () => {
|
||||
const task = new CancellableTask();
|
||||
let controller: AbortController | null = null;
|
||||
const taskFn = async (_: AbortSignal) => {
|
||||
// Capture the controller to abort it externally
|
||||
controller = task.cancelToken;
|
||||
// Simulate some work
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
// Now abort before the function returns
|
||||
controller?.abort();
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle abort signal in async operations', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
// Simulate listening to abort signal during async operation
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,15 +15,7 @@ export class CancellableTask {
|
||||
private canceledCallback?: () => void,
|
||||
private errorCallback?: (error: unknown) => void,
|
||||
) {
|
||||
this.complete = new Promise<void>((resolve, reject) => {
|
||||
this.loadedSignal = resolve;
|
||||
this.canceledSignal = reject;
|
||||
}).catch(
|
||||
() =>
|
||||
// if no-one waits on complete its rejected a uncaught rejection message is logged.
|
||||
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
|
||||
void 0,
|
||||
);
|
||||
this.init();
|
||||
}
|
||||
|
||||
get loading() {
|
||||
@@ -34,11 +26,30 @@ export class CancellableTask {
|
||||
if (this.executed) {
|
||||
return 'DONE';
|
||||
}
|
||||
// if there is a cancel token, task is currently executing, so wait on the promise. If it
|
||||
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
|
||||
// in either case, we wait on the promise.
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
// The `complete` promise resolves when executed, rejects when canceled/errored.
|
||||
try {
|
||||
const complete = this.complete;
|
||||
await complete;
|
||||
return 'WAITED';
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 'CANCELED';
|
||||
}
|
||||
|
||||
async waitUntilExecution() {
|
||||
// Keep retrying until the task completes successfully (not canceled)
|
||||
for (;;) {
|
||||
try {
|
||||
if (this.executed) {
|
||||
return 'DONE';
|
||||
}
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
||||
@@ -80,21 +91,14 @@ export class CancellableTask {
|
||||
}
|
||||
|
||||
private init() {
|
||||
this.cancelToken = null;
|
||||
this.executed = false;
|
||||
// create a promise, and store its resolve/reject callbacks. The loadedSignal callback
|
||||
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
|
||||
// callback will be called if the bucket is canceled before it was loaded, rejecting the
|
||||
// promise.
|
||||
this.complete = new Promise<void>((resolve, reject) => {
|
||||
this.cancelToken = null;
|
||||
this.executed = false;
|
||||
this.loadedSignal = resolve;
|
||||
this.canceledSignal = reject;
|
||||
}).catch(
|
||||
() =>
|
||||
// if no-one waits on complete its rejected a uncaught rejection message is logged.
|
||||
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
|
||||
void 0,
|
||||
);
|
||||
});
|
||||
// Suppress unhandled rejection warning
|
||||
this.complete.catch(() => {});
|
||||
}
|
||||
|
||||
// will reset this job back to the initial state (isLoaded=false, no errors, etc)
|
||||
|
||||
Reference in New Issue
Block a user