Compare commits

..

1 Commits

Author SHA1 Message Date
bo0tzz
f163cc07f9 fix: force docs-build on deployment changes 2025-10-13 17:44:17 +02:00
39 changed files with 624 additions and 834 deletions

View File

@@ -31,6 +31,8 @@ jobs:
- 'open-api/immich-openapi-specs.json'
force-filters: |
- '.github/workflows/docs-build.yml'
- '.github/workflows/docs-deploy.yml'
- 'deployment/**'
force-events: 'release'
force-branches: 'main'

View File

@@ -41,18 +41,10 @@ jobs:
run:
working-directory: ./mobile
steps:
- name: Generate token
id: token
uses: immich-app/devtools/actions/create-workflow-token@feat/create-workflow-token-action
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
@@ -66,7 +58,7 @@ jobs:
- name: Install DCM
uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1
with:
github-token: ${{ steps.token.outputs.token }}
github-token: ${{ secrets.GITHUB_TOKEN }}
version: auto
working-directory: ./mobile

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -131,13 +131,10 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -250,7 +247,6 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
B231F52D2E93A44A00BC45D1 /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
@@ -335,7 +331,6 @@
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
);
name = Runner;
@@ -526,14 +521,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -562,14 +553,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
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)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine)
AppDelegate.registerPlugins(binaryMessenger: controller.binaryMessenger)
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
BackgroundServicePlugin.registerBackgroundProcessing()
@@ -51,13 +51,9 @@ import UIKit
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
public static func registerPlugins(with engine: FlutterEngine) {
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
}
public static func cancelPlugins(with engine: FlutterEngine) {
(engine.valuePublished(byPlugin: NativeSyncApiImpl.name) as? NativeSyncApiImpl)?.detachFromEngine()
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
}
}

View File

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

View File

@@ -1,17 +0,0 @@
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,25 +17,13 @@ struct AssetWrapper: Hashable, Equatable {
}
}
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()
}
class NativeSyncApiImpl: NativeSyncApi {
private let defaults: UserDefaults
private let changeTokenKey = "immich:changeToken"
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
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 hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
@@ -284,7 +272,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
if Task.isCancelled {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
return completion(Self.hashCancelled)
}
await withTaskGroup(of: HashResult?.self) { taskGroup in
@@ -292,7 +280,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
results.reserveCapacity(assets.count)
for asset in assets {
if Task.isCancelled {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
return completion(Self.hashCancelled)
}
taskGroup.addTask {
guard let self = self else { return nil }
@@ -302,7 +290,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
for await result in taskGroup {
guard let result = result else {
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
return completion(Self.hashCancelled)
}
results.append(result)
}
@@ -311,7 +299,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
}
return self?.completeWhenActive(for: completion, with: .success(results))
completion(.success(results))
}
}
}

View File

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

View File

@@ -2,12 +2,12 @@ part of 'base_asset.model.dart';
class LocalAsset extends BaseAsset {
final String id;
final String? remoteAssetId;
final String? remoteId;
final int orientation;
const LocalAsset({
required this.id,
String? remoteId,
this.remoteId,
required super.name,
super.checksum,
required super.type,
@@ -19,13 +19,7 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false,
super.livePhotoVideoId,
this.orientation = 0,
}) : remoteAssetId = remoteId;
@override
String? get localId => id;
@override
String? get remoteId => remoteAssetId;
});
@override
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
class RemoteAsset extends BaseAsset {
final String id;
final String? localAssetId;
final String? localId;
final String? thumbHash;
final AssetVisibility visibility;
final String ownerId;
@@ -13,7 +13,7 @@ class RemoteAsset extends BaseAsset {
const RemoteAsset({
required this.id,
String? localId,
this.localId,
required super.name,
required this.ownerId,
required super.checksum,
@@ -28,13 +28,7 @@ class RemoteAsset extends BaseAsset {
this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId,
this.stackId,
}) : localAssetId = localId;
@override
String? get localId => localAssetId;
@override
String? get remoteId => id;
});
@override
AssetState get storage => localId == null ? AssetState.remote : AssetState.merged;

View File

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

View File

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

View File

@@ -221,15 +221,6 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.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) {
final query = _db.remoteAlbumAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),

View File

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

View File

@@ -8,7 +8,6 @@ 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/providers/infrastructure/action.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';
/// This delete action has the following behavior:
@@ -23,17 +22,7 @@ class DeleteLocalActionButton extends ConsumerWidget {
return;
}
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);
final result = await ref.read(actionProvider.notifier).deleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,6 @@ 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/server_info.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';
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
@@ -54,7 +53,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
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 {
final selectedAssets = multiselect.selectedAssets;
@@ -95,35 +93,28 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
],
const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(source: ActionSource.timeline),
],
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
slivers: ownsAlbum
? [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
]
: null,
);
}
}

View File

@@ -260,15 +260,8 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> deleteLocal(ActionSource source, bool backedUpOnly) async {
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);
}
Future<ActionResult> deleteLocal(ActionSource source) async {
final ids = _getLocalIdsForSource(source);
try {
final deletedCount = await _service.deleteLocal(ids);
return ActionResult(count: deletedCount, success: true);

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
"version": "0.0.1",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"packageManager": "pnpm@10.18.0+sha512.e804f889f1cecc40d572db084eec3e4881739f8dec69c0ff10d2d1beff9a4e309383ba27b5b750059d7f4c149535b6cd0d2cb1ed3aeb739239a4284a68f40cfa",
"engines": {
"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",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.206.0",
"@opentelemetry/instrumentation-http": "^0.206.0",
"@opentelemetry/instrumentation-ioredis": "^0.54.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.53.0",
"@opentelemetry/instrumentation-pg": "^0.59.0",
"@opentelemetry/exporter-prometheus": "^0.205.0",
"@opentelemetry/instrumentation-http": "^0.205.0",
"@opentelemetry/instrumentation-ioredis": "^0.53.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.52.0",
"@opentelemetry/instrumentation-pg": "^0.58.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.206.0",
"@opentelemetry/sdk-node": "^0.205.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2",
@@ -78,7 +78,7 @@
"ioredis": "^5.3.2",
"js-yaml": "^4.1.0",
"kysely": "0.28.2",
"kysely-postgres-js": "^3.0.0",
"kysely-postgres-js": "^2.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
"mnemonist": "^0.40.3",

View File

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

View File

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

View File

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

View File

@@ -2,21 +2,24 @@
interface Props {
height: number;
title?: string;
invisible?: boolean;
}
let { height = 0, title, invisible = false }: Props = $props();
let { height = 0, title }: Props = $props();
</script>
<div class={['overflow-clip', { invisible }]} style:height={height + 'px'}>
<div class="overflow-clip" style:height={height + 'px'}>
{#if title}
<div
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"
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"
>
{title}
</div>
{/if}
<div class="animate-pulse h-full w-full" data-skeleton="true"></div>
<div
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
style:width="calc(100% - 20px)"
data-skeleton="true"
></div>
</div>
<style>
@@ -44,7 +47,4 @@
0s linear 0.1s forwards delayedVisibility,
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.invisible [data-skeleton] {
visibility: hidden !important;
}
</style>

View File

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

View File

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

View File

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

View File

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