Compare commits

..

14 Commits

Author SHA1 Message Date
shenlong-tanwen 1ee679f832 fix: re-enable stale asset pruning 2026-06-23 14:54:46 +05:30
renovate[bot] 7dd02ffbad chore(deps): update github-actions (#29272)
Co-authored-by: bo0tzz <git@bo0tzz.me>
2026-06-23 08:49:21 +00:00
shenlong e51c4cb355 feat: column button (#29265)
* refactor: icon buttons implicit loading

* chore: cleanup

* feat: ui color override

* feat: column button

* feat: ui menu item (#29266)

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-22 20:56:00 -05:00
shenlong d4102c0489 refactor: ui icon buttons implicit loading (#29263)
* refactor: icon buttons implicit loading

* chore: cleanup

* feat: ui color override (#29264)

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-22 16:24:52 -04:00
shenlong 30a73c1105 feat: mobile-ui snackbar (#29260)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-22 16:22:26 -04:00
Jason Rasmussen ec7c0f9ec8 fix: sync backfill (#29267) 2026-06-22 16:19:01 -04:00
Brandon Wees a5198e23a8 refactor: use SemVer classes for version compatability message (#29056)
* refactor: use SemVer classes for version compatability message

* chore: readd major version compatabilty messages

* fix: remove 1.106.0 check

(we dont support v1 servers anymore)
2026-06-22 11:28:56 -04:00
Mees Frensel 51f2905fcc fix(web): remove map's fullscreen button (#29192) 2026-06-22 16:58:07 +02:00
Vogeluff 3b7d75c18a feat(web): Add text-white-shadow to elements and increase the shadows effect (#29165)
* fix(web): increase text shadow strength for white text on thumbnails

* fix(web): fix class order for text-white-shadow

* fixup: format fix
2026-06-22 09:43:15 -05:00
Daniel Dietzler c484bd99b6 fix: ignore external libraries for integrity report checksum check (#29248) 2026-06-22 13:56:24 +00:00
Anthony Clerici c0bf5a4c56 fix(server): use VBR for QSV so the max bitrate is respected (#29240)
* fix(server): use VBR for QSV so the max bitrate is respected

* update test
2026-06-22 09:56:20 -04:00
MuySup d9d50d2848 fix: turkish readme translation (#29234)
* Translation completed

3-2-1 rule translated

* Fix formatting of warning message in Turkish README
2026-06-22 09:55:58 -04:00
Daniel Dietzler c7453a67fd fix: detail panel people reactivity and iterator consumption (#29250) 2026-06-22 15:47:09 +02:00
Daniel Dietzler e918e3a313 feat: keyboard seeking for new video player (#29208) 2026-06-22 09:42:59 -04:00
65 changed files with 1165 additions and 374 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'zulu'
java-version: '17'
+2 -1
View File
@@ -25,11 +25,12 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@3530478ec30f84adedbfeb28f0d9527a290f50a9 # v0.0.57
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
fail-on: ERR
review: false
check-mobile-patches:
runs-on: ubuntu-latest
+2 -2
View File
@@ -406,7 +406,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -483,7 +483,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+1 -1
View File
@@ -1548,7 +1548,7 @@
"map_location_picker_page_use_location": "Use this location",
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
"map_location_service_disabled_title": "Location Service disabled",
"map_marker_for_images": "Map marker for images taken in {city}, {country}",
"map_marker_for_image": "Map marker for image taken in {city}, {country}",
"map_marker_with_image": "Map marker with image",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_no_location_permission_title": "Location Permission denied",
@@ -280,8 +280,7 @@ class SyncStreamService {
return;
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
case SyncEntityType.syncCompleteV1:
return;
// return _syncStreamRepository.pruneAssets();
return _syncStreamRepository.pruneAssets();
// Request to reset the client state. Clear everything related to remote entities
case SyncEntityType.syncResetV1:
return _syncStreamRepository.reset();
+1
View File
@@ -263,6 +263,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
child: MaterialApp.router(
title: 'Immich',
debugShowCheckedModeBanner: true,
scaffoldMessengerKey: scaffoldMessengerKey,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
+12 -3
View File
@@ -1,7 +1,16 @@
String? getVersionCompatibilityMessage(int _, int appMinor, int _, int serverMinor) {
import 'package:immich_mobile/utils/semver.dart';
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
// Add latest compat info up top
if (serverMinor < 106 && appMinor >= 106) {
return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login';
// ensure mobile app major version is not behind server major version
if (appVersion.major < serverVersion.major) {
return 'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
}
// ensure mobile app major version is not ahead of server major version by more than 1 major version
if (appVersion.major > serverVersion.major + 1) {
return 'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
}
return null;
+4 -12
View File
@@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
@@ -88,18 +89,9 @@ class LoginForm extends HookConsumerWidget {
checkVersionMismatch() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
warningMessage.value = getVersionCompatibilityMessage(
appMajorVersion,
appMinorVersion,
serverMajorVersion,
serverMinorVersion,
);
final appSemVer = SemVer.fromString(packageInfo.version);
final serverSemVer = serverInfo.serverVersion;
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
} catch (error) {
warningMessage.value = 'Error checking version compatibility';
}
+3
View File
@@ -1,12 +1,15 @@
export 'src/components/close_button.dart';
export 'src/components/column_button.dart';
export 'src/components/form.dart';
export 'src/components/formatted_text.dart';
export 'src/components/icon_button.dart';
export 'src/components/menu_item.dart';
export 'src/components/password_input.dart';
export 'src/components/text_button.dart';
export 'src/components/text_input.dart';
export 'src/components/url_input.dart';
export 'src/constants.dart';
export 'src/snackbar.dart';
export 'src/theme.dart';
export 'src/translation.dart';
export 'src/types.dart';
@@ -0,0 +1,13 @@
import 'package:flutter/widgets.dart';
class ImmichColorOverride extends InheritedWidget {
const ImmichColorOverride({super.key, required this.color, required super.child});
final Color color;
static Color? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ImmichColorOverride>()?.color;
@override
bool updateShouldNotify(ImmichColorOverride oldWidget) => color != oldWidget.color;
}
@@ -16,10 +16,9 @@ class ImmichCloseButton extends StatelessWidget {
@override
Widget build(BuildContext context) => ImmichIconButton(
key: key,
icon: Icons.close,
color: color,
variant: variant,
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
icon: Icons.close,
color: color,
variant: variant,
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
}
@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichColumnButton extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final bool disabled;
final bool? loading;
const ImmichColumnButton({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.disabled = false,
this.loading,
});
@override
State<ImmichColumnButton> createState() => _ImmichColumnButtonState();
}
class _ImmichColumnButtonState extends State<ImmichColumnButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
return TextButton(
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
tapTargetSize: .shrinkWrap,
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.xl))),
),
child: ConstrainedBox(
constraints: const .new(maxWidth: 90),
child: Column(
mainAxisSize: .min,
children: [
_isLoading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: Icon(widget.icon, size: ImmichIconSize.md),
const SizedBox(height: ImmichSpacing.sm),
Text(
widget.label,
maxLines: 2,
textAlign: .center,
overflow: .ellipsis,
style: const .new(fontSize: ImmichTextSize.label, fontWeight: .w500),
),
],
),
),
);
}
}
@@ -88,7 +88,7 @@ class _ImmichFormState extends State<ImmichForm> {
builder: (context, _) => ImmichTextButton(
labelText: submitText,
icon: widget.submitIcon,
variant: ImmichVariant.filled,
variant: .filled,
loading: _controller.isLoading,
onPressed: _controller.submit,
disabled: _controller.onSubmit == null,
@@ -94,12 +94,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
final tag = match.group(1)!.toLowerCase();
final content = match.group(2)!;
final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag);
final style = formattedSpan.style ?? _defaultTextStyle(tag);
final span = widget.spanBuilder?.call(tag);
final style = span?.style ?? _defaultTextStyle(tag);
GestureRecognizer? recognizer;
if (formattedSpan.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap;
if (span?.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = span!.onTap;
_recognizers.add(recognizer);
}
spans.add(TextSpan(text: content, style: style, recognizer: recognizer));
@@ -114,19 +114,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
return spans;
}
FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) {
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ => const FormattedSpan(),
};
TextStyle? _defaultTextStyle(String tag) => switch (tag) {
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => null,
};
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => null,
};
@override
Widget build(BuildContext context) {
@@ -1,54 +1,80 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/types.dart';
import 'dart:async';
class ImmichIconButton extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichIconButton extends StatefulWidget {
final IconData icon;
final VoidCallback onPressed;
final FutureOr<void> Function() onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
final bool? loading;
const ImmichIconButton({
super.key,
required this.icon,
required this.onPressed,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.filled,
this.color = .primary,
this.variant = .filled,
this.disabled = false,
this.loading,
});
@override
State<ImmichIconButton> createState() => _ImmichIconButtonState();
}
class _ImmichIconButtonState extends State<ImmichIconButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final background = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
ImmichVariant.ghost => Colors.transparent,
final background = switch (widget.variant) {
.filled => switch (widget.color) {
.primary => colorScheme.primary,
.secondary => colorScheme.secondary,
},
.ghost => Colors.transparent,
};
final foreground = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => colorScheme.onPrimary,
ImmichColor.secondary => colorScheme.onSecondary,
},
ImmichVariant.ghost => switch (color) {
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
};
final effectiveOnPressed = disabled ? null : onPressed;
final foreground =
context.colorOverride ??
switch (widget.variant) {
.filled => switch (widget.color) {
.primary => colorScheme.onPrimary,
.secondary => colorScheme.onSecondary,
},
.ghost => switch (widget.color) {
.primary => colorScheme.primary,
.secondary => colorScheme.secondary,
},
};
return IconButton(
icon: Icon(icon),
onPressed: effectiveOnPressed,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
),
icon: _isLoading
? const SizedBox.square(
dimension: ImmichIconSize.sm,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
)
: Icon(widget.icon),
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
);
}
}
@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichMenu extends StatefulWidget {
final List<Widget> children;
final MenuAnchorChildBuilder builder;
final MenuStyle? style;
final bool consumeOutsideTap;
final Widget? child;
const ImmichMenu({
super.key,
required this.children,
required this.builder,
this.style,
this.consumeOutsideTap = false,
this.child,
});
@override
State<ImmichMenu> createState() => _ImmichMenuState();
}
class _ImmichMenuState extends State<ImmichMenu> {
final _controller = MenuController();
@override
Widget build(BuildContext context) {
return _ImmichMenuScope(
controller: _controller,
child: MenuAnchor(
controller: _controller,
style: widget.style,
consumeOutsideTap: widget.consumeOutsideTap,
menuChildren: widget.children,
builder: widget.builder,
child: widget.child,
),
);
}
}
class _ImmichMenuScope extends InheritedWidget {
final MenuController controller;
const _ImmichMenuScope({required this.controller, required super.child});
static MenuController? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_ImmichMenuScope>()?.controller;
@override
bool updateShouldNotify(_ImmichMenuScope oldWidget) => controller != oldWidget.controller;
}
class ImmichMenuItem extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final bool disabled;
const ImmichMenuItem({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.disabled = false,
});
@override
State<ImmichMenuItem> createState() => _ImmichMenuItemState();
}
class _ImmichMenuItemState extends State<ImmichMenuItem> {
Future<void> _onPressed(MenuController? controller) async {
try {
await widget.onPressed();
} finally {
controller?.close();
}
}
@override
Widget build(BuildContext context) {
final controller = _ImmichMenuScope.maybeOf(context);
return MenuItemButton(
onPressed: widget.disabled ? null : () => _onPressed(controller),
closeOnActivate: controller == null,
style: MenuItemButton.styleFrom(
foregroundColor: context.colorOverride,
alignment: .centerLeft,
padding: const .symmetric(horizontal: ImmichSpacing.lg, vertical: ImmichSpacing.md),
),
leadingIcon: Icon(widget.icon, size: ImmichIconSize.sm),
child: Text(widget.label, style: const .new(fontSize: ImmichTextSize.body)),
);
}
}
@@ -52,7 +52,6 @@ class _ImmichPasswordInputState extends State<ImmichPasswordInput> {
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
),
autofillHints: [AutofillHints.password],
keyboardType: TextInputType.text,
);
}
}
@@ -1,85 +1,72 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/types.dart';
import 'package:immich_ui/immich_ui.dart';
class ImmichTextButton extends StatelessWidget {
class ImmichTextButton extends StatefulWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool expanded;
final bool loading;
final bool disabled;
final bool? loading;
const ImmichTextButton({
super.key,
required this.labelText,
this.icon,
required this.onPressed,
this.variant = ImmichVariant.filled,
this.color = ImmichColor.primary,
this.variant = .filled,
this.expanded = true,
this.loading = false,
this.disabled = false,
this.loading,
});
Widget _buildButton(ImmichVariant variant) {
final Widget? effectiveIcon = loading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: icon != null
? Icon(icon, fontWeight: FontWeight.w600)
: null;
final hasIcon = effectiveIcon != null;
@override
State<ImmichTextButton> createState() => _ImmichTextButtonState();
}
final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold));
final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md));
class _ImmichTextButtonState extends State<ImmichTextButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
final effectiveOnPressed = disabled || loading ? null : onPressed;
switch (variant) {
case ImmichVariant.filled:
if (hasIcon) {
return ElevatedButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return ElevatedButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
case ImmichVariant.ghost:
if (hasIcon) {
return TextButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return TextButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final button = _buildButton(variant);
if (expanded) {
final Widget? icon = _isLoading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: widget.icon != null
? Icon(widget.icon, fontWeight: .w600)
: null;
final label = Text(
widget.labelText,
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
);
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
final button = switch (widget.variant) {
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
};
if (widget.expanded) {
return SizedBox(width: double.infinity, child: button);
}
return button;
+5
View File
@@ -1,6 +1,11 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/color_override.dart';
import 'package:immich_ui/src/translation.dart';
extension TranslationHelper on BuildContext {
ImmichTranslations get translations => ImmichTranslationProvider.of(this);
}
extension ColorHelper on BuildContext {
Color? get colorOverride => ImmichColorOverride.maybeOf(this);
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/column_button.dart';
import 'package:immich_ui/src/previews.dart';
void _previewNoop() {}
@ImmichPreview(group: 'ColumnButton', name: 'Default')
Widget previewColumnButtonDefault() => const Wrap(
spacing: 12,
runSpacing: 12,
children: [
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.favorite_border_rounded, label: 'Favorite'),
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.archive_outlined, label: 'Archive'),
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete'),
],
);
@ImmichPreview(group: 'ColumnButton', name: 'Loading')
Widget previewColumnButtonLoading() => ImmichColumnButton(
onPressed: () => Future<void>.delayed(const .new(seconds: 2)),
icon: Icons.download,
label: 'Download',
);
@ImmichPreview(group: 'ColumnButton', name: 'Disabled')
Widget previewColumnButtonDisabled() =>
const ImmichColumnButton(onPressed: _previewNoop, icon: Icons.ios_share_rounded, label: 'Share', disabled: true);
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/menu_item.dart';
import 'package:immich_ui/src/previews.dart';
void _previewNoop() {}
@ImmichPreview(group: 'MenuItem', name: 'Default')
Widget previewMenuItemDefault() => const Column(
mainAxisSize: MainAxisSize.min,
children: [
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.info_outline, label: 'Info'),
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.help_outline_rounded, label: 'Troubleshoot'),
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.cast_rounded, label: 'Cast'),
],
);
@ImmichPreview(group: 'MenuItem', name: 'Disabled')
Widget previewMenuItemDisabled() =>
const ImmichMenuItem(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete', disabled: true);
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/previews.dart';
import 'package:immich_ui/src/snackbar.dart';
@ImmichPreview(group: 'Snackbar', name: 'Types')
Widget previewSnackbarTypes() => const _SnackbarDemo();
class _SnackbarDemo extends StatelessWidget {
const _SnackbarDemo();
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(
key: scaffoldMessengerKey,
child: Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: Wrap(
spacing: ImmichSpacing.md,
runSpacing: ImmichSpacing.md,
children: [
ElevatedButton(onPressed: () => snackbar.info('Info message'), child: const Text('Info')),
ElevatedButton(onPressed: () => snackbar.success('Saved'), child: const Text('Success')),
ElevatedButton(onPressed: () => snackbar.error('Something failed'), child: const Text('Error')),
],
),
),
),
);
}
}
@@ -15,16 +15,6 @@ Widget previewTextButtonVariants() => const Wrap(
],
);
@ImmichPreview(group: 'TextButton', name: 'Colors')
Widget previewTextButtonColors() => const Wrap(
spacing: 12,
runSpacing: 12,
children: [
ImmichTextButton(onPressed: _previewNoop, labelText: 'Primary', expanded: false),
ImmichTextButton(onPressed: _previewNoop, labelText: 'Secondary', color: ImmichColor.secondary, expanded: false),
],
);
@ImmichPreview(group: 'TextButton', name: 'With Icons')
Widget previewTextButtonWithIcons() => const Wrap(
spacing: 12,
@@ -42,7 +32,11 @@ Widget previewTextButtonWithIcons() => const Wrap(
);
@ImmichPreview(group: 'TextButton', name: 'Loading')
Widget previewTextButtonLoading() => const _PreviewLoadingDemo();
Widget previewTextButtonLoading() => ImmichTextButton(
onPressed: () => Future<void>.delayed(const Duration(seconds: 2)),
labelText: 'Click me',
expanded: false,
);
@ImmichPreview(group: 'TextButton', name: 'Disabled')
Widget previewTextButtonDisabled() => const Wrap(
@@ -59,30 +53,3 @@ Widget previewTextButtonDisabled() => const Wrap(
),
],
);
class _PreviewLoadingDemo extends StatefulWidget {
const _PreviewLoadingDemo();
@override
State<_PreviewLoadingDemo> createState() => _PreviewLoadingDemoState();
}
class _PreviewLoadingDemoState extends State<_PreviewLoadingDemo> {
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return ImmichTextButton(
onPressed: () async {
setState(() => _isLoading = true);
await Future<void>.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() => _isLoading = false);
}
},
labelText: _isLoading ? 'Loading...' : 'Click Me',
loading: _isLoading,
expanded: false,
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class SnackbarManager {
const SnackbarManager();
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
final messenger = scaffoldMessengerKey.currentState;
final context = scaffoldMessengerKey.currentContext;
if (messenger == null || context == null) {
return null;
}
messenger.hideCurrentSnackBar();
return messenger.showSnackBar(_build(context, message, type));
}
SnackBar _build(BuildContext context, String message, SnackbarType type) {
final theme = Theme.of(context);
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
final (IconData icon, Color background, Color foreground) = switch (type) {
.info => (Icons.info_rounded, colors.info, colors.onInfo),
.success => (Icons.check_circle_rounded, colors.success, colors.onSuccess),
.error => (Icons.warning_rounded, colors.error, colors.onError),
};
return SnackBar(
behavior: .floating,
backgroundColor: background,
duration: const .new(seconds: 4),
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
content: Row(
children: [
Icon(icon, color: foreground, size: ImmichIconSize.sm),
const SizedBox(width: ImmichSpacing.md),
Expanded(
child: Text(
message,
maxLines: 2,
overflow: .ellipsis,
style: .new(color: foreground, fontWeight: .w600, fontSize: ImmichTextSize.body),
),
),
],
),
);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
}
const snackbar = SnackbarManager();
+74 -2
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:material_color_utilities/blend/blend.dart';
import 'package:material_color_utilities/hct/hct.dart';
import 'package:material_color_utilities/palettes/tonal_palette.dart';
class ImmichThemeProvider extends StatelessWidget {
final ColorScheme colorScheme;
@@ -11,6 +14,7 @@ class ImmichThemeProvider extends StatelessWidget {
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
extensions: [ImmichColors.harmonized(colorScheme)],
colorScheme: colorScheme,
brightness: colorScheme.brightness,
inputDecorationTheme: InputDecorationTheme(
@@ -19,8 +23,8 @@ class ImmichThemeProvider extends StatelessWidget {
final color = states.contains(WidgetState.error)
? colorScheme.error
: states.contains(WidgetState.focused)
? colorScheme.primary
: colorScheme.outline;
? colorScheme.primary
: colorScheme.outline;
return OutlineInputBorder(
borderSide: BorderSide(color: color),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
@@ -38,3 +42,71 @@ class ImmichThemeProvider extends StatelessWidget {
);
}
}
class ImmichColors extends ThemeExtension<ImmichColors> {
final Color info;
final Color onInfo;
final Color success;
final Color onSuccess;
final Color error;
final Color onError;
const ImmichColors({
required this.info,
required this.onInfo,
required this.success,
required this.onSuccess,
required this.error,
required this.onError,
});
factory ImmichColors.harmonized(ColorScheme scheme) {
final (info, onInfo) = scheme.harmonized(const Color(0xFF1984E9));
final (success, onSuccess) = scheme.harmonized(const Color(0xFF10C14D));
final (error, onError) = scheme.harmonized(const Color(0xFFFA2921));
return ImmichColors(
info: info,
onInfo: onInfo,
success: success,
onSuccess: onSuccess,
error: error,
onError: onError,
);
}
@override
ImmichColors copyWith({Color? info, Color? onInfo, Color? success, Color? onSuccess, Color? error, Color? onError}) {
return ImmichColors(
info: info ?? this.info,
onInfo: onInfo ?? this.onInfo,
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
error: error ?? this.error,
onError: onError ?? this.onError,
);
}
@override
ImmichColors lerp(ImmichColors? other, double t) {
if (other == null) {
return this;
}
return ImmichColors(
info: Color.lerp(info, other.info, t)!,
onInfo: Color.lerp(onInfo, other.onInfo, t)!,
success: Color.lerp(success, other.success, t)!,
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
error: Color.lerp(error, other.error, t)!,
onError: Color.lerp(onError, other.onError, t)!,
);
}
}
extension on ColorScheme {
(Color container, Color onContainer) harmonized(Color seed) {
final hct = Hct.fromInt(Blend.harmonize(seed.toARGB32(), primary.toARGB32()));
final tones = TonalPalette.of(hct.hue, hct.chroma);
final isDark = brightness == Brightness.dark;
return (Color(tones.get(isDark ? 30 : 90)), Color(tones.get(isDark ? 90 : 10)));
}
}
+4 -8
View File
@@ -1,9 +1,5 @@
enum ImmichVariant {
filled,
ghost,
}
enum ImmichVariant { filled, ghost }
enum ImmichColor {
primary,
secondary,
}
enum ImmichColor { primary, secondary }
enum SnackbarType { info, success, error }
+1 -1
View File
@@ -92,7 +92,7 @@ packages:
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
dependency: "direct main"
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+1
View File
@@ -7,6 +7,7 @@ environment:
dependencies:
flutter:
sdk: flutter
material_color_utilities: any
dev_dependencies:
flutter_test:
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/color_override.dart';
import 'package:immich_ui/src/components/icon_button.dart';
import 'test_utils.dart';
void main() {
group('ImmichColorOverride', () {
testWidgets('exposes the override color to descendants', (tester) async {
Color? captured;
await tester.pumpTestWidget(
ImmichColorOverride(
color: Colors.green,
child: Builder(
builder: (context) {
captured = ImmichColorOverride.maybeOf(context);
return const SizedBox.shrink();
},
),
),
);
expect(captured, Colors.green);
});
testWidgets('maybeOf returns null when there is no override', (tester) async {
Color? captured = Colors.black;
await tester.pumpTestWidget(
Builder(
builder: (context) {
captured = ImmichColorOverride.maybeOf(context);
return const SizedBox.shrink();
},
),
);
expect(captured, isNull);
});
testWidgets('a descendant component adopts the override as its foreground', (tester) async {
await tester.pumpTestWidget(
ImmichColorOverride(
color: Colors.green,
child: ImmichIconButton(icon: Icons.add, onPressed: () {}),
),
);
final button = tester.widget<IconButton>(find.byType(IconButton));
expect(button.style?.foregroundColor?.resolve(<WidgetState>{}), Colors.green);
});
});
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/snackbar.dart';
import 'test_utils.dart';
void main() {
group('SnackbarManager', () {
testWidgets('shows the message', (tester) async {
await tester.pumpTestWidget(const SizedBox());
snackbar.success('hello');
await tester.pump();
expect(find.text('hello'), findsOneWidget);
expect(find.byType(SnackBar), findsOneWidget);
});
testWidgets('replaces the current snackbar', (tester) async {
await tester.pumpTestWidget(const SizedBox());
snackbar.info('first');
await tester.pump();
snackbar.error('second');
await tester.pump();
expect(find.text('first'), findsNothing);
expect(find.text('second'), findsOneWidget);
});
testWidgets('no-ops when the messenger is unmounted', (tester) async {
expect(snackbar.show('x', .info), isNull);
});
});
}
+7 -2
View File
@@ -1,9 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/snackbar.dart';
extension WidgetTesterExtension on WidgetTester {
/// Pumps a widget wrapped in MaterialApp and Scaffold for testing.
Future<void> pumpTestWidget(Widget widget) {
return pumpWidget(MaterialApp(home: Scaffold(body: widget)));
return pumpWidget(
MaterialApp(
scaffoldMessengerKey: scaffoldMessengerKey,
home: Scaffold(body: widget),
),
);
}
}
@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late SyncStreamRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = SyncStreamRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('pruneAssets', () {
test('deletes foreign orphans and keeps owned, partner, and in-album assets', () async {
final me = await ctx.newUser();
final partner = await ctx.newUser();
final stranger = await ctx.newUser();
await ctx.newAuthUser(id: me.id);
await ctx.newPartner(sharedById: partner.id, sharedWithId: me.id);
final own = await ctx.newRemoteAsset(ownerId: me.id);
final fromPartner = await ctx.newRemoteAsset(ownerId: partner.id);
final shared = await ctx.newRemoteAsset(ownerId: stranger.id);
await ctx.newRemoteAsset(ownerId: stranger.id);
final album = await ctx.newRemoteAlbum(ownerId: me.id);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: shared.id);
await sut.pruneAssets();
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
expect(remaining.map((a) => a.id), unorderedEquals([own.id, fromPartner.id, shared.id]));
});
test('does nothing when there is no authenticated user', () async {
final stranger = await ctx.newUser();
final orphan = await ctx.newRemoteAsset(ownerId: stranger.id);
await sut.pruneAssets();
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
expect(remaining.map((a) => a.id), [orphan.id]);
});
test('prunes every stale foreign asset in a large data set', () async {
final stranger = await ctx.newUser();
await ctx.newAuthUser();
for (var i = 0; i < 600; i++) {
await ctx.newRemoteAsset(ownerId: stranger.id);
}
await sut.pruneAssets();
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
expect(remaining, isEmpty);
});
});
}
@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
@@ -72,6 +73,20 @@ class MediumRepositoryContext {
);
}
Future<AuthUserEntityData> newAuthUser({String? id, String? email, AvatarColor? avatarColor}) async {
id ??= TestUtils.uuid();
return await db
.into(db.authUserEntity)
.insertReturning(
AuthUserEntityCompanion(
id: .new(id),
email: .new(email ?? '$id@test.com'),
name: .new('user_$id'),
avatarColor: .new(avatarColor ?? TestUtils.randElement(AvatarColor.values)),
),
);
}
Future<void> newPartner({required String sharedById, required String sharedWithId, bool? inTimeline}) {
return db
.into(db.partnerEntity)
@@ -1,29 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
void main() {
test('getVersionCompatibilityMessage', () {
String? result;
group('app major version behind server', () {
const message =
'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
result = getVersionCompatibilityMessage(1, 106, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
test('returns message when app major is behind server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 1, minor: 200, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 107, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
test('returns null when app major matches server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
});
result = getVersionCompatibilityMessage(1, 106, 1, 106);
expect(result, null);
group('app major version too far ahead of server', () {
const message =
'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
result = getVersionCompatibilityMessage(1, 107, 1, 106);
expect(result, null);
test('returns message when app major is more than one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 3, minor: 0, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 107, 1, 108);
expect(result, null);
test('returns null when app major is exactly one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
});
}
+2 -2
View File
@@ -38,8 +38,8 @@
</p>
> [!WARNING]
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
>
> ⚠️ Değerli fotoğraflarınız ve videolarınız için daima [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) yedekleme planını uygulayın!
>
> [!NOTE]
> Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
@@ -129,6 +129,7 @@ from
and "integrity_report"."type" = $1
where
"asset"."deletedAt" is null
and "asset"."isExternal" = false
and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
order by
+7 -7
View File
@@ -88,7 +88,7 @@ from
where
"album_asset"."updateId" < $3
and "album_asset"."updateId" <= $4
and "album_asset"."updateId" >= $5
and "album_asset"."updateId" > $5
and "album_asset"."albumId" = $6
order by
"album_asset"."updateId" asc
@@ -202,7 +202,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" >= $3
and "album_asset"."updateId" > $3
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -297,7 +297,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" >= $3
and "album_asset"."updateId" > $3
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -349,7 +349,7 @@ from
where
"album_user"."updateId" < $1
and "album_user"."updateId" <= $2
and "album_user"."updateId" >= $3
and "album_user"."updateId" > $3
and "albumId" = $4
order by
"album_user"."updateId" asc
@@ -810,7 +810,7 @@ from
where
"asset"."updateId" < $2
and "asset"."updateId" <= $3
and "asset"."updateId" >= $4
and "asset"."updateId" > $4
and "ownerId" = $5
order by
"asset"."updateId" asc
@@ -908,7 +908,7 @@ from
where
"asset_exif"."updateId" < $1
and "asset_exif"."updateId" <= $2
and "asset_exif"."updateId" >= $3
and "asset_exif"."updateId" > $3
and "asset"."ownerId" = $4
order by
"asset_exif"."updateId" asc
@@ -997,7 +997,7 @@ from
where
"stack"."updateId" < $1
and "stack"."updateId" <= $2
and "stack"."updateId" >= $3
and "stack"."updateId" > $3
and "ownerId" = $4
order by
"stack"."updateId" asc
@@ -177,6 +177,7 @@ export class IntegrityRepository {
'asset.id as assetId',
'integrity_report.id as reportId',
])
.where('asset.isExternal', '=', sql.lit(false))
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
+1 -1
View File
@@ -106,7 +106,7 @@ export class BaseSync {
.selectFrom(table(t).as(t))
.where(updateIdRef, '<', nowId)
.where(updateIdRef, '<=', beforeUpdateId)
.$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>=', afterUpdateId!))
.$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>', afterUpdateId!))
.orderBy(updateIdRef, 'asc');
}
@@ -2939,6 +2939,8 @@ describe(MediaService.name, () => {
'7',
'-global_quality:v',
'23',
'-b:v',
'6897k',
'-maxrate',
'10000k',
'-bufsize',
+6
View File
@@ -788,6 +788,12 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'}`, `${this.config.crf}`];
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
// Workaround for https://github.com/immich-app/immich/issues/29220, to be revisited
// QSV seems to ignore -maxrate without -b:v
// -b:v alongside global_quality uses QVBR
if (!this.useCQP()) {
options.push('-b:v', `${bitrates.target}${bitrates.unit}`);
}
options.push('-maxrate', `${bitrates.max}${bitrates.unit}`, '-bufsize', `${bitrates.max * 2}${bitrates.unit}`);
}
return options;
@@ -686,6 +686,22 @@ describe(IntegrityService.name, () => {
nextCursor: undefined,
});
});
it('should skip external library files', async () => {
const { sut, ctx } = setup();
const job = ctx.getMock(JobRepository);
job.queue.mockResolvedValue(void 0);
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, isExternal: true });
await sut.handleChecksumFiles({ refreshOnly: false });
await expect(
ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail),
).resolves.toEqual({ items: [], nextCursor: undefined });
});
});
describe('handleChecksumRefresh', () => {
@@ -155,6 +155,57 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
it('should not resend an already-acked item when backfill resumes', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
// backfill needs assets with an older updateId
const { asset: sharedAsset1 } = await ctx.newAsset({ ownerId: user2.id });
const { asset: sharedAsset2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { album: sharedAlbum } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: sharedAlbum.id, assetId: sharedAsset1.id });
await ctx.newAlbumAsset({ albumId: sharedAlbum.id, assetId: sharedAsset2.id });
await wait(2);
// backfill needs an initial ack, otherwise it syncs everything
const { asset: ownedAsset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album: ownedAlbum } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: ownedAlbum.id, assetId: ownedAsset.id });
const setupResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
await ctx.syncAckAll(auth, setupResponse);
// share album to trigger backfill
await ctx.newAlbumUser({ albumId: sharedAlbum.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response1 = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response1).toEqual([
// receive both
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset1.id } }),
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset2.id } }),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack 1st
await ctx.sut.setAcks(auth, { acks: [response1[0].ack] });
const response2 = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response2).toEqual([
// receive 2nd
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset2.id } }),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
it('should detect and sync a deleted album to asset relation', async () => {
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
@@ -279,6 +279,68 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
});
it('should not resend an already-acked item when backfill resumes', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
// backfill needs assets with an older updateId
const { asset: partnerAsset1 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
const { asset: partnerAsset2 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
// backfill needs an initial ack, otherwise it syncs everything
const { asset: initialAsset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const setupResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(setupResponse).toEqual([
expect.objectContaining({
data: expect.objectContaining({ id: initialAsset.id }),
type: SyncEntityType.PartnerAssetV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, setupResponse);
// partner share to trigger backfill
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const response1 = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(response1).toEqual([
// receive both
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset1.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset2.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack 1st
await ctx.sut.setAcks(auth, { acks: [response1[0].ack] });
const response2 = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(response2).toEqual([
// receive 2nd
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset2.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
});
it('should hide isFavorite for partner assets', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
+3 -1
View File
@@ -159,7 +159,9 @@
}
.text-white-shadow {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
text-shadow:
0 0 4px rgba(0, 0, 0, 0.9),
0 1px 3px rgba(0, 0, 0, 0.8);
}
.icon-white-drop-shadow {
@@ -20,6 +20,7 @@
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
@@ -68,6 +69,7 @@
onAssetChange?: (asset: AssetResponseDto) => void;
preAction?: PreAction;
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (assetId: string) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>;
@@ -83,6 +85,7 @@
onAssetChange,
preAction,
onAction,
onUndoDelete,
onClose,
onRemoveFromAlbum,
onRandom,
@@ -311,6 +314,11 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.DELETE:
case AssetAction.TRASH: {
eventManager.emit('AssetsDelete', [asset.id]);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (stack) {
@@ -493,6 +501,7 @@
{stack}
preAction={handlePreAction}
onAction={handleAction}
{onUndoDelete}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
{onRemoveFromAlbum}
{playOriginalVideo}
@@ -4,9 +4,11 @@
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 SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
@@ -23,8 +25,9 @@
import { Route } from '$lib/route';
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleTrashOrDelete } from '$lib/services/asset.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
@@ -34,7 +37,7 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, shortcut, Tooltip, type ActionItem } from '@immich/ui';
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiCompare, mdiDotsVertical, mdiImageSearch, mdiVideoOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -45,6 +48,7 @@
stack?: StackResponseDto | null;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: () => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
playOriginalVideo: boolean;
@@ -58,6 +62,7 @@
stack = null,
preAction,
onAction,
onUndoDelete = undefined,
onClose,
onRemoveFromAlbum,
playOriginalVideo = false,
@@ -83,10 +88,6 @@
const sharedLink = getSharedLink();
</script>
<svelte:document
use:shortcut={{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => handleTrashOrDelete(asset, true) }}
/>
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} />
<div
@@ -127,8 +128,10 @@
{/if}
<ActionButton action={Actions.Edit} />
<ActionButton action={Actions.Delete} />
<ActionButton action={Actions.PermanentlyDelete} />
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
{/if}
{#if !sharedLink}
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
@@ -136,7 +139,10 @@
<ActionMenuItem action={Actions.Download} />
<ActionMenuItem action={Actions.DownloadOriginal} />
<ActionMenuItem action={Actions.Restore} />
{#if !isLocked && asset.isTrashed}
<RestoreAction {asset} {onAction} />
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (isOwner || isAlbumOwner)}
@@ -23,7 +23,7 @@
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { Icon, IconButton, Link, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
@@ -310,14 +310,13 @@
{#snippet popup({ marker })}
{@const { lat, lon } = marker}
<div class="flex flex-col items-center gap-1">
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
<a
<Text fontWeight="bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</Text>
<Link
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
target="_blank"
class="font-medium text-primary underline focus:outline-none"
class="text-primary"
>
{$t('open_in_openstreetmap')}
</a>
</Link>
</div>
{/snippet}
</Map>
@@ -324,6 +324,18 @@
shortcut: { key: ' ' },
onShortcut: () => (videoPlayer?.paused ? videoPlayer?.play() : videoPlayer?.pause()),
},
{
shortcut: { shift: true, key: 'ArrowLeft' },
onShortcut: () =>
videoPlayer ? (videoPlayer.currentTime = Math.max(videoPlayer.currentTime - 0.4, 0)) : undefined,
},
{
shortcut: { shift: true, key: 'ArrowRight' },
onShortcut: () =>
videoPlayer
? (videoPlayer.currentTime = Math.min(videoPlayer.currentTime + 0.4, videoPlayer.duration))
: undefined,
},
]}
/>
@@ -0,0 +1,48 @@
import type { AssetResponseDto } from '@immich/sdk';
import '@testing-library/jest-dom';
import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });
});
it('displays a button to move the asset to the trash bin', () => {
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('delete')).toBeInTheDocument();
expect(queryByTitle('deletePermanently')).toBeNull();
});
});
describe('but if the asset is already trashed', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: true });
});
it('displays a button to permanently delete the asset', () => {
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('permanently_delete')).toBeInTheDocument();
expect(queryByTitle('delete')).toBeNull();
});
});
});
@@ -0,0 +1,75 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { AssetAction } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { deleteAssets as deleteAssetsUtil, type OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
preAction: PreAction;
onUndoDelete?: OnUndoDelete;
}
let { asset, onAction, preAction, onUndoDelete = undefined }: Props = $props();
const forceDefault = $derived(asset.isTrashed || !featureFlagsManager.value.trash);
const trashOrDelete = async (forceRequest?: boolean) => {
const timelineAsset = toTimelineAsset(asset);
const force = forceDefault || forceRequest;
if (force) {
if ($showDeleteModal) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
try {
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
toastManager.primary($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
return;
}
preAction({ type: AssetAction.TRASH, asset: timelineAsset });
await deleteAssetsUtil(
false,
() => onAction({ type: AssetAction.TRASH, asset: timelineAsset }),
[timelineAsset],
onUndoDelete,
);
};
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
]}
/>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline}
aria-label={forceDefault ? $t('permanently_delete') : $t('delete')}
onclick={() => trashOrDelete()}
/>
@@ -0,0 +1,31 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
}
let { asset = $bindable(), onAction }: Props = $props();
const handleRestoreAsset = async () => {
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
</script>
<MenuOption icon={mdiHistory} onClick={handleRestoreAsset} text={$t('restore')} />
@@ -5,6 +5,9 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
[AssetAction.UNARCHIVE]: { asset: TimelineAsset };
[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 };
@@ -342,7 +342,7 @@
{#if !!assetOwner}
<div class="absolute inset-e-2 bottom-1 z-2 max-w-[50%]">
<p class="max-w-full truncate text-xs font-medium text-white drop-shadow-lg">
<p class="text-white-shadow max-w-full truncate p-1 text-xs font-medium text-white">
{assetOwner.name}
</p>
</div>
@@ -22,7 +22,6 @@
import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
interface Props {
sharedLink: SharedLinkResponseDto;
@@ -64,20 +63,15 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE: {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
await goto(Route.photos());
break;
}
// no default
}
};
const onAssetsDelete = async (assetIds: string[]) => {
// Only used for single asset shared link
if (assetIds.includes(assets[0].id)) {
await goto(Route.photos());
}
};
</script>
{#if sharedLink?.allowUpload || assets.length > 1}
@@ -138,8 +132,6 @@
{/if}
</header>
{:else if assets.length === 1}
<OnEvents {onAssetsDelete} />
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer cursor={{ current: asset }} onAction={handleAction} />
@@ -4,7 +4,6 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
@@ -286,7 +285,9 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE: {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
@@ -304,17 +305,6 @@
}
};
const onAssetsDelete = async (assetIds: string[]) => {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets = assets.filter((asset) => !assetIds.includes(asset.id));
if (assets.length === 0) {
return await goto(Route.photos());
}
if (assetIds.includes(assetCursor.current.id) && nextAsset) {
await navigateToAsset(nextAsset);
}
};
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset);
@@ -348,8 +338,6 @@
<svelte:document onselectstart={onSelectStart} use:shortcuts={shortcutList} onscroll={() => updateSlidingWindow()} />
<OnEvents {onAssetsDelete} />
{#if assets.length > 0}
<div
style:position="relative"
@@ -38,7 +38,6 @@
Control,
ControlButton,
ControlGroup,
FullscreenControl,
GeoJSON,
GeolocateControl,
MapLibre,
@@ -343,7 +342,6 @@
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{/if}
@@ -401,13 +399,13 @@
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[calc(5px-50%)] text-primary" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
? $t('map_marker_for_image', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
@@ -415,7 +413,7 @@
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
{@render popup({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
@@ -1,12 +1,12 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
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';
@@ -78,14 +78,6 @@
};
};
/** Find the next asset to show or close the viewer */
const navigateOrCloseViewer = async (id: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(id));
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
const asset = assetViewerManager.asset;
@@ -117,20 +109,35 @@
const handleRemoveFromAlbum = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
if (assetIds.includes(assetCursor.current.id)) {
await navigateOrCloseViewer(assetCursor.current.id);
if (!assetIds.includes(assetCursor.current.id)) {
return;
}
// keep the cleanup workflow in viewer by moving to adjacent asset first
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(assetCursor.current.id));
};
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: {
// must update manager before performing any navigation
timelineManager.removeAssets([action.asset.id]);
await navigateOrCloseViewer(action.asset.id);
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset.id));
break;
}
// no default
@@ -192,19 +199,9 @@
// no default
}
};
const onAssetsDelete = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
if (assetIds.includes(assetCursor.current.id)) {
await navigateOrCloseViewer(assetCursor.current.id);
}
};
const onAssetsRestore = async (assets: AssetResponseDto[]) => {
timelineManager.upsertAssets(assets.map((a) => toTimelineAsset(a)));
if (assets.length !== 1) {
// don't reopen asset viewer if multiple assets were restored (bulk action)
const handleUndoDelete = async (assets: TimelineAsset[]) => {
timelineManager.upsertAssets(assets);
if (assets.length === 0) {
return;
}
@@ -237,8 +234,6 @@
});
</script>
<OnEvents {onAssetsDelete} {onAssetsRestore} />
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
@@ -254,6 +249,7 @@
handleAction(action);
assetCacheManager.invalidate();
}}
onUndoDelete={handleUndoDelete}
onRandom={handleRandom}
onRemoveFromAlbum={handleRemoveFromAlbum}
onClose={handleClose}
+3
View File
@@ -3,6 +3,9 @@ export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12
export enum AssetAction {
ARCHIVE = 'archive',
UNARCHIVE = 'unarchive',
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
STACK = 'stack',
UNSTACK = 'unstack',
SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
@@ -36,7 +36,6 @@ export type Events = {
AssetUpdate: [AssetResponseDto];
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetsRestore: [AssetResponseDto[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
@@ -31,12 +31,6 @@ vitest.mock('$lib/utils', async () => {
};
});
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
};
});
describe('AssetService', () => {
describe('getAssetActions', () => {
beforeEach(() => {
-77
View File
@@ -3,9 +3,7 @@ import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
deleteAssets,
getAssetInfo,
restoreAssets,
runAssetJobs,
updateAsset,
type AssetJobsDto,
@@ -17,15 +15,12 @@ import {
mdiCogRefreshOutline,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDeleteForeverOutline,
mdiDeleteOutline,
mdiDownload,
mdiDownloadBox,
mdiFaceRecognition,
mdiHeadSyncOutline,
mdiHeart,
mdiHeartOutline,
mdiHistory,
mdiImageRefreshOutline,
mdiInformationOutline,
mdiMagnifyMinusOutline,
@@ -39,18 +34,14 @@ import {
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ProjectionType } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils';
@@ -105,7 +96,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const sharedLink = getSharedLink();
const authUser = authManager.authenticated ? authManager.user : undefined;
const isOwner = !!(authUser && authUser.id === asset.ownerId);
const isDeletionPermanent = asset.isTrashed || !featureFlagsManager.value.trash;
const Share: ActionItem = {
title: $t('share'),
@@ -252,29 +242,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'e' }],
};
const Delete: ActionItem = {
title: $t('delete'),
icon: mdiDeleteOutline,
$if: () => isOwner && !isDeletionPermanent,
onAction: () => handleTrashOrDelete(asset),
shortcuts: { key: 'Delete' },
};
const PermanentlyDelete: ActionItem = {
title: $t('permanently_delete'),
icon: mdiDeleteForeverOutline,
$if: () => isOwner && isDeletionPermanent,
onAction: () => handleTrashOrDelete(asset, true),
shortcuts: { key: 'Delete', shift: true },
};
const Restore: ActionItem = {
title: $t('restore'),
icon: mdiHistory,
$if: () => asset.visibility !== AssetVisibility.Locked && asset.isTrashed,
onAction: () => handleRestore(asset),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
@@ -319,9 +286,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Tag,
TagPeople,
Edit,
Delete,
PermanentlyDelete,
Restore,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
@@ -403,47 +367,6 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
}
};
export const handleTrashOrDelete = async (asset: AssetResponseDto, force?: boolean) => {
const $t = await getFormatter();
if (force && get(showDeleteModal)) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force } });
eventManager.emit('AssetsDelete', [asset.id]);
if (force) {
toastManager.primary($t('permanently_deleted_asset'));
} else {
toastManager.primary(
{
description: $t('moved_to_trash'),
button: { label: $t('undo'), color: 'secondary', onclick: () => handleRestore(asset) },
},
{ timeout: 5000 },
);
}
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
};
const handleRestore = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
eventManager.emit('AssetsRestore', [asset]);
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => {
const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
+3 -2
View File
@@ -24,7 +24,8 @@ class FaceManager {
});
readonly people = $derived.by(() => {
const people = new SvelteMap<string, PersonResponseDto>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const people = new Map<string, PersonResponseDto>();
for (const face of this.data) {
if (face.person) {
@@ -32,7 +33,7 @@ class FaceManager {
}
}
return people.values();
return Array.from(people.values());
});
readonly facesByPersonId = $derived.by(() => {
@@ -169,7 +169,9 @@
preload={false}
/>
{#if person.name}
<span class="absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text">
<span
class="text-white-shadow absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text"
>
{person.name}
</span>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import LargeAssetData from './LargeAssetData.svelte';
@@ -36,14 +37,16 @@
return asset;
};
const onAssetsDelete = async (assetIds: string[]) => {
if (assetIds.includes(assetCursor.current.id)) {
const preAction = async (payload: Action) => {
if (payload.type == 'trash') {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
assetViewerManager.showAssetViewer(false);
}
};
const onAssetsDelete = (assetIds: string[]) => {
assets = assets.filter(({ id }) => !assetIds.includes(id));
};
@@ -81,6 +84,7 @@
cursor={assetCursor}
showNavigation={assets.length > 1}
{onRandom}
{preAction}
onClose={() => {
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));