Compare commits
7 Commits
no-video-p
...
mobile/sma
| 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}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PersonCreateDto,
|
||||
SharedLinkCreateDto,
|
||||
UserAdminCreateDto,
|
||||
UserPreferencesUpdateDto,
|
||||
ValidateLibraryDto,
|
||||
checkExistingAssets,
|
||||
createAlbum,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
createPartner,
|
||||
createPerson,
|
||||
createSharedLink,
|
||||
createStack,
|
||||
createUserAdmin,
|
||||
deleteAssets,
|
||||
getAllJobsStatus,
|
||||
@@ -28,10 +30,13 @@ import {
|
||||
searchMetadata,
|
||||
setBaseUrl,
|
||||
signUpAdmin,
|
||||
tagAssets,
|
||||
updateAdminOnboarding,
|
||||
updateAlbumUser,
|
||||
updateAssets,
|
||||
updateConfig,
|
||||
updateMyPreferences,
|
||||
upsertTags,
|
||||
validate,
|
||||
} from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
@@ -444,6 +449,18 @@ export const utils = {
|
||||
|
||||
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') =>
|
||||
await context.addCookies([
|
||||
{
|
||||
|
||||
66
e2e/src/web/specs/asset-viewer/stack.e2e-spec.ts
Normal file
66
e2e/src/web/specs/asset-viewer/stack.e2e-spec.ts
Normal file
@@ -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(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
isDismissible: true,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_location_title'.tr(),
|
||||
onSearch: search,
|
||||
@@ -238,7 +238,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
isDismissible: true,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_camera_title'.tr(),
|
||||
onSearch: search,
|
||||
|
||||
@@ -18,8 +18,8 @@ class ImmichLocalThumbnailProvider
|
||||
|
||||
ImmichLocalThumbnailProvider({
|
||||
required this.asset,
|
||||
this.height = 256,
|
||||
this.width = 256,
|
||||
this.height = 128,
|
||||
this.width = 128,
|
||||
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
|
||||
import 'package:immich_mobile/services/partner.service.dart';
|
||||
@@ -9,9 +10,19 @@ import 'package:isar/isar.dart';
|
||||
|
||||
class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
|
||||
PartnerSharedWithNotifier(Isar db, this._ps) : super([]) {
|
||||
final query = db.users.filter().isPartnerSharedWithEqualTo(true);
|
||||
query.findAll().then((partners) => state = partners);
|
||||
query.watch().listen((partners) => state = partners);
|
||||
Function eq = const ListEquality<User>().equals;
|
||||
final query = db.users.filter().isPartnerSharedWithEqualTo(true).sortById();
|
||||
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}) {
|
||||
@@ -31,9 +42,19 @@ final partnerSharedWithProvider =
|
||||
|
||||
class PartnerSharedByNotifier extends StateNotifier<List<User>> {
|
||||
PartnerSharedByNotifier(Isar db) : super([]) {
|
||||
final query = db.users.filter().isPartnerSharedByEqualTo(true);
|
||||
query.findAll().then((partners) => state = partners);
|
||||
streamSub = query.watch().listen((partners) => state = partners);
|
||||
Function eq = const ListEquality<User>().equals;
|
||||
final query = db.users.filter().isPartnerSharedByEqualTo(true).sortById();
|
||||
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;
|
||||
|
||||
@@ -76,10 +76,16 @@ class AlbumService {
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
final List<String> excludedIds = await _backupAlbumRepository
|
||||
.getIdsBySelection(BackupSelection.exclude);
|
||||
final List<String> selectedIds = await _backupAlbumRepository
|
||||
.getIdsBySelection(BackupSelection.select);
|
||||
final (selectedIds, excludedIds, onDevice) = await (
|
||||
_backupAlbumRepository
|
||||
.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) {
|
||||
final numLocal = await _albumRepository.count(local: true);
|
||||
if (numLocal > 0) {
|
||||
@@ -87,8 +93,6 @@ class AlbumService {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final List<Album> onDevice = await _albumMediaRepository.getAll();
|
||||
_log.info("Found ${onDevice.length} device albums");
|
||||
Set<String>? excludedAssets;
|
||||
if (excludedIds.isNotEmpty) {
|
||||
if (Platform.isIOS) {
|
||||
@@ -108,22 +112,19 @@ class AlbumService {
|
||||
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
|
||||
);
|
||||
}
|
||||
final hasAll = selectedIds
|
||||
.map(
|
||||
(id) => onDevice.firstWhereOrNull((album) => album.localId == id),
|
||||
)
|
||||
.whereNotNull()
|
||||
.any((a) => a.isAll);
|
||||
|
||||
final allAlbum = onDevice.firstWhereOrNull((album) => album.isAll);
|
||||
final hasAll = allAlbum != null && selectedIds.contains(allAlbum.localId);
|
||||
if (hasAll) {
|
||||
if (Platform.isAndroid) {
|
||||
// remove the virtual "Recent" album and keep and individual albums
|
||||
// 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");
|
||||
}
|
||||
} else {
|
||||
// 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");
|
||||
}
|
||||
changes =
|
||||
@@ -138,15 +139,19 @@ class AlbumService {
|
||||
|
||||
Future<Set<String>> _loadExcludedAssetIds(
|
||||
List<Album> albums,
|
||||
List<String> excludedAlbumIds,
|
||||
Set<String> excludedAlbumIds,
|
||||
) async {
|
||||
final Set<String> result = HashSet<String>();
|
||||
for (Album album in albums) {
|
||||
if (excludedAlbumIds.contains(album.localId)) {
|
||||
final assetIds =
|
||||
await _albumMediaRepository.getAssetIds(album.localId!);
|
||||
result.addAll(assetIds);
|
||||
}
|
||||
for (final batchAlbums in albums
|
||||
.where((album) => excludedAlbumIds.contains(album.localId))
|
||||
.slices(5)) {
|
||||
await batchAlbums
|
||||
.map(
|
||||
(album) => _albumMediaRepository
|
||||
.getAssetIds(album.localId!)
|
||||
.then((assetIds) => result.addAll(assetIds)),
|
||||
)
|
||||
.wait;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -163,11 +168,10 @@ class AlbumService {
|
||||
bool changes = false;
|
||||
try {
|
||||
await _userService.refreshUsers();
|
||||
final List<Album> sharedAlbum =
|
||||
await _albumApiRepository.getAll(shared: true);
|
||||
|
||||
final List<Album> ownedAlbum =
|
||||
await _albumApiRepository.getAll(shared: null);
|
||||
final (sharedAlbum, ownedAlbum) = await (
|
||||
_albumApiRepository.getAll(shared: true),
|
||||
_albumApiRepository.getAll(shared: null)
|
||||
).wait;
|
||||
|
||||
final albums = HashSet<Album>(
|
||||
equals: (a, b) => a.remoteId == b.remoteId,
|
||||
|
||||
@@ -259,6 +259,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return Dismissible(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
direction: DismissDirection.down,
|
||||
onDismissed: (_) => Navigator.of(context).pop(),
|
||||
key: const Key('app_bar_dialog'),
|
||||
|
||||
@@ -31,7 +31,7 @@ class ImmichThumbnail extends HookWidget {
|
||||
static ImageProvider imageProvider({
|
||||
Asset? asset,
|
||||
String? assetId,
|
||||
int thumbnailSize = 256,
|
||||
int thumbnailSize = 128,
|
||||
}) {
|
||||
if (asset == null && assetId == null) {
|
||||
throw Exception('Must supply either asset or assetId');
|
||||
|
||||
@@ -54,6 +54,7 @@ void main() {
|
||||
.thenAnswer((_) async => []);
|
||||
when(() => backupRepository.getIdsBySelection(BackupSelection.select))
|
||||
.thenAnswer((_) async => []);
|
||||
when(() => albumMediaRepository.getAll()).thenAnswer((_) async => []);
|
||||
when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1);
|
||||
when(() => syncService.removeAllLocalAlbumsAndAssets())
|
||||
.thenAnswer((_) async => true);
|
||||
|
||||
@@ -127,6 +127,7 @@ export class StackRepository implements IStackRepository {
|
||||
relations: {
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
tags: true,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm">
|
||||
<h2>{$t('tags').toUpperCase()}</h2>
|
||||
</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)}
|
||||
<div class="flex group transition-all">
|
||||
<a
|
||||
|
||||
Reference in New Issue
Block a user