Compare commits

...

7 Commits

Author SHA1 Message Date
Alex fe4c6c6365 default thumbnail size 2024-11-19 11:49:08 -06:00
Alex 63ed6283fc feat(mobile): smaller default local thumbnail size 2024-11-18 17:41:56 -06:00
John Stef 41f138d3c8 fix(mobile): Dismissible menus (#14192)
* chore(mobile): make all search filters dismissible

* chore(mobile): make ImmichAppBarDialog dismissible

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2024-11-18 10:06:07 -06:00
Mert 6b5defc27b fix(mobile): use sets in album refresh, concurrent futures (#14193)
* use sets in album sync, concurrent futures

* batch excluded asset IDs

* update test

* take advantage of sets in Recents check

* move log statement

* smaller diff
2024-11-18 09:26:23 -06:00
renovate[bot] 2604940f09 chore(deps): pin mcr.microsoft.com/devcontainers/typescript-node docker tag to dc2c365 (#14124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 14:57:43 +01:00
Michel Heusschen 32f908baf1 fix: show tags when viewing stacked assets (#14199)
fix: refresh tags when viewing stacked assets
2024-11-18 07:50:04 -05:00
Mert 944ea7dbcd fix(mobile): unnecessary rebuilds from partner share notifier (#14170)
* fix unnecessary notifications

* move equality function

* sort by id

* use same comparison for initial and later queries
2024-11-17 12:04:55 -05:00
12 changed files with 150 additions and 39 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22 ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:dc2c3654370fe92a55daeefe9d2d95839d85bdc1f68f7fd4ab86621f49e5818a
FROM ${BASEIMAGE} FROM ${BASEIMAGE}
+17
View File
@@ -11,6 +11,7 @@ import {
PersonCreateDto, PersonCreateDto,
SharedLinkCreateDto, SharedLinkCreateDto,
UserAdminCreateDto, UserAdminCreateDto,
UserPreferencesUpdateDto,
ValidateLibraryDto, ValidateLibraryDto,
checkExistingAssets, checkExistingAssets,
createAlbum, createAlbum,
@@ -19,6 +20,7 @@ import {
createPartner, createPartner,
createPerson, createPerson,
createSharedLink, createSharedLink,
createStack,
createUserAdmin, createUserAdmin,
deleteAssets, deleteAssets,
getAllJobsStatus, getAllJobsStatus,
@@ -28,10 +30,13 @@ import {
searchMetadata, searchMetadata,
setBaseUrl, setBaseUrl,
signUpAdmin, signUpAdmin,
tagAssets,
updateAdminOnboarding, updateAdminOnboarding,
updateAlbumUser, updateAlbumUser,
updateAssets, updateAssets,
updateConfig, updateConfig,
updateMyPreferences,
upsertTags,
validate, validate,
} from '@immich/sdk'; } from '@immich/sdk';
import { BrowserContext } from '@playwright/test'; import { BrowserContext } from '@playwright/test';
@@ -444,6 +449,18 @@ export const utils = {
createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }), createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }),
updateMyPreferences: (accessToken: string, userPreferencesUpdateDto: UserPreferencesUpdateDto) =>
updateMyPreferences({ userPreferencesUpdateDto }, { headers: asBearerAuth(accessToken) }),
createStack: (accessToken: string, assetIds: string[]) =>
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
upsertTags: (accessToken: string, tags: string[]) =>
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') => setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
await context.addCookies([ await context.addCookies([
{ {
@@ -0,0 +1,66 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, Page, test } from '@playwright/test';
import { utils } from 'src/utils';
async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
test.describe('Asset Viewer stack', () => {
let admin: LoginResponseDto;
let assetOne: AssetMediaResponseDto;
let assetTwo: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
assetOne = await utils.createAsset(admin.accessToken);
assetTwo = await utils.createAsset(admin.accessToken);
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
});
test('stack slideshow is visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await expect(stackAssets.first()).toBeVisible();
await expect(stackAssets.nth(1)).toBeVisible();
});
test('tags of primary asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});
+2 -2
View File
@@ -187,7 +187,7 @@ class SearchPage extends HookConsumerWidget {
showFilterBottomSheet( showFilterBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
isDismissible: false, isDismissible: true,
child: FilterBottomSheetScaffold( child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.tr(), title: 'search_filter_location_title'.tr(),
onSearch: search, onSearch: search,
@@ -238,7 +238,7 @@ class SearchPage extends HookConsumerWidget {
showFilterBottomSheet( showFilterBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
isDismissible: false, isDismissible: true,
child: FilterBottomSheetScaffold( child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.tr(), title: 'search_filter_camera_title'.tr(),
onSearch: search, onSearch: search,
@@ -18,8 +18,8 @@ class ImmichLocalThumbnailProvider
ImmichLocalThumbnailProvider({ ImmichLocalThumbnailProvider({
required this.asset, required this.asset,
this.height = 256, this.height = 128,
this.width = 256, this.width = 128,
}) : assert(asset.local != null, 'Only usable when asset.local is set'); }) : assert(asset.local != null, 'Only usable when asset.local is set');
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
+27 -6
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart'; import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
import 'package:immich_mobile/services/partner.service.dart'; import 'package:immich_mobile/services/partner.service.dart';
@@ -9,9 +10,19 @@ import 'package:isar/isar.dart';
class PartnerSharedWithNotifier extends StateNotifier<List<User>> { class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
PartnerSharedWithNotifier(Isar db, this._ps) : super([]) { PartnerSharedWithNotifier(Isar db, this._ps) : super([]) {
final query = db.users.filter().isPartnerSharedWithEqualTo(true); Function eq = const ListEquality<User>().equals;
query.findAll().then((partners) => state = partners); final query = db.users.filter().isPartnerSharedWithEqualTo(true).sortById();
query.watch().listen((partners) => state = partners); query.findAll().then((partners) {
if (!eq(state, partners)) {
state = partners;
}
}).then((_) {
query.watch().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
});
});
} }
Future<bool> updatePartner(User partner, {required bool inTimeline}) { Future<bool> updatePartner(User partner, {required bool inTimeline}) {
@@ -31,9 +42,19 @@ final partnerSharedWithProvider =
class PartnerSharedByNotifier extends StateNotifier<List<User>> { class PartnerSharedByNotifier extends StateNotifier<List<User>> {
PartnerSharedByNotifier(Isar db) : super([]) { PartnerSharedByNotifier(Isar db) : super([]) {
final query = db.users.filter().isPartnerSharedByEqualTo(true); Function eq = const ListEquality<User>().equals;
query.findAll().then((partners) => state = partners); final query = db.users.filter().isPartnerSharedByEqualTo(true).sortById();
streamSub = query.watch().listen((partners) => state = partners); query.findAll().then((partners) {
if (!eq(state, partners)) {
state = partners;
}
}).then((_) {
streamSub = query.watch().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
});
});
} }
late final StreamSubscription<List<User>> streamSub; late final StreamSubscription<List<User>> streamSub;
+30 -26
View File
@@ -76,10 +76,16 @@ class AlbumService {
final Stopwatch sw = Stopwatch()..start(); final Stopwatch sw = Stopwatch()..start();
bool changes = false; bool changes = false;
try { try {
final List<String> excludedIds = await _backupAlbumRepository final (selectedIds, excludedIds, onDevice) = await (
.getIdsBySelection(BackupSelection.exclude); _backupAlbumRepository
final List<String> selectedIds = await _backupAlbumRepository .getIdsBySelection(BackupSelection.select)
.getIdsBySelection(BackupSelection.select); .then((value) => value.toSet()),
_backupAlbumRepository
.getIdsBySelection(BackupSelection.exclude)
.then((value) => value.toSet()),
_albumMediaRepository.getAll()
).wait;
_log.info("Found ${onDevice.length} device albums");
if (selectedIds.isEmpty) { if (selectedIds.isEmpty) {
final numLocal = await _albumRepository.count(local: true); final numLocal = await _albumRepository.count(local: true);
if (numLocal > 0) { if (numLocal > 0) {
@@ -87,8 +93,6 @@ class AlbumService {
} }
return false; return false;
} }
final List<Album> onDevice = await _albumMediaRepository.getAll();
_log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets; Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) { if (excludedIds.isNotEmpty) {
if (Platform.isIOS) { if (Platform.isIOS) {
@@ -108,22 +112,19 @@ class AlbumService {
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums", "Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
); );
} }
final hasAll = selectedIds
.map( final allAlbum = onDevice.firstWhereOrNull((album) => album.isAll);
(id) => onDevice.firstWhereOrNull((album) => album.localId == id), final hasAll = allAlbum != null && selectedIds.contains(allAlbum.localId);
)
.whereNotNull()
.any((a) => a.isAll);
if (hasAll) { if (hasAll) {
if (Platform.isAndroid) { if (Platform.isAndroid) {
// remove the virtual "Recent" album and keep and individual albums // remove the virtual "Recent" album and keep and individual albums
// on Android, the virtual "Recent" `lastModified` value is always null // on Android, the virtual "Recent" `lastModified` value is always null
onDevice.removeWhere((e) => e.isAll); onDevice.removeWhere((album) => album.isAll);
_log.info("'Recents' is selected, keeping all individual albums"); _log.info("'Recents' is selected, keeping all individual albums");
} }
} else { } else {
// keep only the explicitly selected albums // keep only the explicitly selected albums
onDevice.removeWhere((e) => !selectedIds.contains(e.localId)); onDevice.removeWhere((album) => !selectedIds.contains(album.localId));
_log.info("'Recents' is not selected, keeping only selected albums"); _log.info("'Recents' is not selected, keeping only selected albums");
} }
changes = changes =
@@ -138,15 +139,19 @@ class AlbumService {
Future<Set<String>> _loadExcludedAssetIds( Future<Set<String>> _loadExcludedAssetIds(
List<Album> albums, List<Album> albums,
List<String> excludedAlbumIds, Set<String> excludedAlbumIds,
) async { ) async {
final Set<String> result = HashSet<String>(); final Set<String> result = HashSet<String>();
for (Album album in albums) { for (final batchAlbums in albums
if (excludedAlbumIds.contains(album.localId)) { .where((album) => excludedAlbumIds.contains(album.localId))
final assetIds = .slices(5)) {
await _albumMediaRepository.getAssetIds(album.localId!); await batchAlbums
result.addAll(assetIds); .map(
} (album) => _albumMediaRepository
.getAssetIds(album.localId!)
.then((assetIds) => result.addAll(assetIds)),
)
.wait;
} }
return result; return result;
} }
@@ -163,11 +168,10 @@ class AlbumService {
bool changes = false; bool changes = false;
try { try {
await _userService.refreshUsers(); await _userService.refreshUsers();
final List<Album> sharedAlbum = final (sharedAlbum, ownedAlbum) = await (
await _albumApiRepository.getAll(shared: true); _albumApiRepository.getAll(shared: true),
_albumApiRepository.getAll(shared: null)
final List<Album> ownedAlbum = ).wait;
await _albumApiRepository.getAll(shared: null);
final albums = HashSet<Album>( final albums = HashSet<Album>(
equals: (a, b) => a.remoteId == b.remoteId, equals: (a, b) => a.remoteId == b.remoteId,
@@ -259,6 +259,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
} }
return Dismissible( return Dismissible(
behavior: HitTestBehavior.translucent,
direction: DismissDirection.down, direction: DismissDirection.down,
onDismissed: (_) => Navigator.of(context).pop(), onDismissed: (_) => Navigator.of(context).pop(),
key: const Key('app_bar_dialog'), key: const Key('app_bar_dialog'),
@@ -31,7 +31,7 @@ class ImmichThumbnail extends HookWidget {
static ImageProvider imageProvider({ static ImageProvider imageProvider({
Asset? asset, Asset? asset,
String? assetId, String? assetId,
int thumbnailSize = 256, int thumbnailSize = 128,
}) { }) {
if (asset == null && assetId == null) { if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId'); throw Exception('Must supply either asset or assetId');
@@ -54,6 +54,7 @@ void main() {
.thenAnswer((_) async => []); .thenAnswer((_) async => []);
when(() => backupRepository.getIdsBySelection(BackupSelection.select)) when(() => backupRepository.getIdsBySelection(BackupSelection.select))
.thenAnswer((_) async => []); .thenAnswer((_) async => []);
when(() => albumMediaRepository.getAll()).thenAnswer((_) async => []);
when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1); when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1);
when(() => syncService.removeAllLocalAlbumsAndAssets()) when(() => syncService.removeAllLocalAlbumsAndAssets())
.thenAnswer((_) async => true); .thenAnswer((_) async => true);
@@ -127,6 +127,7 @@ export class StackRepository implements IStackRepository {
relations: { relations: {
assets: { assets: {
exifInfo: true, exifInfo: true,
tags: true,
}, },
}, },
order: { order: {
@@ -46,7 +46,7 @@
<div class="flex h-10 w-full items-center justify-between text-sm"> <div class="flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('tags').toUpperCase()}</h2> <h2>{$t('tags').toUpperCase()}</h2>
</div> </div>
<section class="flex flex-wrap pt-2 gap-1"> <section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)} {#each tags as tag (tag.id)}
<div class="flex group transition-all"> <div class="flex group transition-all">
<a <a