diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index ca9f91bbe8..b725b45f6c 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30 + uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index a2c763a0f6..d3eb66810e 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -42,10 +42,10 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './cli/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4f093a170e..3450fe96bb 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 28828f22c6..02d7b3456a 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -67,10 +67,10 @@ jobs: fetch-depth: 0 - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './docs/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 1daa279cd2..0091bcef89 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -29,10 +29,10 @@ jobs: persist-credentials: true - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index a1d31a61ea..6030dc8752 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -63,13 +63,13 @@ jobs: ref: main - name: Install uv - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 1bcdec4747..2da7d79b26 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -30,10 +30,10 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 # Setup .npmrc file to publish to npm - - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './open-api/typescript-sdk/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1cad2b0023..2a2ebe2389 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,9 +75,9 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -119,9 +119,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -166,9 +166,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -208,9 +208,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -252,9 +252,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -290,9 +290,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -338,9 +338,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -385,9 +385,9 @@ jobs: submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -424,9 +424,9 @@ jobs: submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -496,9 +496,9 @@ jobs: submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -620,7 +620,7 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Install uv - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 with: python-version: 3.11 - name: Install dependencies @@ -661,9 +661,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './.github/.nvmrc' cache: 'pnpm' @@ -712,9 +712,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -774,9 +774,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/i18n/en.json b/i18n/en.json index e926434f6d..eb1b95851f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1653,6 +1653,7 @@ "only_favorites": "Only favorites", "open": "Open", "open_calendar": "Open calendar", + "open_in_browser": "Open in browser", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", diff --git a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart index 7c7e96c1c5..a673dff1d7 100644 --- a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart @@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; Future performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { if (!context.mounted) return; - final result = await ref.read(actionProvider.notifier).archive(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).archive(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart index 94ee1b2343..2121ef3159 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart @@ -57,13 +57,13 @@ class DeleteActionButton extends ConsumerWidget { if (confirm != true) return; } - final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index 710ec506c2..27a1a4d8af 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -35,13 +35,13 @@ class DeletePermanentActionButton extends ConsumerWidget { false; if (!confirm) return; - final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'delete_permanently_action_prompt'.t( context: context, args: {'count': result.count.toString()}, diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart index 341791c1af..2f7c3899eb 100644 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart @@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; Future performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { if (!context.mounted) return; - final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'move_to_lock_folder_action_prompt'.t( context: context, args: {'count': result.count.toString()}, diff --git a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart new file mode 100644 index 0000000000..17703d0beb --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class OpenInBrowserActionButton extends ConsumerWidget { + final String remoteId; + final TimelineOrigin origin; + final bool iconOnly; + final bool menuItem; + final Color? iconColor; + + const OpenInBrowserActionButton({ + super.key, + required this.remoteId, + required this.origin, + this.iconOnly = false, + this.menuItem = false, + this.iconColor, + }); + + void _onTap() async { + final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', ''); + + String originPath = ''; + switch (origin) { + case TimelineOrigin.favorite: + originPath = '/favorites'; + break; + case TimelineOrigin.trash: + originPath = '/trash'; + break; + case TimelineOrigin.archive: + originPath = '/archive'; + break; + default: + break; + } + + final url = '$serverEndpoint$originPath/photos/$remoteId'; + if (await canLaunchUrl(Uri.parse(url))) { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + label: 'open_in_browser'.t(context: context), + iconData: Icons.open_in_browser, + iconColor: iconColor, + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: _onTap, + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart index fd88e94cf7..97a36a56dc 100644 --- a/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart @@ -29,13 +29,13 @@ class RemoveFromAlbumActionButton extends ConsumerWidget { return; } - final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'remove_from_album_action_prompt'.t( context: context, args: {'count': result.count.toString()}, diff --git a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart index 5f1e385769..e95569af45 100644 --- a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart @@ -25,13 +25,13 @@ class TrashActionButton extends ConsumerWidget { return; } - final result = await ref.read(actionProvider.notifier).trash(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).trash(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart index 8cf0bcba92..98e868d953 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart @@ -16,13 +16,13 @@ import 'package:immich_mobile/domain/utils/event_stream.dart'; Future performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { if (!context.mounted) return; - final result = await ref.read(actionProvider.notifier).unArchive(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).unArchive(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 903105406c..4d8954d4ef 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -81,19 +81,17 @@ class _AssetViewerState extends ConsumerState { late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); late int _currentPage = widget.initialIndex; + late int _totalAssets = ref.read(timelineServiceProvider).totalAssets; StreamSubscription? _reloadSubscription; KeepAliveLink? _stackChildrenKeepAlive; - bool _assetReloadRequested = false; - void _onTapNavigate(int direction) { final page = _pageController.page?.toInt(); if (page == null) return; final target = page + direction; - final maxPage = ref.read(timelineServiceProvider).totalAssets - 1; + final maxPage = _totalAssets - 1; if (target >= 0 && target <= maxPage) { - _currentPage = target; _pageController.jumpToPage(target); _onAssetChanged(target); } @@ -141,7 +139,6 @@ class _AssetViewerState extends ConsumerState { final page = _pageController.page?.round(); if (page != null && page != _currentPage) { - _currentPage = page; _onAssetChanged(page); } return false; @@ -153,8 +150,9 @@ class _AssetViewerState extends ConsumerState { } void _onAssetChanged(int index) async { - final timelineService = ref.read(timelineServiceProvider); - final asset = await timelineService.getAssetAsync(index); + _currentPage = index; + + final asset = await ref.read(timelineServiceProvider).getAssetAsync(index); if (asset == null) return; AssetViewer._setAsset(ref, asset); @@ -193,11 +191,20 @@ class _AssetViewerState extends ConsumerState { case TimelineReloadEvent(): _onTimelineReloadEvent(); case ViewerReloadAssetEvent(): - _assetReloadRequested = true; + _onViewerReloadEvent(); default: } } + void _onViewerReloadEvent() { + if (_totalAssets <= 1) return; + + final index = _pageController.page?.round() ?? 0; + final target = index >= _totalAssets - 1 ? index - 1 : index + 1; + _pageController.animateToPage(target, duration: Durations.medium1, curve: Curves.easeInOut); + _onAssetChanged(target); + } + void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); final totalAssets = timelineService.totalAssets; @@ -207,43 +214,24 @@ class _AssetViewerState extends ConsumerState { return; } - var index = _pageController.page?.round() ?? 0; final currentAsset = ref.read(assetViewerProvider).currentAsset; - if (currentAsset != null) { - final newIndex = timelineService.getIndex(currentAsset.heroTag); - if (newIndex != null && newIndex != index) { - index = newIndex; - _currentPage = index; - _pageController.jumpToPage(index); - } - } + final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null; + final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1); - if (index >= totalAssets) { - index = totalAssets - 1; - _currentPage = index; + if (index != _currentPage) { _pageController.jumpToPage(index); + _onAssetChanged(index); + } else if (currentAsset != null && assetIndex == null) { + _onAssetChanged(index); } - if (_assetReloadRequested) { - _assetReloadRequested = false; - _onAssetReloadEvent(index); + if (_totalAssets != totalAssets) { + setState(() { + _totalAssets = totalAssets; + }); } } - void _onAssetReloadEvent(int index) async { - final timelineService = ref.read(timelineServiceProvider); - - final newAsset = await timelineService.getAssetAsync(index); - if (newAsset == null) return; - - final currentAsset = ref.read(assetViewerProvider).currentAsset; - - // Do not reload if the asset has not changed - if (newAsset.heroTag == currentAsset?.heroTag) return; - - _onAssetChanged(index); - } - void _setSystemUIMode(bool controls, bool details) { final mode = !controls || (CurrentPlatform.isIOS && details) ? SystemUiMode.immersiveSticky @@ -301,7 +289,7 @@ class _AssetViewerState extends ConsumerState { : CurrentPlatform.isIOS ? const FastScrollPhysics() : const FastClampingScrollPhysics(), - itemCount: ref.read(timelineServiceProvider).totalAssets, + itemCount: _totalAssets, itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate), ), diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 2e26d8e80d..071956392c 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; @@ -75,6 +76,7 @@ enum ActionButtonType { viewInTimeline, download, upload, + openInBrowser, unstack, archive, unarchive, @@ -149,6 +151,7 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.isStacked, + ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.likeActivity => !context.isInLockedView && context.currentAlbum != null && @@ -236,6 +239,13 @@ enum ActionButtonType { ), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.openInBrowser => OpenInBrowserActionButton( + remoteId: context.asset.remoteId!, + origin: context.timelineOrigin, + iconOnly: iconOnly, + menuItem: menuItem, + iconColor: context.originalTheme?.iconTheme.color, + ), ActionButtonType.similarPhotos => SimilarPhotosActionButton( assetId: (context.asset as RemoteAsset).id, iconOnly: iconOnly, diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 6dfd11ee4b..63acb13789 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -3,13 +3,16 @@ import { PATH_METADATA } from '@nestjs/common/constants'; import { Reflector } from '@nestjs/core'; import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils'; import { NextFunction, RequestHandler } from 'express'; -import multer, { StorageEngine, diskStorage } from 'multer'; +import multer from 'multer'; import { createHash, randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { pipeline } from 'node:stream'; import { Observable } from 'rxjs'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { RouteKey } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; import { AssetMediaService } from 'src/services/asset-media.service'; import { ImmichFile, UploadFile, UploadFiles } from 'src/types'; import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util'; @@ -26,8 +29,6 @@ export function getFiles(files: UploadFiles) { }; } -type DiskStorageCallback = (error: Error | null, result: string) => void; - type ImmichMulterFile = Express.Multer.File & { uuid: string }; interface Callback { @@ -35,34 +36,21 @@ interface Callback { (error: null, result: T): void; } -const callbackify = (target: (...arguments_: any[]) => T, callback: Callback) => { - try { - return callback(null, target()); - } catch (error: Error | any) { - return callback(error); - } -}; - @Injectable() export class FileUploadInterceptor implements NestInterceptor { private handlers: { userProfile: RequestHandler; assetUpload: RequestHandler; }; - private defaultStorage: StorageEngine; constructor( private reflect: Reflector, private assetService: AssetMediaService, + private storageRepository: StorageRepository, private logger: LoggingRepository, ) { this.logger.setContext(FileUploadInterceptor.name); - this.defaultStorage = diskStorage({ - filename: this.filename.bind(this), - destination: this.destination.bind(this), - }); - const instance = multer({ fileFilter: this.fileFilter.bind(this), storage: { @@ -99,60 +87,60 @@ export class FileUploadInterceptor implements NestInterceptor { } private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) { - return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback); - } - - private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { - return callbackify( - () => this.assetService.getUploadFilename(asUploadRequest(request, file)), - callback as Callback, - ); - } - - private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { - return callbackify( - () => this.assetService.getUploadFolder(asUploadRequest(request, file)), - callback as Callback, - ); + try { + callback(null, this.assetService.canUploadFile(asUploadRequest(request, file))); + } catch (error: Error | any) { + callback(error); + } } private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback>) { - (file as ImmichMulterFile).uuid = randomUUID(); - request.on('error', (error) => { this.logger.warn('Request error while uploading file, cleaning up', error); this.assetService.onUploadError(request, file).catch(this.logger.error); }); - if (!this.isAssetUploadFile(file)) { - this.defaultStorage._handleFile(request, file, callback); - return; - } + try { + (file as ImmichMulterFile).uuid = randomUUID(); - const hash = createHash('sha1'); - file.stream.on('data', (chunk) => hash.update(chunk)); - this.defaultStorage._handleFile(request, file, (error, info) => { - if (error) { - hash.destroy(); - callback(error); - } else { - callback(null, { ...info, checksum: hash.digest() }); - } - }); + const uploadRequest = asUploadRequest(request, file); + + const path = join( + this.assetService.getUploadFolder(uploadRequest), + this.assetService.getUploadFilename(uploadRequest), + ); + + const writeStream = this.storageRepository.createWriteStream(path); + const hash = file.fieldname === UploadFieldName.ASSET_DATA ? createHash('sha1') : null; + + let size = 0; + + file.stream.on('data', (chunk) => { + hash?.update(chunk); + size += chunk.length; + }); + + pipeline(file.stream, writeStream, (error) => { + if (error) { + hash?.destroy(); + return callback(error); + } + callback(null, { + path, + size, + checksum: hash?.digest(), + }); + }); + } catch (error: Error | any) { + callback(error); + } } - private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) { - this.defaultStorage._removeFile(request, file, callback); - } - - private isAssetUploadFile(file: Express.Multer.File) { - switch (file.fieldname as UploadFieldName) { - case UploadFieldName.ASSET_DATA: { - return true; - } - } - - return false; + private removeFile(_request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) { + this.storageRepository + .unlink(file.path) + .then(() => callback(null)) + .catch(callback); } private getHandler(route: RouteKey) { diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 5a1a936e77..c7ba4ab6cc 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -63,7 +63,7 @@ export class StorageRepository { } createWriteStream(filepath: string): Writable { - return createWriteStream(filepath, { flags: 'w' }); + return createWriteStream(filepath, { flags: 'w', flush: true }); } createOrOverwriteFile(filepath: string, buffer: Buffer) { diff --git a/web/src/lib/components/album-page/album-card-group.svelte b/web/src/lib/components/album-page/album-card-group.svelte index 99aa8f2b71..eb931d834c 100644 --- a/web/src/lib/components/album-page/album-card-group.svelte +++ b/web/src/lib/components/album-page/album-card-group.svelte @@ -65,6 +65,7 @@ {#each albums as album, index (album.id)} oncontextmenu(event, album)} > diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 2098f48049..d20eb29227 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -63,8 +63,9 @@ let playerInitialized = $state(false); let paused = $state(false); let current = $state(undefined); - let currentMemoryAssetFull = $derived.by(async () => - current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined, + const currentAssetId = $derived(current?.asset.id); + const currentMemoryAssetFull = $derived.by(async () => + currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined, ); let currentTimelineAssets = $derived(current?.memory.assets ?? []); let viewerAssets = $derived([ @@ -550,14 +551,18 @@
- + {#await currentMemoryAssetFull then asset} + {#if asset} + + {/if} + {/await}
diff --git a/web/src/lib/components/user-settings-page/PinCodeInput.svelte b/web/src/lib/components/user-settings-page/PinCodeInput.svelte index c438d8f0b1..2ab48c0afe 100644 --- a/web/src/lib/components/user-settings-page/PinCodeInput.svelte +++ b/web/src/lib/components/user-settings-page/PinCodeInput.svelte @@ -49,25 +49,22 @@ const handleInput = (event: Event, index: number) => { const target = event.target as HTMLInputElement; - let currentPinValue = target.value; + const digits = target.value.replaceAll(/\D/g, '').slice(0, pinLength - index); - if (target.value.length > 1) { - currentPinValue = value.slice(0, 1); - } - - if (Number.isNaN(Number(value))) { + if (digits.length === 0) { pinValues[index] = ''; - target.value = ''; + value = pinValues.join('').trim(); return; } - pinValues[index] = currentPinValue; + for (let i = 0; i < digits.length; i++) { + pinValues[index + i] = digits[i]; + } value = pinValues.join('').trim(); - if (value && index < pinLength - 1) { - focusNext(index); - } + const lastFilledIndex = Math.min(index + digits.length, pinLength - 1); + pinCodeInputElements[lastFilledIndex]?.focus(); if (value.length === pinLength) { onFilled?.(value); @@ -104,12 +101,6 @@ } return; } - default: { - if (Number.isNaN(Number(event.key))) { - event.preventDefault(); - } - break; - } } } @@ -125,7 +116,6 @@ {type} inputmode="numeric" pattern="[0-9]*" - maxlength="1" bind:this={pinCodeInputElements[index]} id="pin-code-{index}" class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light" diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 701bc0ff59..4f050b8629 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -67,7 +67,7 @@ type SearchTerms = MetadataSearchDto & Pick; let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY)); let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); - let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); + let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); $effect(() => { // we want this to *only* be reactive on `terms` @@ -137,15 +137,13 @@ const searchDto: SearchTerms = { page: nextPage, withExif: true, - isVisible: true, - language: $lang, ...terms, }; try { const { albums, assets } = ('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled - ? await searchSmart({ smartSearchDto: searchDto }) + ? await searchSmart({ smartSearchDto: { ...searchDto, language: $lang } }) : await searchAssets({ metadataSearchDto: searchDto }); searchResultAlbums.push(...albums.items); @@ -230,7 +228,7 @@ const onAlbumAddAssets = ({ assetIds }: { assetIds: string[] }) => { cancelMultiselect(assetInteraction); - if (terms.isNotInAlbum.toString() == 'true') { + if (terms.isNotInAlbum) { const assetIdSet = new Set(assetIds); searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id)); }