Compare commits

...

8 Commits

Author SHA1 Message Date
Thomas
677cb660f5 fix(mobile): reflect asset deletions instantly (#26835)
Sometimes the current asset won't update when deleted, or it won't
refresh until an event (like showing details) happens.
2026-03-17 06:43:14 -05:00
Michel Heusschen
9b0b2bfcf2 fix(web): jump to primary stacked asset from memory (#26978) 2026-03-17 06:39:39 -05:00
Preslav Penchev
ac6938a629 fix(web): allow pasting PIN code from clipboard or password manager (#26944)
* fix(web): allow pasting PIN code from clipboard or password manager

The keydown handler was blocking Ctrl+V/Cmd+V since it called
preventDefault() on all non-numeric keys. Also adds an onpaste
handler to distribute pasted digits across the individual inputs.

* refactor: handle paste in handleInput, remove maxlength

* cleanup + fix digit focus

---------

Co-authored-by: Preslav Penchev <preslav.penchev@acronis.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2026-03-17 06:38:06 -05:00
Thomas
16749ff8ba fix(server): sync files to disk (#26881)
Ensure that all files are flushed after they've been written.

At current, files are not explicitly flushed to disk, which can cause
data corruption. In extreme circumstances, it's possible that uploaded
files may not ever be persisted at all.
2026-03-17 06:33:43 -05:00
renovate[bot]
bba4a00eb1 chore(deps): update github-actions (#26967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 10:40:22 +01:00
Mees Frensel
9dafc8e8e9 fix(web): make link fit album card (#26958) 2026-03-16 19:17:55 +01:00
Michel Heusschen
4e44fb9cf7 fix(web): prevent search page error on missing album filter (#26948) 2026-03-16 19:15:20 +01:00
Yaros
82db581cc5 feat(mobile): open in browser (#26369)
* feat(mobile): open in browser

* chore: open in browser instead of webview

* chore: allow archived asset

* fix: moved openinbrowser above unstack

* feat: deeplink into favorites, trash & archived

* fix: use remoteId (for tests to succeed)

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-16 18:06:51 +00:00
25 changed files with 237 additions and 195 deletions

View File

@@ -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

View File

@@ -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'

View File

@@ -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}}'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -1651,6 +1651,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",

View File

@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> 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) {

View File

@@ -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) {

View File

@@ -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()},

View File

@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> 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()},

View File

@@ -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,
);
}
}

View File

@@ -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()},

View File

@@ -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) {

View File

@@ -16,13 +16,13 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
Future<void> 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) {

View File

@@ -81,19 +81,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
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<AssetViewer> {
final page = _pageController.page?.round();
if (page != null && page != _currentPage) {
_currentPage = page;
_onAssetChanged(page);
}
return false;
@@ -153,8 +150,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
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<AssetViewer> {
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<AssetViewer> {
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<AssetViewer> {
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: ref.read(timelineServiceProvider).totalAssets,
itemCount: _totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),

View File

@@ -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,

View File

@@ -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<T> {
@@ -35,34 +36,21 @@ interface Callback<T> {
(error: null, result: T): void;
}
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
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<string>,
);
}
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
callback as Callback<string>,
);
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<Partial<ImmichFile>>) {
(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) {

View File

@@ -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) {

View File

@@ -65,6 +65,7 @@
{#each albums as album, index (album.id)}
<a
href={Route.viewAlbum(album)}
class="h-fit"
animate:flip={{ duration: 400 }}
oncontextmenu={(event) => oncontextmenu(event, album)}
>

View File

@@ -63,8 +63,9 @@
let playerInitialized = $state(false);
let paused = $state(false);
let current = $state<MemoryAsset | undefined>(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 @@
</div>
<div>
<IconButton
href={Route.photos({ at: current.asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
/>
{#await currentMemoryAssetFull then asset}
{#if asset}
<IconButton
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
/>
{/if}
{/await}
</div>
</div>
<!-- CONTROL BUTTONS -->

View File

@@ -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;
}
}
}
</script>
@@ -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"

View File

@@ -67,7 +67,7 @@
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
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<SearchTerms>(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));
}