Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state

# Conflicts:
#	mobile/ios/Runner/Sync/MessagesImpl.swift
This commit is contained in:
Peter Ombodi 2025-10-14 15:36:09 +03:00
commit 69b6472adf
41 changed files with 829 additions and 625 deletions

View file

@ -140,7 +140,7 @@ services:
database: database:
container_name: immich_postgres container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84 image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
env_file: env_file:
- .env - .env
environment: environment:

View file

@ -63,7 +63,7 @@ services:
database: database:
container_name: immich_postgres container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84 image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
env_file: env_file:
- .env - .env
environment: environment:

View file

@ -56,7 +56,7 @@ services:
database: database:
container_name: immich_postgres container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84 image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
environment: environment:
POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME} POSTGRES_USER: ${DB_USERNAME}

View file

@ -22,7 +22,7 @@ For organizations seeking to resell Immich, we have established the following gu
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team. - Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directy from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work. - For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app

View file

@ -4,7 +4,7 @@ Immich supports the Google's Cast protocol so that photos and videos can be cast
## Enable Google Cast Support ## Enable Google Cast Support
Google Cast support is disabled by default. The web UI uses Google-provided scripts and must retreive them from Google servers when the page loads. This is a privacy concern for some and is thus opt-in. Google Cast support is disabled by default. The web UI uses Google-provided scripts and must retrieve them from Google servers when the page loads. This is a privacy concern for some and is thus opt-in.
You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast` You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast`

View file

@ -35,10 +35,10 @@ services:
- 2285:2285 - 2285:2285
redis: redis:
image: redis:6.2-alpine@sha256:2185e741f4c1e7b0ea9ca1e163a3767c4270a73086b6bbea2049a7203212fb7f image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc
database: database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:11ced39d65a92a54d12890ced6a26cc2003f92697d6f0d4d944b98459dba7138 image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment: environment:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres

View file

@ -1039,6 +1039,7 @@
"exif_bottom_sheet_description_error": "Error updating description", "exif_bottom_sheet_description_error": "Error updating description",
"exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_details": "DETAILS",
"exif_bottom_sheet_location": "LOCATION", "exif_bottom_sheet_location": "LOCATION",
"exif_bottom_sheet_no_description": "No description",
"exif_bottom_sheet_people": "PEOPLE", "exif_bottom_sheet_people": "PEOPLE",
"exif_bottom_sheet_person_add_person": "Add name", "exif_bottom_sheet_person_add_person": "Add name",
"exit_slideshow": "Exit Slideshow", "exit_slideshow": "Exit Slideshow",

View file

@ -1,7 +1,7 @@
[tools] [tools]
node = "22.20.0" node = "22.20.0"
flutter = "3.35.5" flutter = "3.35.5"
pnpm = "10.18.0" pnpm = "10.18.1"
[tools."github:CQLabs/homebrew-dcm"] [tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0" version = "1.30.0"

View file

@ -131,10 +131,13 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync; path = Sync;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -247,6 +250,7 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B231F52D2E93A44A00BC45D1 /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */, B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */, B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
@ -331,6 +335,7 @@
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */, F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
); );
name = Runner; name = Runner;
@ -521,10 +526,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@ -553,10 +562,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";

View file

@ -20,7 +20,7 @@ import UIKit
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(binaryMessenger: controller.binaryMessenger) AppDelegate.registerPlugins(with: controller.engine)
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
BackgroundServicePlugin.registerBackgroundProcessing() BackgroundServicePlugin.registerBackgroundProcessing()
@ -51,9 +51,13 @@ import UIKit
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) { public static func registerPlugins(with engine: FlutterEngine) {
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl()) NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl()) ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
}
public static func cancelPlugins(with engine: FlutterEngine) {
(engine.valuePublished(byPlugin: NativeSyncApiImpl.name) as? NativeSyncApiImpl)?.detachFromEngine()
} }
} }

View file

@ -95,7 +95,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
// Register plugins in the new engine // Register plugins in the new engine
GeneratedPluginRegistrant.register(with: engine) GeneratedPluginRegistrant.register(with: engine)
// Register custom plugins // Register custom plugins
AppDelegate.registerPlugins(binaryMessenger: engine.binaryMessenger) AppDelegate.registerPlugins(with: engine)
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger) flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self) BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
@ -168,6 +168,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
} }
isComplete = true isComplete = true
AppDelegate.cancelPlugins(with: engine)
engine.destroyContext() engine.destroyContext()
flutterApi = nil flutterApi = nil
completionHandler(success) completionHandler(success)

View file

@ -0,0 +1,17 @@
class ImmichPlugin: NSObject {
var detached: Bool
override init() {
detached = false
super.init()
}
func detachFromEngine() {
self.detached = true
}
func completeWhenActive<T>(for completion: @escaping (T) -> Void, with value: T) {
guard !self.detached else { return }
completion(value)
}
}

View file

@ -17,13 +17,25 @@ struct AssetWrapper: Hashable, Equatable {
} }
} }
class NativeSyncApiImpl: NativeSyncApi { class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
static let name = "NativeSyncApi"
static func register(with registrar: any FlutterPluginRegistrar) {
let instance = NativeSyncApiImpl()
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
registrar.publish(instance)
}
func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
super.detachFromEngine()
}
private let defaults: UserDefaults private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken" private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
private let recoveredAlbumSubType = 1000000219 private let recoveredAlbumSubType = 1000000219
private var hashTask: Task<Void, Error>? private var hashTask: Task<Void?, Error>?
private static let hashCancelledCode = "HASH_CANCELLED" private static let hashCancelledCode = "HASH_CANCELLED"
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil)) private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
@ -272,7 +284,7 @@ class NativeSyncApiImpl: NativeSyncApi {
} }
if Task.isCancelled { if Task.isCancelled {
return completion(Self.hashCancelled) return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
} }
await withTaskGroup(of: HashResult?.self) { taskGroup in await withTaskGroup(of: HashResult?.self) { taskGroup in
@ -280,7 +292,7 @@ class NativeSyncApiImpl: NativeSyncApi {
results.reserveCapacity(assets.count) results.reserveCapacity(assets.count)
for asset in assets { for asset in assets {
if Task.isCancelled { if Task.isCancelled {
return completion(Self.hashCancelled) return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
} }
taskGroup.addTask { taskGroup.addTask {
guard let self = self else { return nil } guard let self = self else { return nil }
@ -290,7 +302,7 @@ class NativeSyncApiImpl: NativeSyncApi {
for await result in taskGroup { for await result in taskGroup {
guard let result = result else { guard let result = result else {
return completion(Self.hashCancelled) return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
} }
results.append(result) results.append(result)
} }
@ -299,7 +311,7 @@ class NativeSyncApiImpl: NativeSyncApi {
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil)) results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
} }
completion(.success(results)) return self?.completeWhenActive(for: completion, with: .success(results))
} }
} }
} }

View file

@ -114,7 +114,7 @@ struct ImmichMemoryProvider: TimelineProvider {
} }
} }
// If we didnt add any memory images (some failure occured or no images in memory), // If we didn't add any memory images (some failure occurred or no images in memory),
// default to 12 hours of random photos // default to 12 hours of random photos
if entries.count == 0 { if entries.count == 0 {
// this must be a do/catch since we need to // this must be a do/catch since we need to

View file

@ -56,6 +56,8 @@ sealed class BaseAsset {
// Overridden in subclasses // Overridden in subclasses
AssetState get storage; AssetState get storage;
String? get localId;
String? get remoteId;
String get heroTag; String get heroTag;
@override @override

View file

@ -2,12 +2,12 @@ part of 'base_asset.model.dart';
class LocalAsset extends BaseAsset { class LocalAsset extends BaseAsset {
final String id; final String id;
final String? remoteId; final String? remoteAssetId;
final int orientation; final int orientation;
const LocalAsset({ const LocalAsset({
required this.id, required this.id,
this.remoteId, String? remoteId,
required super.name, required super.name,
super.checksum, super.checksum,
required super.type, required super.type,
@ -19,7 +19,13 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false, super.isFavorite = false,
super.livePhotoVideoId, super.livePhotoVideoId,
this.orientation = 0, this.orientation = 0,
}); }) : remoteAssetId = remoteId;
@override
String? get localId => id;
@override
String? get remoteId => remoteAssetId;
@override @override
AssetState get storage => remoteId == null ? AssetState.local : AssetState.merged; AssetState get storage => remoteId == null ? AssetState.local : AssetState.merged;

View file

@ -5,7 +5,7 @@ enum AssetVisibility { timeline, hidden, archive, locked }
// Model for an asset stored in the server // Model for an asset stored in the server
class RemoteAsset extends BaseAsset { class RemoteAsset extends BaseAsset {
final String id; final String id;
final String? localId; final String? localAssetId;
final String? thumbHash; final String? thumbHash;
final AssetVisibility visibility; final AssetVisibility visibility;
final String ownerId; final String ownerId;
@ -13,7 +13,7 @@ class RemoteAsset extends BaseAsset {
const RemoteAsset({ const RemoteAsset({
required this.id, required this.id,
this.localId, String? localId,
required super.name, required super.name,
required this.ownerId, required this.ownerId,
required super.checksum, required super.checksum,
@ -28,7 +28,13 @@ class RemoteAsset extends BaseAsset {
this.visibility = AssetVisibility.timeline, this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId, super.livePhotoVideoId,
this.stackId, this.stackId,
}); }) : localAssetId = localId;
@override
String? get localId => localAssetId;
@override
String? get remoteId => id;
@override @override
AssetState get storage => localId == null ? AssetState.remote : AssetState.merged; AssetState get storage => localId == null ? AssetState.remote : AssetState.merged;

View file

@ -192,6 +192,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_cancellationToken.cancel(); _cancellationToken.cancel();
_logger.info("Cleaning up background worker"); _logger.info("Cleaning up background worker");
final cleanupFutures = [ final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManager.dispose().catchError((_) async { workerManager.dispose().catchError((_) async {
// Discard any errors on the dispose call // Discard any errors on the dispose call
return; return;
@ -201,7 +202,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_drift.close(), _drift.close(),
_driftLogger.close(), _driftLogger.close(),
backgroundSyncManager?.cancel(), backgroundSyncManager?.cancel(),
nativeSyncApi?.cancelHashing(),
]; ];
if (_isar.isOpen) { if (_isar.isOpen) {

View file

@ -120,6 +120,10 @@ class RemoteAlbumService {
return _repository.getSharedUsers(albumId); return _repository.getSharedUsers(albumId);
} }
Future<AlbumUserRole?> getUserRole(String albumId, String userId) {
return _repository.getUserRole(albumId, userId);
}
Future<List<RemoteAsset>> getAssets(String albumId) { Future<List<RemoteAsset>> getAssets(String albumId) {
return _repository.getAssets(albumId); return _repository.getAssets(albumId);
} }

View file

@ -86,7 +86,7 @@ class StoreService {
_cache.remove(key.id); _cache.remove(key.id);
} }
/// Clears all values from thw store (cache and DB) /// Clears all values from the store (cache and DB)
Future<void> clear() async { Future<void> clear() async {
await _storeRepository.deleteAll(); await _storeRepository.deleteAll();
_cache.clear(); _cache.clear();

View file

@ -221,6 +221,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.get(); .get();
} }
Future<AlbumUserRole?> getUserRole(String albumId, String userId) async {
final query = _db.remoteAlbumUserEntity.select()
..where((row) => row.albumId.equals(albumId) & row.userId.equals(userId))
..limit(1);
final result = await query.getSingleOrNull();
return result?.role;
}
Future<List<RemoteAsset>> getAssets(String albumId) { Future<List<RemoteAsset>> getAssets(String albumId) {
final query = _db.remoteAlbumAssetEntity.select().join([ final query = _db.remoteAlbumAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)), innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),

View file

@ -169,9 +169,11 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
context.pushRoute(const DriftActivitiesRoute()); context.pushRoute(const DriftActivitiesRoute());
} }
void showOptionSheet(BuildContext context) { Future<void> showOptionSheet(BuildContext context) async {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false; final isOwner = user != null ? user.id == _album.ownerId : false;
final canAddPhotos =
await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@ -193,22 +195,30 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
context.pop(); context.pop();
} }
: null, : null,
onAddPhotos: () async { onAddPhotos: isOwner || canAddPhotos
? () async {
await addAssets(context); await addAssets(context);
context.pop(); context.pop();
}, }
onToggleAlbumOrder: () async { : null,
onToggleAlbumOrder: isOwner
? () async {
await toggleAlbumOrder(); await toggleAlbumOrder();
context.pop(); context.pop();
}, }
onEditAlbum: () async { : null,
onEditAlbum: isOwner
? () async {
context.pop(); context.pop();
await showEditTitleAndDescription(context); await showEditTitleAndDescription(context);
}, }
onCreateSharedLink: () async { : null,
onCreateSharedLink: isOwner
? () async {
context.pop(); context.pop();
context.pushRoute(SharedLinkEditRoute(albumId: _album.id)); context.pushRoute(SharedLinkEditRoute(albumId: _album.id));
}, }
: null,
onShowOptions: () { onShowOptions: () {
context.pop(); context.pop();
context.pushRoute(const DriftAlbumOptionsRoute()); context.pushRoute(const DriftAlbumOptionsRoute());
@ -220,6 +230,9 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false;
return PopScope( return PopScope(
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (didPop) { if (didPop) {
@ -243,8 +256,8 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
appBar: RemoteAlbumSliverAppBar( appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined, icon: Icons.photo_album_outlined,
onShowOptions: () => showOptionSheet(context), onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: () => toggleAlbumOrder(), onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
onEditTitle: () => showEditTitleAndDescription(context), onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
onActivity: () => showActivity(context), onActivity: () => showActivity(context),
), ),
bottomSheet: RemoteAlbumBottomSheet(album: _album), bottomSheet: RemoteAlbumBottomSheet(album: _album),

View file

@ -8,6 +8,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior: /// This delete action has the following behavior:
@ -22,7 +23,17 @@ class DeleteLocalActionButton extends ConsumerWidget {
return; return;
} }
final result = await ref.read(actionProvider.notifier).deleteLocal(source); bool? backedUpOnly = await showDialog<bool>(
context: context,
builder: (BuildContext context) => DeleteLocalOnlyDialog(onDeleteLocal: (_) {}),
);
if (backedUpOnly == null) {
// User cancelled the dialog
return;
}
final result = await ref.read(actionProvider.notifier).deleteLocal(source, backedUpOnly);
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {

View file

@ -43,7 +43,7 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(), if (asset.type == AssetType.image && isOwner) const EditImageActionButton(),
if (isOwner) ...[ if (isOwner) ...[
if (asset.hasRemote && isOwner && isArchived) if (asset.hasRemote && isOwner && isArchived)
const UnArchiveActionButton(source: ActionSource.viewer) const UnArchiveActionButton(source: ActionSource.viewer)

View file

@ -140,6 +140,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo); final cameraTitle = _getCameraInfoTitle(exifInfo);
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
return SliverList.list( return SliverList.list(
children: [ children: [
@ -147,10 +148,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
_SheetTile( _SheetTile(
title: _getDateTime(context, asset), title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null, trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote ? () async => await _editDateTime(context, ref) : null, onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
), ),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo), if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
const SheetPeopleDetails(), const SheetPeopleDetails(),
const SheetLocationDetails(), const SheetLocationDetails(),
// Details header // Details header
@ -265,8 +266,9 @@ class _SheetTile extends ConsumerWidget {
class _SheetAssetDescription extends ConsumerStatefulWidget { class _SheetAssetDescription extends ConsumerStatefulWidget {
final ExifInfo exif; final ExifInfo exif;
final bool isEditable;
const _SheetAssetDescription({required this.exif}); const _SheetAssetDescription({required this.exif, this.isEditable = true});
@override @override
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
@ -312,19 +314,24 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
// Update controller text when EXIF data changes // Update controller text when EXIF data changes
final currentDescription = currentExifInfo?.description ?? ''; final currentDescription = currentExifInfo?.description ?? '';
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
context: context,
);
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
_controller.text = currentDescription; _controller.text = currentDescription;
} }
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: IgnorePointer(
ignoring: !widget.isEditable,
child: TextField( child: TextField(
controller: _controller, controller: _controller,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
focusNode: _descriptionFocus, focusNode: _descriptionFocus,
maxLines: null, // makes it grow as text is added maxLines: null, // makes it grow as text is added
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'exif_bottom_sheet_description'.t(context: context), hintText: hintText,
border: InputBorder.none, border: InputBorder.none,
enabledBorder: InputBorder.none, enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none, focusedBorder: InputBorder.none,
@ -334,6 +341,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
), ),
onTapOutside: (_) => saveDescription(currentExifInfo?.description), onTapOutside: (_) => saveDescription(currentExifInfo?.description),
), ),
),
); );
} }
} }

View file

@ -45,7 +45,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
(previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) && (previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) &&
previousRouteName != AssetViewerRoute.name && previousRouteName != AssetViewerRoute.name &&
previousRouteName != null && previousRouteName != null &&
previousRouteName != LocalTimelineRoute.name; previousRouteName != LocalTimelineRoute.name &&
isOwner;
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));

View file

@ -24,6 +24,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget { class RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
@ -53,6 +54,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider); final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
Future<void> addAssetsToAlbum(RemoteAlbum album) async { Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets; final selectedAssets = multiselect.selectedAssets;
@ -93,9 +95,13 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const ShareActionButton(source: ActionSource.timeline), const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[ if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline),
],
const DownloadActionButton(source: ActionSource.timeline), const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.timeline) ? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline), : const DeletePermanentActionButton(source: ActionSource.timeline),
@ -105,16 +111,19 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
], ],
],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(source: ActionSource.timeline), const UploadActionButton(source: ActionSource.timeline),
], ],
RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id), if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
], ],
slivers: [ slivers: ownsAlbum
? [
const AddToAlbumHeader(), const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand), AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
], ]
: null,
); );
} }
} }

View file

@ -260,8 +260,15 @@ class ActionNotifier extends Notifier<void> {
} }
} }
Future<ActionResult> deleteLocal(ActionSource source) async { Future<ActionResult> deleteLocal(ActionSource source, bool backedUpOnly) async {
final ids = _getLocalIdsForSource(source); final List<String> ids;
if (backedUpOnly) {
final assets = _getAssets(source);
ids = assets.where((asset) => asset.storage == AssetState.merged).map((asset) => asset.localId!).toList();
} else {
ids = _getLocalIdsForSource(source);
}
try { try {
final deletedCount = await _service.deleteLocal(ids); final deletedCount = await _service.deleteLocal(ids);
return ActionResult(count: deletedCount, success: true); return ActionResult(count: deletedCount, success: true);

View file

@ -22,6 +22,7 @@ final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.
class AssetMediaRepository { class AssetMediaRepository {
final AssetApiRepository _assetApiRepository; final AssetApiRepository _assetApiRepository;
static final Logger _log = Logger("AssetMediaRepository"); static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository); const AssetMediaRepository(this._assetApiRepository);

View file

@ -22,12 +22,12 @@ class DeleteLocalOnlyDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
void onDeleteBackedUpOnly() { void onDeleteBackedUpOnly() {
context.pop(); context.pop(true);
onDeleteLocal(true); onDeleteLocal(true);
} }
void onForceDelete() { void onForceDelete() {
context.pop(); context.pop(false);
onDeleteLocal(false); onDeleteLocal(false);
} }
@ -36,26 +36,44 @@ class DeleteLocalOnlyDialog extends StatelessWidget {
title: const Text("delete_dialog_title").tr(), title: const Text("delete_dialog_title").tr(),
content: const Text("delete_dialog_alert_local_non_backed_up").tr(), content: const Text("delete_dialog_alert_local_non_backed_up").tr(),
actions: [ actions: [
TextButton( SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: () => context.pop(), onPressed: () => context.pop(),
child: Text( style: FilledButton.styleFrom(
"cancel", backgroundColor: context.colorScheme.surfaceDim,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold), foregroundColor: context.primaryColor,
).tr(),
), ),
TextButton( child: const Text("cancel", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: onDeleteBackedUpOnly, onPressed: onDeleteBackedUpOnly,
child: Text( style: FilledButton.styleFrom(
backgroundColor: context.colorScheme.errorContainer,
foregroundColor: context.colorScheme.onErrorContainer,
),
child: const Text(
"delete_local_dialog_ok_backed_up_only", "delete_local_dialog_ok_backed_up_only",
style: TextStyle(color: context.colorScheme.tertiary, fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
).tr(), ).tr(),
), ),
TextButton( ),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: onForceDelete, onPressed: onForceDelete,
child: Text( style: FilledButton.styleFrom(backgroundColor: Colors.red[400], foregroundColor: Colors.white),
"delete_local_dialog_ok_force", child: const Text("delete_local_dialog_ok_force", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
style: TextStyle(color: Colors.red[400], fontWeight: FontWeight.bold), ),
).tr(),
), ),
], ],
); );

View file

@ -3,7 +3,7 @@
"version": "0.0.1", "version": "0.0.1",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"private": true, "private": true,
"packageManager": "pnpm@10.18.0+sha512.e804f889f1cecc40d572db084eec3e4881739f8dec69c0ff10d2d1beff9a4e309383ba27b5b750059d7f4c149535b6cd0d2cb1ed3aeb739239a4284a68f40cfa", "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"engines": { "engines": {
"pnpm": ">=10.0.0" "pnpm": ">=10.0.0"
} }

880
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -44,14 +44,14 @@
"@nestjs/websockets": "^11.0.4", "@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.205.0", "@opentelemetry/exporter-prometheus": "^0.206.0",
"@opentelemetry/instrumentation-http": "^0.205.0", "@opentelemetry/instrumentation-http": "^0.206.0",
"@opentelemetry/instrumentation-ioredis": "^0.53.0", "@opentelemetry/instrumentation-ioredis": "^0.54.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.52.0", "@opentelemetry/instrumentation-nestjs-core": "^0.53.0",
"@opentelemetry/instrumentation-pg": "^0.58.0", "@opentelemetry/instrumentation-pg": "^0.59.0",
"@opentelemetry/resources": "^2.0.1", "@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.205.0", "@opentelemetry/sdk-node": "^0.206.0",
"@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0", "@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2", "@react-email/render": "^1.1.2",
@ -78,7 +78,7 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"kysely": "0.28.2", "kysely": "0.28.2",
"kysely-postgres-js": "^2.0.0", "kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.4.2", "luxon": "^3.4.2",
"mnemonist": "^0.40.3", "mnemonist": "^0.40.3",

View file

@ -89,10 +89,10 @@
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore; let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let element: HTMLElement | undefined = $state(); let scrollableElement: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state(); let timelineElement: HTMLElement | undefined = $state();
let showSkeleton = $state(true); let invisible = $state(true);
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport. // The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
// Note: There may be multiple months visible within the viewport at any given time. // Note: There may be multiple months visible within the viewport at any given time.
let viewportTopMonthScrollPercent = $state(0); let viewportTopMonthScrollPercent = $state(0);
@ -124,29 +124,22 @@
timelineManager.setLayoutOptions(layoutOptions); timelineManager.setLayoutOptions(layoutOptions);
}); });
const scrollTo = (top: number) => { $effect(() => {
if (element) { timelineManager.scrollableElement = scrollableElement;
element.scrollTo({ top }); });
}
};
const scrollTop = (top: number) => {
if (element) {
element.scrollTop = top;
}
};
const scrollToTop = () => { const scrollToTop = () => {
scrollTo(0); timelineManager.scrollTo(0);
}; };
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId); const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
const assetIsVisible = (assetTop: number): boolean => { const assetIsVisible = (assetTop: number): boolean => {
if (!element) { if (!scrollableElement) {
return false; return false;
} }
const { clientHeight, scrollTop } = element; const { clientHeight, scrollTop } = scrollableElement;
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight; return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
}; };
@ -163,8 +156,7 @@
return true; return true;
} }
scrollTo(height); timelineManager.scrollTo(height);
updateSlidingWindow();
return true; return true;
}; };
@ -174,8 +166,7 @@
return false; return false;
} }
const height = getAssetHeight(asset.id, monthGroup); const height = getAssetHeight(asset.id, monthGroup);
scrollTo(height); timelineManager.scrollTo(height);
updateSlidingWindow();
return true; return true;
}; };
@ -189,7 +180,7 @@
// if the asset is not found, scroll to the top // if the asset is not found, scroll to the top
scrollToTop(); scrollToTop();
} }
showSkeleton = false; invisible = false;
}; };
beforeNavigate(() => (timelineManager.suspendTransitions = true)); beforeNavigate(() => (timelineManager.suspendTransitions = true));
@ -216,7 +207,7 @@
} else { } else {
scrollToTop(); scrollToTop();
} }
showSkeleton = false; invisible = false;
}, 500); }, 500);
} }
}; };
@ -230,13 +221,12 @@
const updateIsScrolling = () => (timelineManager.scrolling = true); const updateIsScrolling = () => (timelineManager.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker // note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height); const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => { onMount(() => {
if (!enableRouting) { if (!enableRouting) {
showSkeleton = false; invisible = false;
} }
}); });
@ -246,11 +236,13 @@
}; };
const getMaxScroll = () => { const getMaxScroll = () => {
if (!element || !timelineElement) { if (!scrollableElement || !timelineElement) {
return 0; return 0;
} }
return ( return (
timelineManager.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight) timelineManager.topSectionHeight +
bottomSectionHeight +
(timelineElement.clientHeight - scrollableElement.clientHeight)
); );
}; };
@ -260,7 +252,7 @@
const delta = monthGroup.height * monthGroupScrollPercent; const delta = monthGroup.height * monthGroupScrollPercent;
const scrollToTop = (topOffset + delta) * maxScrollPercent; const scrollToTop = (topOffset + delta) * maxScrollPercent;
scrollTop(scrollToTop); timelineManager.scrollTo(scrollToTop);
}; };
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker // note: don't throttle, debounce, or otherwise make this function async - it causes flicker
@ -272,7 +264,7 @@
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
const offset = maxScroll * overallScrollPercent; const offset = maxScroll * overallScrollPercent;
scrollTop(offset); timelineManager.scrollTo(offset);
} else { } else {
const monthGroup = timelineManager.months.find( const monthGroup = timelineManager.months.find(
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month, ({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
@ -288,26 +280,26 @@
const handleTimelineScroll = () => { const handleTimelineScroll = () => {
isInLeadOutSection = false; isInLeadOutSection = false;
if (!element) { if (!scrollableElement) {
return; return;
} }
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) { if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll); timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
viewportTopMonth = undefined; viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0; viewportTopMonthScrollPercent = 0;
} else { } else {
let top = element.scrollTop; let top = scrollableElement.scrollTop;
if (top < timelineManager.topSectionHeight) { if (top < timelineManager.topSectionHeight) {
// in the lead-in area // in the lead-in area
viewportTopMonth = undefined; viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0; viewportTopMonthScrollPercent = 0;
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll); timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
return; return;
} }
@ -414,7 +406,7 @@
onSelect(asset); onSelect(asset);
if (singleSelect) { if (singleSelect) {
scrollTop(0); timelineManager.scrollTo(0);
return; return;
} }
@ -564,10 +556,10 @@
if (evt.key === 'ArrowUp') { if (evt.key === 'ArrowUp') {
amount = -amount; amount = -amount;
if (shiftKeyIsDown) { if (shiftKeyIsDown) {
element?.scrollBy({ top: amount, behavior: 'smooth' }); scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' });
} }
} else if (evt.key === 'ArrowDown') { } else if (evt.key === 'ArrowDown') {
element?.scrollBy({ top: amount, behavior: 'smooth' }); scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' });
} }
}} }}
/> />
@ -580,19 +572,19 @@
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'} style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
tabindex="-1" tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight} bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())} bind:clientWidth={timelineManager.viewportWidth}
bind:this={element} bind:this={scrollableElement}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
> >
<section <section
bind:this={timelineElement} bind:this={timelineElement}
id="virtual-timeline" id="virtual-timeline"
class:invisible={showSkeleton} class:invisible
style:height={timelineManager.timelineHeight + 'px'} style:height={timelineManager.timelineHeight + 'px'}
> >
<section <section
use:resizeObserver={topSectionResizeObserver} use:resizeObserver={topSectionResizeObserver}
class:invisible={showSkeleton} class:invisible
style:position="absolute" style:position="absolute"
style:left="0" style:left="0"
style:right="0" style:right="0"
@ -615,10 +607,7 @@
style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%" style:width="100%"
> >
<Skeleton <Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} />
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
title={monthGroup.monthGroupTitle}
/>
</div> </div>
{:else if display} {:else if display}
<div <div
@ -658,7 +647,7 @@
<Portal target="body"> <Portal target="body">
{#if $showAssetViewer} {#if $showAssetViewer}
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} /> <TimelineAssetViewer bind:invisible {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
{/if} {/if}
</Portal> </Portal>

View file

@ -13,7 +13,7 @@
interface Props { interface Props {
timelineManager: TimelineManager; timelineManager: TimelineManager;
showSkeleton: boolean; invisible: boolean;
withStacked?: boolean; withStacked?: boolean;
isShared?: boolean; isShared?: boolean;
album?: AlbumResponseDto | null; album?: AlbumResponseDto | null;
@ -30,7 +30,7 @@
let { let {
timelineManager, timelineManager,
showSkeleton = $bindable(false), invisible = $bindable(false),
removeAction, removeAction,
withStacked = false, withStacked = false,
isShared = false, isShared = false,
@ -81,7 +81,7 @@
const handleClose = async (asset: { id: string }) => { const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false); assetViewingStore.showAssetViewer(false);
showSkeleton = true; invisible = true;
$gridScrollTarget = { at: asset.id }; $gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }); await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
}; };

View file

@ -14,7 +14,7 @@
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util'; import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import type { Snippet } from 'svelte'; import { type Snippet } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';

View file

@ -2,24 +2,21 @@
interface Props { interface Props {
height: number; height: number;
title?: string; title?: string;
invisible?: boolean;
} }
let { height = 0, title }: Props = $props(); let { height = 0, title, invisible = false }: Props = $props();
</script> </script>
<div class="overflow-clip" style:height={height + 'px'}> <div class={['overflow-clip', { invisible }]} style:height={height + 'px'}>
{#if title} {#if title}
<div <div
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm" class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
> >
{title} {title}
</div> </div>
{/if} {/if}
<div <div class="animate-pulse h-full w-full" data-skeleton="true"></div>
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
style:width="calc(100% - 20px)"
data-skeleton="true"
></div>
</div> </div>
<style> <style>
@ -47,4 +44,7 @@
0s linear 0.1s forwards delayedVisibility, 0s linear 0.1s forwards delayedVisibility,
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
} }
.invisible [data-skeleton] {
visibility: hidden !important;
}
</style> </style>

View file

@ -4,7 +4,6 @@ import { getTimeBucket } from '@immich/sdk';
import type { MonthGroup } from '../month-group.svelte'; import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte'; import type { TimelineManager } from '../timeline-manager.svelte';
import type { TimelineManagerOptions } from '../types'; import type { TimelineManagerOptions } from '../types';
import { layoutMonthGroup } from './layout-support.svelte';
export async function loadFromTimeBuckets( export async function loadFromTimeBuckets(
timelineManager: TimelineManager, timelineManager: TimelineManager,
@ -55,6 +54,4 @@ export async function loadFromTimeBuckets(
)}`, )}`,
); );
} }
layoutMonthGroup(timelineManager, monthGroup);
} }

View file

@ -36,6 +36,7 @@ export class MonthGroup {
#initialCount: number = 0; #initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc; #sortOrder: AssetOrder = AssetOrder.Desc;
percent: number = $state(0);
assetsCount: number = $derived( assetsCount: number = $derived(
this.isLoaded this.isLoaded
@ -241,7 +242,6 @@ export class MonthGroup {
if (this.#height === height) { if (this.#height === height) {
return; return;
} }
let needsIntersectionUpdate = false;
const timelineManager = this.timelineManager; const timelineManager = this.timelineManager;
const index = timelineManager.months.indexOf(this); const index = timelineManager.months.indexOf(this);
const heightDelta = height - this.#height; const heightDelta = height - this.#height;
@ -261,11 +261,21 @@ export class MonthGroup {
const newTop = monthGroup.#top + heightDelta; const newTop = monthGroup.#top + heightDelta;
if (monthGroup.#top !== newTop) { if (monthGroup.#top !== newTop) {
monthGroup.#top = newTop; monthGroup.#top = newTop;
needsIntersectionUpdate = true;
} }
} }
if (needsIntersectionUpdate) { if (!timelineManager.viewportTopMonthIntersection) {
timelineManager.updateIntersections(); return;
}
const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection;
const currentIndex = month ? timelineManager.months.indexOf(month) : -1;
if (!month || currentIndex <= 0 || index > currentIndex) {
return;
}
if (index < currentIndex || monthBottomViewportRatio < 1) {
timelineManager.scrollBy(heightDelta);
} else if (index === currentIndex) {
const scrollTo = this.top + height * viewportTopRatioInMonth;
timelineManager.scrollTo(scrollTo);
} }
} }

View file

@ -4,6 +4,7 @@ import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util'; import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { tick } from 'svelte';
import { TimelineManager } from './timeline-manager.svelte'; import { TimelineManager } from './timeline-manager.svelte';
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
@ -64,11 +65,12 @@ describe('TimelineManager', () => {
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
await timelineManager.updateViewport({ width: 1588, height: 1000 }); await timelineManager.updateViewport({ width: 1588, height: 1000 });
await tick();
}); });
it('should load months in viewport', () => { it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1); expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(3); expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
}); });
it('calculates month height', () => { it('calculates month height', () => {
@ -82,13 +84,13 @@ describe('TimelineManager', () => {
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }), expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }), expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
expect.objectContaining({ year: 2024, month: 1, height: 48 }), expect.objectContaining({ year: 2024, month: 1, height: 286 }),
]), ]),
); );
}); });
it('calculates timeline height', () => { it('calculates timeline height', () => {
expect(timelineManager.timelineHeight).toBe(12_209.5); expect(timelineManager.timelineHeight).toBe(12_447.5);
}); });
}); });

View file

@ -5,7 +5,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { debounce, isEqual } from 'lodash-es'; import { clamp, debounce, isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
@ -37,6 +37,13 @@ import type {
Viewport, Viewport,
} from './types'; } from './types';
type ViewportTopMonthIntersection = {
month: MonthGroup | undefined;
// Where viewport top intersects month (0 = month top, 1 = month bottom)
viewportTopRatioInMonth: number;
// Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom)
monthBottomViewportRatio: number;
};
export class TimelineManager { export class TimelineManager {
isInitialized = $state(false); isInitialized = $state(false);
months: MonthGroup[] = $state([]); months: MonthGroup[] = $state([]);
@ -49,6 +56,8 @@ export class TimelineManager {
scrubberMonths: ScrubberMonth[] = $state([]); scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0); scrubberTimelineHeight: number = $state(0);
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
visibleWindow = $derived.by(() => ({ visibleWindow = $derived.by(() => ({
top: this.#scrollTop, top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight, bottom: this.#scrollTop + this.viewportHeight,
@ -85,6 +94,8 @@ export class TimelineManager {
#suspendTransitions = $state(false); #suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000); #resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000); #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
#updatingIntersections = false;
#scrollableElement: HTMLElement | undefined = $state();
constructor() {} constructor() {}
@ -98,6 +109,20 @@ export class TimelineManager {
} }
} }
set scrollableElement(element: HTMLElement | undefined) {
this.#scrollableElement = element;
}
scrollTo(top: number) {
this.#scrollableElement?.scrollTo({ top });
this.updateSlidingWindow();
}
scrollBy(y: number) {
this.#scrollableElement?.scrollBy(0, y);
this.updateSlidingWindow();
}
#setHeaderHeight(value: number) { #setHeaderHeight(value: number) {
if (this.#headerHeight == value) { if (this.#headerHeight == value) {
return false; return false;
@ -161,7 +186,8 @@ export class TimelineManager {
const changed = value !== this.#viewportWidth; const changed = value !== this.#viewportWidth;
this.#viewportWidth = value; this.#viewportWidth = value;
this.suspendTransitions = true; this.suspendTransitions = true;
void this.#updateViewportGeometry(changed); this.#updateViewportGeometry(changed);
this.updateSlidingWindow();
} }
get viewportWidth() { get viewportWidth() {
@ -223,20 +249,52 @@ export class TimelineManager {
this.#websocketSupport = undefined; this.#websocketSupport = undefined;
} }
updateSlidingWindow(scrollTop: number) { updateSlidingWindow() {
const scrollTop = this.#scrollableElement?.scrollTop ?? 0;
if (this.#scrollTop !== scrollTop) { if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop; this.#scrollTop = scrollTop;
this.updateIntersections(); this.updateIntersections();
} }
} }
#calculateMonthBottomViewportRatio(month: MonthGroup | undefined) {
if (!month) {
return 0;
}
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
const bottomOfMonth = month.top + month.height;
const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top;
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
}
#calculateVewportTopRatioInMonth(month: MonthGroup | undefined) {
if (!month) {
return 0;
}
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
}
updateIntersections() { updateIntersections() {
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return; return;
} }
this.#updatingIntersections = true;
for (const month of this.months) { for (const month of this.months) {
updateIntersectionMonthGroup(this, month); updateIntersectionMonthGroup(this, month);
} }
const month = this.months.find((month) => month.actuallyIntersecting);
const viewportTopRatioInMonth = this.#calculateVewportTopRatioInMonth(month);
const monthBottomViewportRatio = this.#calculateMonthBottomViewportRatio(month);
this.viewportTopMonthIntersection = {
month,
monthBottomViewportRatio,
viewportTopRatioInMonth,
};
this.#updatingIntersections = false;
} }
clearDeferredLayout(month: MonthGroup) { clearDeferredLayout(month: MonthGroup) {
@ -368,7 +426,8 @@ export class TimelineManager {
await loadFromTimeBuckets(this, monthGroup, this.#options, signal); await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
}, cancelable); }, cancelable);
if (executionStatus === 'LOADED') { if (executionStatus === 'LOADED') {
updateIntersectionMonthGroup(this, monthGroup); updateGeometry(this, monthGroup, { invalidateHeight: false });
this.updateIntersections();
} }
} }