Compare commits

..

10 Commits

Author SHA1 Message Date
shenlong-tanwen 0c4cc56dd0 fix: remove partner assets from existing memories 2026-06-09 19:41:26 +05:30
renovate[bot] f382624e68 fix(deps): update @immich/ui to ^0.80.0 (#28935) 2026-06-09 11:19:41 +02:00
renovate[bot] 24dad15636 chore(deps): update grafana monorepo to v12.4.4 (#28931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-09 00:05:01 -04:00
renovate[bot] 7ab533b57b chore(deps): update dependency vitest to v3.2.6 [security] (#28915)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-09 00:03:56 -04:00
Timon d10153bbc7 fix(server): hide isFavorite from album asset sync stream (#28923)
* fix(server): hide isFavorite from album asset sync stream

* some tests

* Revert "some tests"

This reverts commit 3242e6961c.

* alter existing test to clear test's intent

* Reapply "some tests"

This reverts commit f1d4c47f5f.

* drop one

* sql
2026-06-09 00:03:03 -04:00
Timon b846afeb08 chore(server): tests for hide isFavorite for partner assets (#28927) 2026-06-09 00:01:39 -04:00
shenlong e222b19576 fix: do not handle drag without enough scrub area (#28921)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-08 16:47:08 -05:00
shenlong 1fee99cd2a ci: verify pigeon autogen output during static analysis (#28920)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-08 16:46:51 -05:00
bo0tzz 70bb7e4b7e fix: step name reference in fix-format.yml (#28912) 2026-06-08 14:32:34 -04:00
Yaros f973927c68 docs: replace make for mise (#28913)
* docs: replace make for mise

* chore: remove makefile comment
2026-06-08 14:31:23 -04:00
31 changed files with 710 additions and 576 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
if: always()
with:
github-token: ${{ steps.generate-token.outputs.token }}
github-token: ${{ steps.token.outputs.token }}
script: |
github.rest.issues.removeLabel({
issue_number: context.payload.pull_request.number,
+2
View File
@@ -90,6 +90,8 @@ jobs:
mobile/**/*.g.dart
mobile/**/*.gr.dart
mobile/**/*.drift.dart
mobile/**/*.g.swift
mobile/**/*.g.kt
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
+1 -1
View File
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.4.3-ubuntu@sha256:ca3f764fdc48cebdf22dd206f33ecb0795a9a7210eacd1b5c02204aebd78b223
image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58
volumes:
- grafana-data:/var/lib/grafana
+1 -1
View File
@@ -7,7 +7,7 @@ Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generat
OpenAPI is used to generate the client (Typescript, Dart) SDK. `openapi-generator-cli` can be installed [here](https://openapi-generator.tech/docs/installation/). The generated SDK is based on the `immich-openapi-specs.json` file, which is autogenerated by the server **when running in development mode**. The `immich-openapi-specs.json` file can be modified with `@nestjs/swagger` decorators used or referenced by controller endpoints. See the [NestJS OpenAPI docs](https://docs.nestjs.com/openapi/types-and-parameters) for more info. When you add a new endpoint or modify an existing one, you must run the server in development mode and run the command below to update the client SDK.
```bash
make open-api
mise open-api
```
You can find the generated client SDK in the `packages/sdk/client` for Typescript SDK and `mobile/openapi` for Dart SDK.
+1 -1
View File
@@ -218,7 +218,7 @@ When the Dev Container starts, it automatically:
- Debug ports: 9230 (workers), 9231 (API)
:::info
The Dev Container setup replaces the `make dev` command from the traditional setup. All services start automatically when you open the container.
The Dev Container setup replaces the `mise dev` command from the traditional setup. All services start automatically when you open the container.
:::
### Accessing Services
+1 -1
View File
@@ -2,7 +2,7 @@
A minimal devcontainer is supplied with this repository. All commands can be executed directly inside this container to avoid tedious installation of the environment.
:::warning
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`make dev`, ....). Feel free to contribute!
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`mise dev`, ....). Feel free to contribute!
:::
When contributing code through a pull request, please check the following:
+2 -2
View File
@@ -45,7 +45,7 @@ All the services are packaged to run as with single Docker Compose command.
5. From the root directory, run:
```bash title="Start development server"
make dev # required Makefile installed on the system.
mise dev
```
5. Access the dev instance in your browser at http://localhost:3000, or connect via the mobile app.
@@ -88,7 +88,7 @@ To see local changes to `@immich/ui` in Immich, do the following:
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yml` file (`../../ui:/usr/src/ui`)
4. Uncomment the corresponding alias in the `web/vite.config.ts` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui/packages/ui')`)
5. Uncomment the import statement in `web/src/app.css` file `@import '../../../ui/packages/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
6. Start up the stack via `make dev`
6. Start up the stack via `mise dev`
7. After making changes in `@immich/ui`, rebuild it (`pnpm run build`)
### Mobile app
+1 -1
View File
@@ -12,7 +12,7 @@ You need to run `mise //server:install` before _once_.
The e2e tests can be run by first starting up a test production environment via:
```bash
make e2e
mise e2e
```
Before you can run the tests, you need to run the following commands _once_:
-18
View File
@@ -11,24 +11,6 @@ import Foundation
#error("Unsupported platform.")
#endif
/// Error class for passing custom error details to Dart side.
final class PigeonError: Error {
let code: String
let message: String?
let details: Sendable?
init(code: String, message: String?, details: Sendable?) {
self.code = code
self.message = message
self.details = details
}
var localizedDescription: String {
return
"PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
}
}
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
@@ -221,6 +221,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
return;
}
if (_scrubberHeight <= 0) {
return;
}
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
+468 -427
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -392,6 +392,27 @@ export const columns = {
'asset.height',
'asset.isEdited',
],
syncAlbumAsset: [
'asset.id',
'asset.ownerId',
'asset.originalFileName',
'asset.thumbhash',
'asset.checksum',
'asset.fileCreatedAt',
'asset.fileModifiedAt',
'asset.createdAt',
'asset.localDateTime',
'asset.type',
'asset.deletedAt',
'asset.visibility',
'asset.duration',
'asset.livePhotoVideoId',
'asset.stackId',
'asset.libraryId',
'asset.width',
'asset.height',
'asset.isEdited',
],
syncPartnerAsset: [
'asset.id',
'asset.ownerId',
+24 -15
View File
@@ -69,7 +69,6 @@ select
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
"asset"."isFavorite",
"asset"."visibility",
"asset"."duration",
"asset"."livePhotoVideoId",
@@ -78,15 +77,19 @@ select
"asset"."width",
"asset"."height",
"asset"."isEdited",
case
when "asset"."ownerId" = $1 then "asset"."isFavorite"
else $2
end as "isFavorite",
"album_asset"."updateId"
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" >= $3
and "album_asset"."albumId" = $4
"album_asset"."updateId" < $3
and "album_asset"."updateId" <= $4
and "album_asset"."updateId" >= $5
and "album_asset"."albumId" = $6
order by
"album_asset"."updateId" asc
@@ -103,7 +106,6 @@ select
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
"asset"."isFavorite",
"asset"."visibility",
"asset"."duration",
"asset"."livePhotoVideoId",
@@ -112,16 +114,20 @@ select
"asset"."width",
"asset"."height",
"asset"."isEdited",
case
when "asset"."ownerId" = $1 then "asset"."isFavorite"
else $2
end as "isFavorite",
"asset"."updateId"
from
"asset" as "asset"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset"."updateId" < $1
and "asset"."updateId" > $2
and "album_asset"."updateId" <= $3
and "album_user"."userId" = $4
"asset"."updateId" < $3
and "asset"."updateId" > $4
and "album_asset"."updateId" <= $5
and "album_user"."userId" = $6
order by
"asset"."updateId" asc
@@ -139,7 +145,6 @@ select
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
"asset"."isFavorite",
"asset"."visibility",
"asset"."duration",
"asset"."livePhotoVideoId",
@@ -147,15 +152,19 @@ select
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."isEdited"
"asset"."isEdited",
case
when "asset"."ownerId" = $1 then "asset"."isFavorite"
else $2
end as "isFavorite"
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and "album_user"."userId" = $3
"album_asset"."updateId" < $3
and "album_asset"."updateId" > $4
and "album_user"."userId" = $5
order by
"album_asset"."updateId" asc
+32 -5
View File
@@ -195,11 +195,20 @@ class AlbumSync extends BaseSync {
}
class AlbumAssetSync extends BaseSync {
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true })
getBackfill(options: SyncBackfillOptions, albumId: string) {
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID, DummyValue.UUID], stream: true })
getBackfill(options: SyncBackfillOptions, albumId: string, userId: string) {
return this.backfillQuery('album_asset', options)
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
.select(columns.syncAsset)
.select(columns.syncAlbumAsset)
.select((eb) =>
eb
.case()
.when('asset.ownerId', '=', userId)
.then(eb.ref('asset.isFavorite'))
.else(eb.val(false))
.end()
.as('isFavorite'),
)
.select('album_asset.updateId')
.where('album_asset.albumId', '=', albumId)
.stream();
@@ -210,7 +219,16 @@ class AlbumAssetSync extends BaseSync {
const userId = options.userId;
return this.upsertQuery('asset', options)
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
.select(columns.syncAsset)
.select(columns.syncAlbumAsset)
.select((eb) =>
eb
.case()
.when('asset.ownerId', '=', userId)
.then(eb.ref('asset.isFavorite'))
.else(eb.val(false))
.end()
.as('isFavorite'),
)
.select('asset.updateId')
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
@@ -224,7 +242,16 @@ class AlbumAssetSync extends BaseSync {
return this.upsertQuery('album_asset', options)
.select('album_asset.updateId')
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
.select(columns.syncAsset)
.select(columns.syncAlbumAsset)
.select((eb) =>
eb
.case()
.when('asset.ownerId', '=', userId)
.then(eb.ref('asset.isFavorite'))
.else(eb.val(false))
.end()
.as('isFavorite'),
)
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
@@ -0,0 +1,16 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Delete cross-owner memory assets
await sql`
DELETE FROM memory_asset
USING memory, asset
WHERE memory_asset."memoriesId" = memory.id
AND memory_asset."assetId" = asset.id
AND memory."ownerId" != asset."ownerId"
`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented: the deleted rows were cross-owner entries
}
+1 -1
View File
@@ -175,7 +175,7 @@ export class AlbumService extends BaseService {
const results = await addAssets(
auth,
{ access: this.accessRepository, bulk: this.albumRepository },
{ parentId: id, assetIds: dto.ids },
{ parentId: id, assetIds: dto.ids, permission: Permission.AssetShare },
);
const { id: firstNewAssetId } = results.find(({ success }) => success) || {};
@@ -134,6 +134,27 @@ describe(MemoryService.name, () => {
);
});
it('should not link a partner asset', async () => {
const [assetId, userId] = newUuids();
const memory = MemoryFactory.create({ ownerId: userId });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetId]));
mocks.memory.create.mockResolvedValue(getForMemory(memory));
await expect(
sut.create(factory.auth({ user: { id: userId } }), {
type: memory.type,
data: memory.data as OnThisDayData,
memoryAt: memory.memoryAt,
assetIds: [assetId],
}),
).resolves.toMatchObject({ assets: [] });
expect(mocks.memory.create).toHaveBeenCalledWith(expect.objectContaining({ ownerId: userId }), new Set());
expect(mocks.access.asset.checkPartnerAccess).not.toHaveBeenCalled();
});
it('should create a memory without assets', async () => {
const memory = MemoryFactory.create();
@@ -230,6 +251,24 @@ describe(MemoryService.name, () => {
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled();
});
it('should not link a partner asset', async () => {
const assetId = newUuid();
const memory = MemoryFactory.create();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetId]));
mocks.memory.get.mockResolvedValue(getForMemory(memory));
mocks.memory.getAssetIds.mockResolvedValue(new Set());
await expect(sut.addAssets(factory.auth(), memory.id, { ids: [assetId] })).resolves.toEqual([
{ error: 'no_permission', id: assetId, success: false },
]);
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled();
expect(mocks.access.asset.checkPartnerAccess).not.toHaveBeenCalled();
});
it('should add assets', async () => {
const assetId = newUuid();
const memory = MemoryFactory.create();
+6 -2
View File
@@ -93,7 +93,7 @@ export class MemoryService extends BaseService {
const assetIds = dto.assetIds || [];
const allowedAssetIds = await this.checkAccess({
auth,
permission: Permission.AssetShare,
permission: Permission.AssetUpdate,
ids: assetIds,
});
const memory = await this.memoryRepository.create(
@@ -134,7 +134,11 @@ export class MemoryService extends BaseService {
await this.requireAccess({ auth, permission: Permission.MemoryRead, ids: [id] });
const repos = { access: this.accessRepository, bulk: this.memoryRepository };
const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids });
const results = await addAssets(auth, repos, {
parentId: id,
assetIds: dto.ids,
permission: Permission.AssetUpdate,
});
const hasSuccess = results.find(({ success }) => success);
if (hasSuccess) {
+1
View File
@@ -545,6 +545,7 @@ export class SyncService extends BaseService {
const backfill = this.syncRepository.albumAsset.getBackfill(
{ ...options, afterUpdateId: startId, beforeUpdateId: endId },
album.id,
options.userId,
);
for await (const { updateId, ...data } of backfill) {
+13
View File
@@ -275,6 +275,19 @@ describe(TagService.name, () => {
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
});
it('should not tag a partner asset', async () => {
mocks.tag.getAssetIds.mockResolvedValue(new Set());
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1']));
await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
{ id: 'asset-1', success: false, error: BulkIdErrorReason.NO_PERMISSION },
]);
expect(mocks.tag.addAssetIds).not.toHaveBeenCalled();
expect(mocks.access.asset.checkPartnerAccess).not.toHaveBeenCalled();
});
});
describe('removeAssets', () => {
+1 -1
View File
@@ -104,7 +104,7 @@ export class TagService extends BaseService {
const results = await addAssets(
auth,
{ access: this.accessRepository, bulk: this.tagRepository },
{ parentId: id, assetIds: dto.ids },
{ parentId: id, assetIds: dto.ids, permission: Permission.AssetUpdate },
);
for (const { id: assetId, success } of results) {
+2 -2
View File
@@ -33,14 +33,14 @@ export const getAssetFiles = (files: AssetFile[]) => ({
export const addAssets = async (
auth: AuthDto,
repositories: { access: AccessRepository; bulk: IBulkAsset },
dto: { parentId: string; assetIds: string[] },
dto: { parentId: string; assetIds: string[]; permission: Permission },
) => {
const { access, bulk } = repositories;
const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds);
const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id));
const allowedAssetIds = await checkAccess(access, {
auth,
permission: Permission.AssetShare,
permission: dto.permission,
ids: notPresentAssetIds,
});
@@ -270,7 +270,7 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
it('should sync asset updates for an album shared with you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: false });
const { asset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'before' });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
@@ -281,9 +281,7 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset.id,
}),
data: expect.objectContaining({ id: asset.id, originalFileName: 'before' }),
type: SyncEntityType.AlbumAssetCreateV2,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
@@ -291,24 +289,56 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
await ctx.syncAckAll(auth, response);
// update the asset
const assetRepository = ctx.get(AssetRepository);
await assetRepository.update({
id: asset.id,
isFavorite: true,
});
await assetRepository.update({ id: asset.id, originalFileName: 'after' });
const updateResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]);
expect(updateResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset.id,
isFavorite: true,
}),
data: expect.objectContaining({ id: asset.id, originalFileName: 'after' }),
type: SyncEntityType.AlbumAssetUpdateV2,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
it('should hide isFavorite for album assets owned by another user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: true });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Viewer });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]);
expect(response).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({ id: asset.id, isFavorite: false }),
type: SyncEntityType.AlbumAssetCreateV2,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
it('should sync isFavorite for album assets owned by the requesting user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id, isFavorite: true });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Viewer });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]);
expect(response).toEqual(
expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({ id: asset.id, isFavorite: true }),
type: SyncEntityType.AlbumAssetCreateV2,
}),
]),
);
});
});
@@ -278,4 +278,21 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
});
it('should hide isFavorite for partner assets', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: true });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: asset.id, isFavorite: false }),
type: SyncEntityType.PartnerAssetV2,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
});
+1 -1
View File
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.79.2",
"@immich/ui": "^0.80.0",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0",
@@ -310,12 +310,12 @@
untrack(() => map?.jumpTo({ center, zoom }));
});
const onAssetsChanged = async () => {
const onAssetsDelete = async () => {
mapMarkers = await loadMapMarkers();
};
</script>
<OnEvents onAssetsDelete={onAssetsChanged} onAssetsArchive={onAssetsChanged} onAssetsUnarchive={onAssetsChanged} />
<OnEvents {onAssetsDelete} />
<!-- We handle style loading ourselves so we set style blank here -->
<MapLibre
@@ -7,7 +7,6 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
@@ -220,17 +219,6 @@
const unsubscribes = [
websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)),
websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)),
eventManager.on({
AssetsUndoArchive: async (assets) => {
if (assets.length === 0) {
return;
}
const restoredAsset = assets[0];
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
assetViewerManager.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
},
}),
];
return () => {
for (const unsubscribe of unsubscribes) {
@@ -14,7 +14,6 @@ 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';
@@ -34,8 +33,6 @@ export type Events = {
AssetUpdate: [AssetResponseDto];
AssetsArchive: [string[]];
AssetsUnarchive: [TimelineAsset[]];
AssetsUndoArchive: [TimelineAsset[]];
AssetsDelete: [string[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
@@ -1,4 +1,4 @@
import { AssetOrder, AssetOrderBy, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { AssetOrder, getAssetInfo, getTimeBuckets, AssetOrderBy, 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,15 +114,7 @@ export class TimelineManager extends VirtualScrollManager {
this.#unsubscribes.push(
eventManager.on({
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),
AssetUpdate: (asset: AssetResponseDto) => this.#updateAssets([toTimelineAsset(asset)]),
}),
);
}
+7 -55
View File
@@ -24,7 +24,6 @@ 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';
@@ -32,7 +31,6 @@ 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 ({
@@ -402,21 +400,7 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
});
asset.isArchived = data.isArchived;
if (asset.isArchived) {
const timelineAsset = toTimelineAsset(asset);
toastManager.primary(
{
description: $t('added_to_archive'),
button: {
label: $t('undo'),
onclick: () => undoArchiveAssets([timelineAsset]),
},
},
{ timeout: 5000 },
);
} else {
toastManager.primary($t('removed_from_archive'));
}
toastManager.primary(asset.isArchived ? $t(`added_to_archive`) : $t(`removed_from_archive`));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_archive', { values: { archived: asset.isArchived } }));
}
@@ -424,30 +408,7 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
return asset;
};
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);
} catch (error) {
handleError(error, $t('errors.unable_to_archive_unarchive', { values: { archived: false } }));
}
};
export const archiveAssets = async (assets: TimelineAsset[], visibility: AssetVisibility) => {
export const archiveAssets = async (assets: { id: string }[], visibility: AssetVisibility) => {
const ids = assets.map(({ id }) => id);
const $t = get(t);
@@ -458,20 +419,11 @@ export const archiveAssets = async (assets: TimelineAsset[], visibility: AssetVi
});
}
if (visibility === AssetVisibility.Archive) {
toastManager.primary(
{
description: $t('archived_count', { values: { count: ids.length } }),
button: {
label: $t('undo'),
onclick: () => undoArchiveAssets(assets),
},
},
{ timeout: 5000 },
);
} else {
toastManager.primary($t('unarchived_count', { values: { count: ids.length } }));
}
toastManager.primary(
visibility === AssetVisibility.Archive
? $t('archived_count', { values: { count: ids.length } })
: $t('unarchived_count', { values: { count: ids.length } }),
);
} catch (error) {
handleError(
error,
@@ -326,7 +326,6 @@
onPersonAssetDelete={handlePersonAssetDelete}
onAssetsDelete={updateAssetCount}
onAssetsArchive={updateAssetCount}
onAssetsUnarchive={updateAssetCount}
/>
<main