mirror of
https://github.com/immich-app/immich.git
synced 2026-06-30 10:07:09 -07:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58d1373a04 | |||
| 0b68e1376a | |||
| 7cbd9eada9 | |||
| 4b05d02769 | |||
| 18d0991b61 | |||
| fa29dc08f9 | |||
| fdcd6c7671 | |||
| 44fdaa47d3 | |||
| 604d100bb8 | |||
| ae022fd90a | |||
| afa433b3ad | |||
| 7cdc252384 | |||
| 3c43b7240b | |||
| b966940264 | |||
| 72cca30272 | |||
| bcd01a2464 | |||
| a5d8415c5f | |||
| 74e9ec4872 | |||
| 411487dfd7 | |||
| 524b191ccc | |||
| 075f7d507b | |||
| c4df4d7852 | |||
| 0eaa2c3419 | |||
| 554e7b28a2 | |||
| 167aad7ac2 |
@@ -182,18 +182,6 @@ class TimelineService {
|
||||
return _buffer.slice(start, start + count);
|
||||
}
|
||||
|
||||
/// Reads a range without disturbing the buffer; queries the source if it isn't resident.
|
||||
Future<List<BaseAsset>> getAssetsRange(int index, int count) async {
|
||||
if (index < 0 || count <= 0 || index >= _totalAssets) {
|
||||
return const [];
|
||||
}
|
||||
final clamped = math.min(count, _totalAssets - index);
|
||||
if (hasRange(index, clamped)) {
|
||||
return getAssets(index, clamped);
|
||||
}
|
||||
return _assetSource(index, clamped);
|
||||
}
|
||||
|
||||
// Preload assets around the given index for asset viewer
|
||||
Future<void> preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
||||
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
// Tracks the [anchor..current] range selected by a drag. The in-buffer part of
|
||||
// each tick is selected synchronously so it follows the finger without racing;
|
||||
// the rare beyond-buffer part is read async (applied only if still in range) and
|
||||
// [end] reads whatever is still missing so the final range always lands.
|
||||
class DragSelectionController {
|
||||
DragSelectionController({required this.getAssetSafe, required this.getAssetsRange, required this.onChange});
|
||||
|
||||
final BaseAsset? Function(int index) getAssetSafe;
|
||||
final Future<List<BaseAsset>> Function(int index, int count) getAssetsRange;
|
||||
final void Function(Set<BaseAsset> select, Set<BaseAsset> deselect) onChange;
|
||||
|
||||
final HashMap<int, BaseAsset> _selected = HashMap();
|
||||
// Indices the buffer didn't hold yet (the edge outran the async buffer-load on a
|
||||
// fast scroll); read in by _extendPending and guaranteed by end().
|
||||
final Set<int> _pending = {};
|
||||
int? _anchor;
|
||||
int? _lo;
|
||||
int? _hi;
|
||||
bool _disposed = false;
|
||||
|
||||
// Call before starting a new drag so a previous drag's in-flight read can't
|
||||
// leak into the new selection.
|
||||
void dispose() => _disposed = true;
|
||||
|
||||
void _emit(Set<BaseAsset> select, Set<BaseAsset> deselect) {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
onChange(select, deselect);
|
||||
}
|
||||
|
||||
void start(int anchor) {
|
||||
_selected.clear();
|
||||
_pending.clear();
|
||||
_anchor = anchor;
|
||||
_lo = anchor;
|
||||
_hi = anchor;
|
||||
_select(anchor);
|
||||
_extendPending();
|
||||
}
|
||||
|
||||
void enter(int current) {
|
||||
final anchor = _anchor;
|
||||
if (anchor == null || _lo == null || _hi == null) {
|
||||
return;
|
||||
}
|
||||
final ns = current < anchor ? current : anchor;
|
||||
final ne = current < anchor ? anchor : current;
|
||||
final ps = _lo!;
|
||||
final pe = _hi!;
|
||||
if (ns == ps && ne == pe) {
|
||||
return;
|
||||
}
|
||||
|
||||
final toSelect = <BaseAsset>{};
|
||||
final toDeselect = <BaseAsset>{};
|
||||
|
||||
_forEach(ps, ns - 1, (k) => _removeIndex(k, toDeselect));
|
||||
_forEach(ne + 1, pe, (k) => _removeIndex(k, toDeselect));
|
||||
_forEach(ns, ps - 1, (k) => _addIndex(k, toSelect));
|
||||
_forEach(pe + 1, ne, (k) => _addIndex(k, toSelect));
|
||||
|
||||
_lo = ns;
|
||||
_hi = ne;
|
||||
|
||||
if (toSelect.isNotEmpty || toDeselect.isNotEmpty) {
|
||||
_emit(toSelect, toDeselect);
|
||||
}
|
||||
_extendPending();
|
||||
}
|
||||
|
||||
Future<void> end() async {
|
||||
final lo = _lo;
|
||||
final hi = _hi;
|
||||
if (lo == null || hi == null) {
|
||||
return;
|
||||
}
|
||||
final missing = <int>[];
|
||||
for (var k = lo; k <= hi; k++) {
|
||||
if (!_selected.containsKey(k)) {
|
||||
missing.add(k);
|
||||
}
|
||||
}
|
||||
if (missing.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final from = missing.first;
|
||||
final assets = await getAssetsRange(from, missing.last - from + 1);
|
||||
final missingSet = missing.toSet();
|
||||
final toSelect = <BaseAsset>{};
|
||||
for (var i = 0; i < assets.length; i++) {
|
||||
final idx = from + i;
|
||||
if (missingSet.contains(idx) && !_selected.containsKey(idx)) {
|
||||
_selected[idx] = assets[i];
|
||||
_pending.remove(idx);
|
||||
toSelect.add(assets[i]);
|
||||
}
|
||||
}
|
||||
if (toSelect.isNotEmpty) {
|
||||
_emit(toSelect, const {});
|
||||
}
|
||||
}
|
||||
|
||||
void _select(int index) {
|
||||
final asset = getAssetSafe(index);
|
||||
if (asset != null) {
|
||||
_selected[index] = asset;
|
||||
_pending.remove(index);
|
||||
_emit({asset}, const {});
|
||||
} else {
|
||||
_pending.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
void _addIndex(int index, Set<BaseAsset> toSelect) {
|
||||
if (_selected.containsKey(index)) {
|
||||
return;
|
||||
}
|
||||
final asset = getAssetSafe(index);
|
||||
if (asset != null) {
|
||||
_selected[index] = asset;
|
||||
_pending.remove(index);
|
||||
toSelect.add(asset);
|
||||
} else {
|
||||
_pending.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
void _removeIndex(int index, Set<BaseAsset> toDeselect) {
|
||||
_pending.remove(index);
|
||||
final asset = _selected.remove(index);
|
||||
if (asset != null) {
|
||||
toDeselect.add(asset);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _extendPending() async {
|
||||
if (_pending.isEmpty) {
|
||||
return;
|
||||
}
|
||||
var from = _pending.first;
|
||||
var to = _pending.first;
|
||||
for (final k in _pending) {
|
||||
if (k < from) {
|
||||
from = k;
|
||||
}
|
||||
if (k > to) {
|
||||
to = k;
|
||||
}
|
||||
}
|
||||
final assets = await getAssetsRange(from, to - from + 1);
|
||||
final toSelect = <BaseAsset>{};
|
||||
for (var i = 0; i < assets.length; i++) {
|
||||
final idx = from + i;
|
||||
if (_pending.contains(idx) && _lo != null && idx >= _lo! && idx <= _hi!) {
|
||||
_selected[idx] = assets[i];
|
||||
toSelect.add(assets[i]);
|
||||
}
|
||||
}
|
||||
_pending.removeWhere((idx) => _selected.containsKey(idx));
|
||||
if (toSelect.isNotEmpty) {
|
||||
_emit(toSelect, const {});
|
||||
}
|
||||
}
|
||||
|
||||
void _forEach(int lo, int hi, void Function(int) fn) {
|
||||
for (var k = lo; k <= hi; k++) {
|
||||
fn(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -7,6 +8,7 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
@@ -18,7 +20,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/drag_selection_controller.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
@@ -28,27 +29,6 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||
|
||||
// First asset index of the row shown at [offset]. Pure for testing.
|
||||
@visibleForTesting
|
||||
int? assetIndexAtOffset(
|
||||
List<Segment> segments,
|
||||
double offset, {
|
||||
required int columnCount,
|
||||
required double maxScrollExtent,
|
||||
}) {
|
||||
final clamped = offset.clamp(0.0, maxScrollExtent);
|
||||
final segment = segments.findByOffset(clamped) ?? segments.lastOrNull;
|
||||
if (segment == null) {
|
||||
return null;
|
||||
}
|
||||
final rowIndex = segment.getMinChildIndexForScrollOffset(clamped);
|
||||
if (rowIndex > segment.firstIndex) {
|
||||
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
|
||||
return segment.firstAssetIndex + rowIndexInSegment * columnCount;
|
||||
}
|
||||
return segment.firstAssetIndex;
|
||||
}
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({
|
||||
super.key,
|
||||
@@ -160,10 +140,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
// Drag selection state
|
||||
static const _autoScrollStep = 175.0;
|
||||
static const _autoScrollDuration = Duration(milliseconds: 125);
|
||||
bool _dragging = false;
|
||||
DragSelectionController? _dragController;
|
||||
TimelineAssetIndex? _dragAnchorIndex;
|
||||
final Set<BaseAsset> _draggedAssets = HashSet();
|
||||
ScrollPhysics? _scrollPhysics;
|
||||
|
||||
int _perRow = 4;
|
||||
@@ -247,18 +226,26 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
|
||||
}
|
||||
|
||||
int? _getCurrentAssetIndex(List<Segment> segments) => _assetIndexAtOffset(segments, _scrollController.offset);
|
||||
|
||||
int? _assetIndexAtOffset(List<Segment> segments, double offset) => assetIndexAtOffset(
|
||||
segments,
|
||||
offset,
|
||||
columnCount: ref.read(timelineArgsProvider).columnCount,
|
||||
maxScrollExtent: _scrollController.position.maxScrollExtent,
|
||||
);
|
||||
int? _getCurrentAssetIndex(List<Segment> segments) {
|
||||
final currentOffset = _scrollController.offset.clamp(0.0, _scrollController.position.maxScrollExtent);
|
||||
final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull;
|
||||
int? targetAssetIndex;
|
||||
if (segment != null) {
|
||||
final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset);
|
||||
if (rowIndex > segment.firstIndex) {
|
||||
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
|
||||
final assetsPerRow = ref.read(timelineArgsProvider).columnCount;
|
||||
final assetIndexInSegment = rowIndexInSegment * assetsPerRow;
|
||||
targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment;
|
||||
} else {
|
||||
targetAssetIndex = segment.firstAssetIndex;
|
||||
}
|
||||
}
|
||||
return targetAssetIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dragController?.dispose();
|
||||
_scrollController.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
@@ -308,21 +295,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
|
||||
// Drag selection methods
|
||||
void _setDragStartIndex(TimelineAssetIndex index) {
|
||||
// Stop the old drag's controller so its in-flight read can't leak into this one.
|
||||
_dragController?.dispose();
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
_dragController = DragSelectionController(
|
||||
getAssetSafe: timelineService.getAssetSafe,
|
||||
getAssetsRange: timelineService.getAssetsRange,
|
||||
onChange: (select, deselect) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(multiSelectProvider.notifier).selectRange(select, deselect);
|
||||
},
|
||||
)..start(index.assetIndex);
|
||||
setState(() {
|
||||
_scrollPhysics = const ClampingScrollPhysics();
|
||||
_dragAnchorIndex = index;
|
||||
_dragging = true;
|
||||
});
|
||||
}
|
||||
@@ -338,12 +313,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
});
|
||||
setState(() {
|
||||
_dragging = false;
|
||||
_draggedAssets.clear();
|
||||
});
|
||||
// Apply the full final range even if a read is still in flight on lift.
|
||||
final finishing = _dragController?.end();
|
||||
if (finishing != null) {
|
||||
unawaited(finishing);
|
||||
}
|
||||
final timelineState = ref.read(timelineStateProvider.notifier);
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
timelineState.setScrolling(false);
|
||||
@@ -351,33 +322,42 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
|
||||
void _dragScroll(ScrollDirection direction) {
|
||||
final position = _scrollController.position;
|
||||
final step = direction == ScrollDirection.forward ? _autoScrollStep : -_autoScrollStep;
|
||||
final target = (_scrollController.offset + step).clamp(0.0, position.maxScrollExtent);
|
||||
_scrollController.animateTo(target, duration: _autoScrollDuration, curve: Curves.easeOut);
|
||||
|
||||
// A held finger emits no move events, so extend the selection to the asset
|
||||
// at the leading edge of the scroll instead.
|
||||
final controller = _dragController;
|
||||
if (controller == null) {
|
||||
return;
|
||||
}
|
||||
final segments = ref.read(timelineSegmentProvider).valueOrNull;
|
||||
if (segments == null) {
|
||||
return;
|
||||
}
|
||||
final edgeOffset = direction == ScrollDirection.forward ? target + position.viewportDimension : target;
|
||||
final edgeIndex = _assetIndexAtOffset(segments, edgeOffset);
|
||||
if (edgeIndex != null) {
|
||||
controller.enter(edgeIndex);
|
||||
}
|
||||
_scrollController.animateTo(
|
||||
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
|
||||
duration: const Duration(milliseconds: 125),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDragAssetEnter(TimelineAssetIndex index) {
|
||||
if (!_dragging) {
|
||||
if (_dragAnchorIndex == null || !_dragging) {
|
||||
return;
|
||||
}
|
||||
_dragController?.enter(index.assetIndex);
|
||||
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final dragAnchorIndex = _dragAnchorIndex!;
|
||||
|
||||
// Calculate the range of assets to select
|
||||
final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex);
|
||||
final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex);
|
||||
final count = endIndex - startIndex + 1;
|
||||
|
||||
// Load the assets in the range
|
||||
if (timelineService.hasRange(startIndex, count)) {
|
||||
final selectedAssets = timelineService.getAssets(startIndex, count);
|
||||
|
||||
// Clear previous drag selection and add new range
|
||||
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
|
||||
for (final asset in _draggedAssets) {
|
||||
multiSelectNotifier.deselectAsset(asset);
|
||||
}
|
||||
_draggedAssets.clear();
|
||||
|
||||
for (final asset in selectedAssets) {
|
||||
multiSelectNotifier.selectAsset(asset);
|
||||
_draggedAssets.add(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -97,15 +97,6 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Drops the previous drag range and adds the new one in a single update. The
|
||||
// full-set copy per drag tick is the accepted cost of immutable state.
|
||||
void selectRange(Set<BaseAsset> toSelect, Set<BaseAsset> toDeselect) {
|
||||
final selectedAssets = state.selectedAssets.toSet()
|
||||
..removeAll(toDeselect)
|
||||
..addAll(toSelect);
|
||||
state = state.copyWith(selectedAssets: selectedAssets);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const MultiSelectState(selectedAssets: {}, lockedSelectionAssets: {}, forceEnable: false);
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
|
||||
void main() {
|
||||
// Two day-segments, 4 columns, 8 assets each (2 rows). header 50, tile 100, no spacing.
|
||||
// A: header@[0,50) rows@50,150 assets 0..7 offset [0,250]
|
||||
// B: header@[250,300) rows@300,400 assets 8..15 offset [250,500]
|
||||
const columnCount = 4;
|
||||
const maxScrollExtent = 500.0;
|
||||
final segments = <Segment>[
|
||||
const FixedSegment(
|
||||
firstIndex: 0,
|
||||
lastIndex: 2,
|
||||
startOffset: 0,
|
||||
endOffset: 250,
|
||||
firstAssetIndex: 0,
|
||||
bucket: Bucket(assetCount: 8),
|
||||
tileHeight: 100,
|
||||
columnCount: columnCount,
|
||||
headerExtent: 50,
|
||||
spacing: 0,
|
||||
header: HeaderType.day,
|
||||
),
|
||||
const FixedSegment(
|
||||
firstIndex: 3,
|
||||
lastIndex: 5,
|
||||
startOffset: 250,
|
||||
endOffset: 500,
|
||||
firstAssetIndex: 8,
|
||||
bucket: Bucket(assetCount: 8),
|
||||
tileHeight: 100,
|
||||
columnCount: columnCount,
|
||||
headerExtent: 50,
|
||||
spacing: 0,
|
||||
header: HeaderType.day,
|
||||
),
|
||||
];
|
||||
|
||||
int? at(double offset) =>
|
||||
assetIndexAtOffset(segments, offset, columnCount: columnCount, maxScrollExtent: maxScrollExtent);
|
||||
|
||||
test('maps an offset to the first asset of the row shown there', () {
|
||||
expect(at(0), 0); // top of segment A
|
||||
expect(at(150), 4); // second row of A
|
||||
expect(at(350), 8); // first row of B
|
||||
expect(at(450), 12); // second row of B
|
||||
});
|
||||
|
||||
test('clamps offsets outside the scroll range', () {
|
||||
expect(at(-100), 0); // below the top -> first asset
|
||||
expect(at(9999), at(maxScrollExtent)); // past the end -> same as the max offset
|
||||
});
|
||||
|
||||
test('returns null for empty segments', () {
|
||||
expect(assetIndexAtOffset(const [], 100, columnCount: columnCount, maxScrollExtent: maxScrollExtent), isNull);
|
||||
});
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/drag_selection_controller.dart';
|
||||
|
||||
import '../../../factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
const total = 50;
|
||||
late List<BaseAsset> all;
|
||||
late Set<int> inBuffer; // indices getAssetSafe resolves synchronously
|
||||
late List<Completer<List<BaseAsset>>> reads; // pending async reads, completed manually
|
||||
late List<({int from, int count})> readArgs;
|
||||
late Set<BaseAsset> selected;
|
||||
late DragSelectionController sut;
|
||||
|
||||
Set<BaseAsset> range(int lo, int hi) => {for (var i = lo; i <= hi; i++) all[i]};
|
||||
|
||||
void completeRead(int i) {
|
||||
final a = readArgs[i];
|
||||
reads[i].complete([for (var k = a.from; k < a.from + a.count && k < total; k++) all[k]]);
|
||||
}
|
||||
|
||||
Future<void> settle() => Future(() {});
|
||||
|
||||
setUp(() {
|
||||
all = List.generate(total, (i) => RemoteAssetFactory.create(id: 'a${i.toString().padLeft(3, '0')}'));
|
||||
inBuffer = {for (var i = 0; i < total; i++) i}; // default: everything buffered (sync)
|
||||
reads = [];
|
||||
readArgs = [];
|
||||
selected = {};
|
||||
sut = DragSelectionController(
|
||||
getAssetSafe: (i) => inBuffer.contains(i) ? all[i] : null,
|
||||
getAssetsRange: (from, count) {
|
||||
final c = Completer<List<BaseAsset>>();
|
||||
reads.add(c);
|
||||
readArgs.add((from: from, count: count));
|
||||
return c.future;
|
||||
},
|
||||
onChange: (select, deselect) {
|
||||
selected
|
||||
..addAll(select)
|
||||
..removeAll(deselect);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('live selection (all in buffer, synchronous)', () {
|
||||
test('dragging down selects the whole range as it grows', () {
|
||||
sut.start(2);
|
||||
sut.enter(6);
|
||||
sut.enter(12);
|
||||
expect(selected, range(2, 12));
|
||||
expect(reads, isEmpty, reason: 'everything buffered -> no async read');
|
||||
});
|
||||
|
||||
test('dragging back up toward the anchor deselects the shrunk tail', () {
|
||||
sut.start(2);
|
||||
sut.enter(12);
|
||||
expect(selected, range(2, 12));
|
||||
sut.enter(6); // reverse
|
||||
expect(selected, range(2, 6));
|
||||
sut.enter(3); // reverse more
|
||||
expect(selected, range(2, 3));
|
||||
});
|
||||
|
||||
test('dragging past the anchor flips the range', () {
|
||||
sut.start(10);
|
||||
sut.enter(14);
|
||||
expect(selected, range(10, 14));
|
||||
sut.enter(7); // crosses the anchor
|
||||
expect(selected, range(7, 10));
|
||||
});
|
||||
});
|
||||
|
||||
group('beyond-buffer (async)', () {
|
||||
setUp(() => inBuffer = {}); // nothing buffered -> every tile needs an async read
|
||||
|
||||
test('drag-end fills the full range even if every live read is still in flight', () async {
|
||||
sut.start(0);
|
||||
sut.enter(20);
|
||||
// simulate the real-rate race: none of the in-drag reads have completed
|
||||
expect(selected, isEmpty);
|
||||
|
||||
final ending = sut.end();
|
||||
// end() issues its own read for the missing range; complete it
|
||||
completeRead(reads.length - 1);
|
||||
await ending;
|
||||
|
||||
expect(selected, range(0, 20), reason: 'final range must always apply on drag-end');
|
||||
});
|
||||
|
||||
test('out-of-order live read completions never corrupt the selection', () async {
|
||||
sut.start(0); // issues read for [0,1]
|
||||
sut.enter(10); // issues read for [0,11]
|
||||
sut.enter(20); // issues read for [0,21]
|
||||
expect(reads.length, 3);
|
||||
|
||||
// complete newest first, then older ones (out of order)
|
||||
completeRead(2);
|
||||
await settle();
|
||||
completeRead(1);
|
||||
await settle();
|
||||
completeRead(0);
|
||||
await settle();
|
||||
|
||||
expect(selected, range(0, 20));
|
||||
|
||||
final ending = sut.end();
|
||||
await ending; // nothing missing -> no extra read
|
||||
expect(selected, range(0, 20));
|
||||
});
|
||||
|
||||
test('a disposed controller never emits when its in-flight read resolves', () async {
|
||||
sut.start(0); // read [0,1] (pending, in flight)
|
||||
sut.enter(20); // read [0,21] (pending, in flight)
|
||||
expect(selected, isEmpty);
|
||||
|
||||
// a new drag starts -> the old controller is disposed
|
||||
final ending = sut.end(); // issues end()'s fill read
|
||||
sut.dispose();
|
||||
|
||||
// every in-flight read for the old controller now resolves
|
||||
for (var i = 0; i < reads.length; i++) {
|
||||
if (!reads[i].isCompleted) {
|
||||
completeRead(i);
|
||||
}
|
||||
}
|
||||
await ending;
|
||||
await settle();
|
||||
|
||||
expect(selected, isEmpty, reason: 'a disposed controller must not leak into the new selection');
|
||||
});
|
||||
|
||||
test('a late read for tiles dragged back out of range is ignored', () async {
|
||||
sut.start(0); // read [0,1]
|
||||
sut.enter(20); // read [0,21]
|
||||
sut.enter(5); // shrink back; read [0,6]
|
||||
// complete the stale wide read AFTER the shrink
|
||||
completeRead(1); // [0,21]
|
||||
await settle();
|
||||
// indices 6..20 left the range -> must not be selected
|
||||
expect(selected.intersection(range(6, 20)), isEmpty);
|
||||
|
||||
final ending = sut.end();
|
||||
// complete any read end() issued for the (now smaller) missing range
|
||||
for (var i = 0; i < reads.length; i++) {
|
||||
if (!reads[i].isCompleted) {
|
||||
completeRead(i);
|
||||
}
|
||||
}
|
||||
await ending;
|
||||
expect(selected, range(0, 5));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
|
||||
import '../factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
// total must exceed the sliding buffer so it cannot hold the whole library at once
|
||||
const total = 2000;
|
||||
late List<BaseAsset> all;
|
||||
late TimelineService sut;
|
||||
|
||||
TimelineService buildService() {
|
||||
all = List.generate(total, (i) => RemoteAssetFactory.create(id: 'a${i.toString().padLeft(5, '0')}'));
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async {
|
||||
final end = (index + count) > total ? total : index + count;
|
||||
return all.sublist(index, end);
|
||||
},
|
||||
bucketSource: () => Stream.value([const Bucket(assetCount: total)]),
|
||||
origin: TimelineOrigin.main,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> settle() => Future.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
setUp(() async {
|
||||
sut = buildService();
|
||||
await settle(); // let the bucket subscription load the first batch and set totalAssets
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await sut.dispose();
|
||||
});
|
||||
|
||||
test('buffer holds the first batch but not the whole library', () {
|
||||
expect(sut.totalAssets, total);
|
||||
expect(sut.hasRange(0, kTimelineAssetLoadBatchSize), isTrue);
|
||||
expect(sut.hasRange(0, total), isFalse);
|
||||
});
|
||||
|
||||
// #27118 / #20855 mechanism: drag-selecting from a low anchor while the grid
|
||||
// auto-scrolls down slides the buffer forward to follow the finger. once the
|
||||
// buffer offset passes the anchor, hasRange(anchor, ...) is false and
|
||||
// _handleDragAssetEnter silently stops extending the selection.
|
||||
test('anchor drops out of the buffer after the grid scrolls down', () async {
|
||||
const anchor = 5;
|
||||
expect(sut.hasRange(anchor, 100), isTrue);
|
||||
|
||||
// the grid loads a far-down range as it auto-scrolls during the drag
|
||||
await sut.loadAssets(1500, 1);
|
||||
|
||||
const current = 1500;
|
||||
const count = current - anchor + 1;
|
||||
expect(sut.hasRange(anchor, 1), isFalse, reason: 'anchor is now below the buffer offset');
|
||||
expect(sut.hasRange(anchor, count), isFalse, reason: 'the full drag range is no longer resident');
|
||||
expect(() => sut.getAssets(anchor, count), throwsRangeError);
|
||||
});
|
||||
|
||||
// the fix: getAssetsRange returns the whole drag range regardless of the
|
||||
// buffer position, so the selection keeps extending while scrolling.
|
||||
group('getAssetsRange', () {
|
||||
test('returns a buffered range', () async {
|
||||
final assets = await sut.getAssetsRange(0, 50);
|
||||
expect(assets.length, 50);
|
||||
expect(assets.first, all[0]);
|
||||
expect(assets.last, all[49]);
|
||||
});
|
||||
|
||||
test('returns a range wider than the buffer', () async {
|
||||
final assets = await sut.getAssetsRange(0, total);
|
||||
expect(assets.length, total);
|
||||
expect(assets.first, all[0]);
|
||||
expect(assets.last, all[total - 1]);
|
||||
});
|
||||
|
||||
test('returns the anchor range after the buffer scrolled past the anchor', () async {
|
||||
const anchor = 5;
|
||||
await sut.loadAssets(1500, 1); // buffer slides forward, dropping the anchor
|
||||
expect(sut.hasRange(anchor, 1), isFalse);
|
||||
|
||||
const current = 1500;
|
||||
const count = current - anchor + 1;
|
||||
final assets = await sut.getAssetsRange(anchor, count);
|
||||
expect(assets.length, count);
|
||||
expect(assets.first, all[anchor]);
|
||||
expect(assets.last, all[current]);
|
||||
});
|
||||
|
||||
test('clamps a range that runs past the end and ignores invalid input', () async {
|
||||
final tail = await sut.getAssetsRange(total - 10, 100);
|
||||
expect(tail.length, 10);
|
||||
expect(await sut.getAssetsRange(-1, 10), isEmpty);
|
||||
expect(await sut.getAssetsRange(0, 0), isEmpty);
|
||||
expect(await sut.getAssetsRange(total, 10), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Generated
+10
-10
@@ -786,8 +786,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../packages/sdk
|
||||
'@immich/ui':
|
||||
specifier: ^0.81.1
|
||||
version: 0.81.1(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))
|
||||
specifier: ^0.83.0
|
||||
version: 0.83.0(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))
|
||||
'@mapbox/mapbox-gl-rtl-text':
|
||||
specifier: 0.4.0
|
||||
version: 0.4.0
|
||||
@@ -3250,8 +3250,8 @@ packages:
|
||||
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
||||
hasBin: true
|
||||
|
||||
'@immich/ui@0.81.1':
|
||||
resolution: {integrity: sha512-7g173hArs7OS5CfHUG+ZJVQp1iCvFZzBmm+f2uH1WChlFdcwty5DLilYrDBszWuPIvu8wTX2AXTneS/KYBCUxw==}
|
||||
'@immich/ui@0.83.0':
|
||||
resolution: {integrity: sha512-Xh3R3yhn8/Qyq8lGWIiVmturA+dHXtmW/h++AGb2UAwS/58qu7ssocO/HWIamaSpkgjPGotlWpPIpUJq1zh9FQ==}
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.13.0
|
||||
svelte: ^5.0.0
|
||||
@@ -16111,7 +16111,7 @@ snapshots:
|
||||
pg-connection-string: 2.13.0
|
||||
postgres: 3.4.9
|
||||
|
||||
'@immich/ui@0.81.1(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))':
|
||||
'@immich/ui@0.83.0(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.12.2
|
||||
'@mdi/js': 7.4.47
|
||||
@@ -21092,21 +21092,21 @@ snapshots:
|
||||
signal-exit: 3.0.7
|
||||
strip-final-newline: 2.0.0
|
||||
|
||||
exiftool-vendored.exe@13.59.0:
|
||||
optional: true
|
||||
exiftool-vendored.exe@13.59.0: {}
|
||||
|
||||
exiftool-vendored.pl@13.59.0: {}
|
||||
exiftool-vendored.pl@13.59.0:
|
||||
optional: true
|
||||
|
||||
exiftool-vendored@35.21.0:
|
||||
dependencies:
|
||||
'@photostructure/tz-lookup': 11.5.0
|
||||
'@types/luxon': 3.7.1
|
||||
batch-cluster: 17.3.1
|
||||
exiftool-vendored.pl: 13.59.0
|
||||
exiftool-vendored.exe: 13.59.0
|
||||
he: 1.2.0
|
||||
luxon: 3.7.2
|
||||
optionalDependencies:
|
||||
exiftool-vendored.exe: 13.59.0
|
||||
exiftool-vendored.pl: 13.59.0
|
||||
|
||||
expand-template@2.0.3:
|
||||
optional: true
|
||||
|
||||
+1
-1
@@ -66,4 +66,4 @@ injectWorkspacePackages: true
|
||||
shamefullyHoist: false
|
||||
verifyDepsBeforeRun: install
|
||||
minimumReleaseAgeExclude:
|
||||
- '@immich/ui@0.81.1'
|
||||
- '@immich/ui@0.83.0'
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.81.1",
|
||||
"@immich/ui": "^0.83.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { faceManager } from '$lib/stores/face.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
@@ -23,6 +24,7 @@
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
@@ -149,6 +151,15 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onAssetsUndoArchive = async (assets: TimelineAsset[]) => {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const restoredAsset = assets[0];
|
||||
await assetViewerManager.setAssetId(restoredAsset.id);
|
||||
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
syncAssetViewerOpenClass(true);
|
||||
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
@@ -475,7 +486,7 @@
|
||||
</script>
|
||||
|
||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
|
||||
<OnEvents {onAssetUpdate} />
|
||||
<OnEvents {onAssetUpdate} {onAssetsUndoArchive} />
|
||||
|
||||
<svelte:document
|
||||
bind:fullscreenElement
|
||||
|
||||
@@ -309,12 +309,12 @@
|
||||
untrack(() => map?.jumpTo({ center, zoom }));
|
||||
});
|
||||
|
||||
const onAssetsDelete = async () => {
|
||||
const onAssetsChanged = async () => {
|
||||
mapMarkers = await loadMapMarkers();
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onAssetsDelete} />
|
||||
<OnEvents onAssetsDelete={onAssetsChanged} onAssetsArchive={onAssetsChanged} onAssetsUnarchive={onAssetsChanged} />
|
||||
|
||||
<!-- We handle style loading ourselves so we set style blank here -->
|
||||
<MapLibre
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
UserAdminResponseDto,
|
||||
WorkflowResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||
|
||||
@@ -35,6 +36,8 @@ export type Events = {
|
||||
|
||||
AssetUpdate: [AssetResponseDto];
|
||||
AssetsArchive: [string[]];
|
||||
AssetsUnarchive: [TimelineAsset[]];
|
||||
AssetsUndoArchive: [TimelineAsset[]];
|
||||
AssetsDelete: [string[]];
|
||||
AssetEditsApplied: [string];
|
||||
AssetsTag: [string[]];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetOrder, AssetOrderBy, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
|
||||
import { clamp, isEqual } from 'lodash-es';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
@@ -114,7 +114,15 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
|
||||
this.#unsubscribes.push(
|
||||
eventManager.on({
|
||||
AssetUpdate: (asset: AssetResponseDto) => this.#updateAssets([toTimelineAsset(asset)]),
|
||||
AssetUpdate: (asset: AssetResponseDto) => {
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
if (this.#options.albumId || this.#options.personId) {
|
||||
this.#updateAssets([timelineAsset]);
|
||||
} else {
|
||||
this.upsertAssets([timelineAsset]);
|
||||
}
|
||||
},
|
||||
AssetsUnarchive: (assets) => this.upsertAssets(assets),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@ import {
|
||||
type StackResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { toastManager, type ToastShow } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { downloadBlob, downloadRequest, withError } from '$lib/utils';
|
||||
@@ -31,6 +32,7 @@ import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { asQueryString } from '$lib/utils/shared-links';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { handleError } from './handle-error';
|
||||
|
||||
export const tagAssets = async ({
|
||||
@@ -400,7 +402,12 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
|
||||
});
|
||||
|
||||
asset.isArchived = data.isArchived;
|
||||
toastManager.primary(asset.isArchived ? $t(`added_to_archive`) : $t(`removed_from_archive`));
|
||||
if (asset.isArchived) {
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
showUndoArchiveToast($t('added_to_archive'), [timelineAsset]);
|
||||
} else {
|
||||
toastManager.primary($t('removed_from_archive'));
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_archive', { values: { archived: asset.isArchived } }));
|
||||
}
|
||||
@@ -408,7 +415,46 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
|
||||
return asset;
|
||||
};
|
||||
|
||||
export const archiveAssets = async (assets: { id: string }[], visibility: AssetVisibility) => {
|
||||
const showUndoArchiveToast = (description: string, assets: TimelineAsset[]) => {
|
||||
const $t = get(t);
|
||||
const toast: ToastShow & { onClose?: () => void } = {
|
||||
description,
|
||||
button: (close) => ({
|
||||
label: $t('undo'),
|
||||
onclick: () => {
|
||||
close();
|
||||
void undoArchiveAssets(assets);
|
||||
},
|
||||
}),
|
||||
};
|
||||
toastManager.primary(toast);
|
||||
};
|
||||
|
||||
const undoArchiveAssets = async (assets: TimelineAsset[]) => {
|
||||
const $t = get(t);
|
||||
try {
|
||||
const ids = assets.map((a) => a.id);
|
||||
if (ids.length > 0) {
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
asset.visibility = AssetVisibility.Timeline;
|
||||
}
|
||||
eventManager.emit('AssetsUnarchive', assets);
|
||||
eventManager.emit('AssetsUndoArchive', assets);
|
||||
toastManager.success($t('unarchived_count', { values: { count: assets.length } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_archive_unarchive', { values: { archived: false } }));
|
||||
}
|
||||
};
|
||||
|
||||
export const archiveAssets = async (assets: TimelineAsset[], visibility: AssetVisibility) => {
|
||||
const ids = assets.map(({ id }) => id);
|
||||
const $t = get(t);
|
||||
|
||||
@@ -419,11 +465,11 @@ export const archiveAssets = async (assets: { id: string }[], visibility: AssetV
|
||||
});
|
||||
}
|
||||
|
||||
toastManager.primary(
|
||||
visibility === AssetVisibility.Archive
|
||||
? $t('archived_count', { values: { count: ids.length } })
|
||||
: $t('unarchived_count', { values: { count: ids.length } }),
|
||||
);
|
||||
if (visibility === AssetVisibility.Archive) {
|
||||
showUndoArchiveToast($t('archived_count', { values: { count: ids.length } }), assets);
|
||||
} else {
|
||||
toastManager.primary($t('unarchived_count', { values: { count: ids.length } }));
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
|
||||
@@ -165,7 +165,10 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
const people = assetResponse.people?.map((person) => person.name) || [];
|
||||
|
||||
const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime);
|
||||
const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC');
|
||||
// Keep this consistent with the bucket loader (getTimes), which stores fileCreatedAt as UTC
|
||||
// components. The timeline sorts assets within a day by fileCreatedAt, so a mismatched
|
||||
// representation here would place re-inserted assets (e.g. undo archive) in the wrong spot.
|
||||
const fileCreatedAt = fromISODateTimeUTCToObject(assetResponse.fileCreatedAt);
|
||||
const createdAt = fromISODateTimeUTCToObject(assetResponse.createdAt);
|
||||
|
||||
return {
|
||||
|
||||
@@ -329,6 +329,7 @@
|
||||
onPersonAssetDelete={handlePersonAssetDelete}
|
||||
onAssetsDelete={updateAssetCount}
|
||||
onAssetsArchive={updateAssetCount}
|
||||
onAssetsUnarchive={updateAssetCount}
|
||||
/>
|
||||
|
||||
<main
|
||||
|
||||
Reference in New Issue
Block a user