Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe4c6c6365 | |||
| 63ed6283fc | |||
| 41f138d3c8 | |||
| 6b5defc27b | |||
| 2604940f09 | |||
| 32f908baf1 | |||
| 944ea7dbcd |
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user