From 29e7ea5302bc3f6ed1eb845706cc77cb34f40048 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:40:08 +0530 Subject: [PATCH 01/89] chore(web): use FUTO F-Droid repo in utilities (#30527) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- web/src/lib/modals/AppDownloadModal.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/lib/modals/AppDownloadModal.svelte b/web/src/lib/modals/AppDownloadModal.svelte index 84d7630909..01a998bf5c 100644 --- a/web/src/lib/modals/AppDownloadModal.svelte +++ b/web/src/lib/modals/AppDownloadModal.svelte @@ -1,5 +1,5 @@ diff --git a/web/src/lib/modals/timezone-utils.ts b/web/src/lib/modals/timezone-utils.ts index 2dfbb3fd5f..ae86e46321 100644 --- a/web/src/lib/modals/timezone-utils.ts +++ b/web/src/lib/modals/timezone-utils.ts @@ -95,7 +95,7 @@ function zoneOptionForDate(zone: string, date: string) { function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) { const offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes; - if (offsetDifference != 0) { + if (offsetDifference !== 0) { return offsetDifference; } return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' }); diff --git a/web/src/lib/stores/upload.ts b/web/src/lib/stores/upload.ts index 04a8a45bee..21929ab1e4 100644 --- a/web/src/lib/stores/upload.ts +++ b/web/src/lib/stores/upload.ts @@ -67,7 +67,7 @@ function createUploadStore() { const updateAssetMap = (id: string, mapper: (assets: UploadAsset) => UploadAsset) => { uploadAssets.update((uploadingAssets) => { return uploadingAssets.map((asset) => { - if (asset.id == id) { + if (asset.id === id) { return mapper(asset); } return asset; @@ -111,7 +111,7 @@ function createUploadStore() { }); } - return uploadingAsset.filter((a) => a.id != id); + return uploadingAsset.filter((a) => a.id !== id); }); }; diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index 3624b34f93..f5733fd5b2 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -68,7 +68,7 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse * @param {StackResponse} stackResponse - The stack response containing the stack and assets to delete. */ export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) { - if (stack == undefined) { + if (stack === undefined) { return; } diff --git a/web/src/lib/utils/duplicate-utils.ts b/web/src/lib/utils/duplicate-utils.ts index 86b221906f..53a52ddcf9 100644 --- a/web/src/lib/utils/duplicate-utils.ts +++ b/web/src/lib/utils/duplicate-utils.ts @@ -147,6 +147,7 @@ const metadataFields = [ titleKey: 'gps', keys: ['latitude', 'longitude'], render: (asset, $t) => + // eslint-disable-next-line eqeqeq asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null ? `${asset.exifInfo.latitude.toFixed(4)}, ${asset.exifInfo.longitude.toFixed(4)}` : $t('unknown'), @@ -173,18 +174,21 @@ const metadataFields = [ icon: mdiCameraIris, titleKey: 'f_number', keys: ['fNumber'], + // eslint-disable-next-line eqeqeq render: (asset, $t) => (asset.exifInfo?.fNumber == null ? $t('unknown') : `f/${asset.exifInfo.fNumber.toFixed(1)}`), }, { icon: mdiRayStartArrow, titleKey: 'focal_length', keys: ['focalLength'], + // eslint-disable-next-line eqeqeq render: (asset, $t) => (asset.exifInfo?.focalLength == null ? $t('unknown') : `${asset.exifInfo.focalLength} mm`), }, { icon: mdiBrightness6, titleKey: 'iso', keys: ['iso'], + // eslint-disable-next-line eqeqeq render: (asset, $t) => (asset.exifInfo?.iso == null ? $t('unknown') : `ISO ${asset.exifInfo.iso}`), }, { @@ -203,6 +207,7 @@ const metadataFields = [ icon: mdiStarOutline, titleKey: 'rating', keys: ['rating'], + // eslint-disable-next-line eqeqeq render: (asset, $t) => (asset.exifInfo?.rating == null ? $t('unknown') : `${asset.exifInfo.rating} stars`), }, { diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index 91513911f1..2f88da1f43 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -44,7 +44,7 @@ export function currentUrlReplaceAssetId(assetId: string) { // always remove the assetGridScrollTargetParams params.delete('at'); const paramsString = params.toString(); - const searchparams = paramsString == '' ? '' : '?' + params.toString(); + const searchparams = paramsString === '' ? '' : '?' + params.toString(); // this contains special casing for the /photos/:assetId photos route, which hangs directly // off / instead of a subpath, unlike every other asset-containing route. return isPhotosRoute(page.route.id) diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 018d7d34ef..e1e717f45a 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -183,8 +183,8 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): isFavorite: assetResponse.isFavorite, visibility: assetResponse.visibility, isTrashed: assetResponse.isTrashed, - isVideo: assetResponse.type == AssetTypeEnum.Video, - isImage: assetResponse.type == AssetTypeEnum.Image, + isVideo: assetResponse.type === AssetTypeEnum.Video, + isImage: assetResponse.type === AssetTypeEnum.Image, stack: assetResponse.stack || null, duration: assetResponse.duration || null, projectionType: assetResponse.exifInfo?.projectionType || null, diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 733c1f556a..c1028ad658 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -222,7 +222,7 @@ const onNameChangeSubmit = async (name: string, targetPerson: PersonResponseDto) => { try { - if (name == targetPerson.name) { + if (name === targetPerson.name) { return; } diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3fe707c6df..e4389f09dc 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -98,7 +98,7 @@ } else if ($page.params.assetId) { previousRoute = Route.viewPerson(data.person); } - if (action == 'merge') { + if (action === 'merge') { viewMode = PersonPageViewMode.MERGE_PEOPLE; } @@ -180,7 +180,7 @@ const [, personToBeMergedInto] = result; - if (personToBeMergedInto.name != personName && person.id === personToBeMergedInto.id) { + if (personToBeMergedInto.name !== personName && person.id === personToBeMergedInto.id) { await updateAssetCount(); return { merged: true }; } diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index e7a18f1d0f..6683dbfe2e 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -201,7 +201,7 @@ personIds.map(async (personId) => { const person = await getPerson({ id: personId }); - if (person.name == '') { + if (person.name === '') { return $t('no_name'); } diff --git a/web/src/routes/(user)/user-settings/AppSettings.svelte b/web/src/routes/(user)/user-settings/AppSettings.svelte index ddfac02bfc..04c8b6af06 100644 --- a/web/src/routes/(user)/user-settings/AppSettings.svelte +++ b/web/src/routes/(user)/user-settings/AppSettings.svelte @@ -74,7 +74,7 @@ - + {selectedDate} diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte index f4c0a93655..338c463375 100644 --- a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -38,7 +38,7 @@ }; const preAction = async (payload: Action) => { - if (payload.type == 'trash') { + if (payload.type === 'trash') { // eslint-disable-next-line @typescript-eslint/no-unused-expressions (await navigateToAsset(assetCursor?.nextAsset)) || (await navigateToAsset(assetCursor?.previousAsset)) || diff --git a/web/src/routes/admin/system-settings/JobSettings.svelte b/web/src/routes/admin/system-settings/JobSettings.svelte index a43be5e9f4..c229a788b4 100644 --- a/web/src/routes/admin/system-settings/JobSettings.svelte +++ b/web/src/routes/admin/system-settings/JobSettings.svelte @@ -66,7 +66,7 @@ description="" bind:value={configToEdit.job[queueName].concurrency} required={true} - isEdited={configToEdit.job[queueName].concurrency != config.job[queueName].concurrency} + isEdited={configToEdit.job[queueName].concurrency !== config.job[queueName].concurrency} /> {:else} { - if (nextStepIndex == -1) { + if (nextStepIndex === -1) { if (authManager.user.isAdmin) { await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } }); await serverConfigManager.loadServerConfig(); diff --git a/web/src/routes/auth/onboarding/OnboardingHello.svelte b/web/src/routes/auth/onboarding/OnboardingHello.svelte index 533de49f81..1b5b0d2e88 100644 --- a/web/src/routes/auth/onboarding/OnboardingHello.svelte +++ b/web/src/routes/auth/onboarding/OnboardingHello.svelte @@ -16,7 +16,7 @@ {$t('onboarding_welcome_user', { values: { user: authManager.user.name } })}

- {userRole == OnboardingRole.SERVER + {userRole === OnboardingRole.SERVER ? $t('onboarding_server_welcome_description') : $t('onboarding_user_welcome_description')}

From c7d7889ed0101ff2f49e0365459e9943d1dcac08 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Tue, 11 Aug 2026 20:41:40 +0200 Subject: [PATCH 59/89] fix: pass secrets to build-mobile (#29031) --- .github/workflows/build-mobile.yml | 4 ++++ .github/workflows/prepare-release.yml | 2 ++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 602ffb3c18..b2ba244ab9 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -12,6 +12,10 @@ on: default: 'development' type: string secrets: + PUSH_O_MATIC_APP_CLIENT_ID: + required: true + PUSH_O_MATIC_APP_KEY: + required: true KEY_JKS: required: true ALIAS: diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 98a3daa3b2..abc84c7cdb 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -102,6 +102,8 @@ jobs: contents: read pull-requests: write secrets: + PUSH_O_MATIC_APP_CLIENT_ID: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} + PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }} KEY_JKS: ${{ secrets.KEY_JKS }} ALIAS: ${{ secrets.ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} From 199723261c6ffa897fec8ccdaea6359e39c37cc3 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:05:18 +0200 Subject: [PATCH 60/89] fix: owner cascade delete album (#30692) --- server/src/schema/functions.ts | 14 +++++++ server/src/schema/index.ts | 2 + .../1786385711807-AlbumOwnerDeleteTrigger.ts | 31 ++++++++++++++++ server/src/schema/tables/album-user.table.ts | 3 +- .../specs/services/album.service.spec.ts | 37 +++++++++++++++++++ 5 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts create mode 100644 server/test/medium/specs/services/album.service.spec.ts diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 230f22478d..2ee61d82dd 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -133,6 +133,20 @@ export const album_asset_delete_audit = registerFunction({ END`, }); +export const album_user_delete = registerFunction({ + name: 'album_user_delete', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + DELETE FROM "album" + WHERE "album"."id" = OLD."albumId" + AND NOT EXISTS (SELECT "albumId" FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner'); + + RETURN NULL; + END`, +}); + export const album_user_delete_audit = registerFunction({ name: 'album_user_delete_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 4e55ff4cf6..171fefb50f 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -7,6 +7,7 @@ import { } from 'src/schema/enums'; import { album_user_after_insert, + album_user_delete, album_user_delete_audit, asset_delete_audit, asset_face_audit, @@ -174,6 +175,7 @@ export class ImmichDatabase { asset_metadata_audit, asset_face_audit, asset_ocr_delete_audit, + album_user_delete, ]; enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum]; diff --git a/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts b/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts new file mode 100644 index 0000000000..082e43ff23 --- /dev/null +++ b/server/src/schema/migrations/1786385711807-AlbumOwnerDeleteTrigger.ts @@ -0,0 +1,31 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`DELETE FROM "album" WHERE NOT EXISTS (SELECT * FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner');`.execute(db); + await sql`CREATE OR REPLACE FUNCTION album_user_delete() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + DELETE FROM "album" + WHERE "album"."id" = OLD."albumId" + AND NOT EXISTS (SELECT "albumId" FROM "album_user" WHERE "album_user"."albumId" = "album"."id" AND "album_user"."role" = 'owner'); + + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "album_user_delete" + AFTER DELETE ON "album_user" + REFERENCING OLD TABLE AS "old" + FOR EACH ROW + EXECUTE FUNCTION album_user_delete();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_user_delete', '{"type":"function","name":"album_user_delete","sql":"CREATE OR REPLACE FUNCTION album_user_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n DELETE FROM \\"album\\"\\n WHERE \\"album\\".\\"id\\" = OLD.\\"albumId\\"\\n AND NOT EXISTS (SELECT \\"albumId\\" FROM \\"album_user\\" WHERE \\"album_user\\".\\"albumId\\" = \\"album\\".\\"id\\" AND \\"album_user\\".\\"role\\" = ''owner'');\\n\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_user_delete', '{"type":"trigger","name":"album_user_delete","sql":"CREATE OR REPLACE TRIGGER \\"album_user_delete\\"\\n AFTER DELETE ON \\"album_user\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION album_user_delete();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "album_user_delete" ON "album_user";`.execute(db); + await sql`DROP FUNCTION album_user_delete;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_user_delete';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_user_delete';`.execute(db); +} diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 677d6ca2f2..c015c1354d 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -13,7 +13,7 @@ import { import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AlbumUserRole } from 'src/enum'; import { album_user_role_enum } from 'src/schema/enums'; -import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions'; +import { album_user_after_insert, album_user_delete, album_user_delete_audit } from 'src/schema/functions'; import { AlbumTable } from 'src/schema/tables/album.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -38,6 +38,7 @@ import { UserTable } from 'src/schema/tables/user.table'; referencingOldTableAs: 'old', when: 'pg_trigger_depth() <= 1', }) +@AfterDeleteTrigger({ scope: 'row', function: album_user_delete, referencingOldTableAs: 'old' }) export class AlbumUserTable { @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', diff --git a/server/test/medium/specs/services/album.service.spec.ts b/server/test/medium/specs/services/album.service.spec.ts new file mode 100644 index 0000000000..bc73007593 --- /dev/null +++ b/server/test/medium/specs/services/album.service.spec.ts @@ -0,0 +1,37 @@ +import { Kysely } from 'kysely'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { AlbumService } from 'src/services/album.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(AlbumService, { + database: db || defaultDatabase, + real: [AlbumRepository, UserRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AlbumService.name, () => { + describe('database triggers', () => { + it('should cascade delete an album when the owner is deleted', async () => { + const { ctx } = setup(); + const { user } = await ctx.newUser(); + await ctx.newAlbum({ ownerId: user.id }); + + await ctx.get(UserRepository).delete({ id: user.id }, true); + + await expect(ctx.database.selectFrom('album').selectAll().execute()).resolves.toEqual([]); + await expect(ctx.database.selectFrom('album_user').selectAll().execute()).resolves.toEqual([]); + }); + }); +}); From 0344c61e4cee9cb2e8ee3ce2292177cd2b0c4129 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 12 Aug 2026 13:36:33 +0200 Subject: [PATCH 61/89] feat: use version service for docs archive switcher (#30675) --- docs/src/components/version-switcher.tsx | 9 +- docs/static/_redirects | 1 + docs/static/archived-versions.json | 265 ------------------ packages/scripts/src/commands/release.spec.ts | 78 +----- packages/scripts/src/commands/release.ts | 52 ---- 5 files changed, 6 insertions(+), 399 deletions(-) delete mode 100644 docs/static/archived-versions.json diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx index 739d7bd001..967c85c780 100644 --- a/docs/src/components/version-switcher.tsx +++ b/docs/src/components/version-switcher.tsx @@ -2,6 +2,8 @@ import { useWindowSize } from '@docusaurus/theme-common'; import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem'; import React, { useEffect, useState } from 'react'; +const ARCHIVED_VERSIONS_URL = 'https://version.immich.cloud/v1/docs/versions'; + export default function VersionSwitcher(): JSX.Element { const [versions, setVersions] = useState([]); const [activeLabel, setLabel] = useState('Versions'); @@ -11,12 +13,7 @@ export default function VersionSwitcher(): JSX.Element { useEffect(() => { async function getVersions() { try { - let baseUrl = 'https://docs.immich.app'; - if (window.location.origin === 'http://localhost:3005') { - baseUrl = window.location.origin; - } - - const response = await fetch(`${baseUrl}/archived-versions.json`); + const response = await fetch(ARCHIVED_VERSIONS_URL); const archiveVersions = await response.json(); diff --git a/docs/static/_redirects b/docs/static/_redirects index 633025ac17..e218124e59 100644 --- a/docs/static/_redirects +++ b/docs/static/_redirects @@ -1,3 +1,4 @@ +/archived-versions.json https://version.immich.cloud/v1/docs/versions 307 / /overview/quick-start 307 /mobile-app-beta-program /features/mobile-app 307 /contribution-guidelines /overview/support-the-project#contributing 307 diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json deleted file mode 100644 index dbeed76b73..0000000000 --- a/docs/static/archived-versions.json +++ /dev/null @@ -1,265 +0,0 @@ -[ - { - "label": "v3.1.0", - "url": "https://docs.v3.1.0.archive.immich.app" - }, - { - "label": "v3.0.3", - "url": "https://docs.v3.0.3.archive.immich.app" - }, - { - "label": "v2.7.5", - "url": "https://docs.v2.7.5.archive.immich.app" - }, - { - "label": "v2.6.3", - "url": "https://docs.v2.6.3.archive.immich.app" - }, - { - "label": "v2.5.6", - "url": "https://docs.v2.5.6.archive.immich.app" - }, - { - "label": "v2.4.1", - "url": "https://docs.v2.4.1.archive.immich.app" - }, - { - "label": "v2.3.1", - "url": "https://docs.v2.3.1.archive.immich.app" - }, - { - "label": "v2.2.3", - "url": "https://docs.v2.2.3.archive.immich.app" - }, - { - "label": "v2.1.0", - "url": "https://docs.v2.1.0.archive.immich.app" - }, - { - "label": "v2.0.1", - "url": "https://docs.v2.0.1.archive.immich.app" - }, - { - "label": "v1.144.1", - "url": "https://docs.v1.144.1.archive.immich.app" - }, - { - "label": "v1.143.1", - "url": "https://docs.v1.143.1.archive.immich.app" - }, - { - "label": "v1.142.1", - "url": "https://v1.142.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.141.1", - "url": "https://v1.141.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.140.1", - "url": "https://v1.140.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.139.4", - "url": "https://v1.139.4.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.138.1", - "url": "https://v1.138.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.137.3", - "url": "https://v1.137.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.136.0", - "url": "https://v1.136.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.135.3", - "url": "https://v1.135.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.134.0", - "url": "https://v1.134.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.133.1", - "url": "https://v1.133.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.132.3", - "url": "https://v1.132.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.131.3", - "url": "https://v1.131.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.130.3", - "url": "https://v1.130.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.129.0", - "url": "https://v1.129.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.128.0", - "url": "https://v1.128.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.127.0", - "url": "https://v1.127.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.126.1", - "url": "https://v1.126.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.125.7", - "url": "https://v1.125.7.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.124.2", - "url": "https://v1.124.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.123.0", - "url": "https://v1.123.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.122.3", - "url": "https://v1.122.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.121.0", - "url": "https://v1.121.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.120.2", - "url": "https://v1.120.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.119.1", - "url": "https://v1.119.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.118.2", - "url": "https://v1.118.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.117.0", - "url": "https://v1.117.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.116.2", - "url": "https://v1.116.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.115.0", - "url": "https://v1.115.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.114.0", - "url": "https://v1.114.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.113.1", - "url": "https://v1.113.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.112.1", - "url": "https://v1.112.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.111.0", - "url": "https://v1.111.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.110.0", - "url": "https://v1.110.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.109.2", - "url": "https://v1.109.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.108.0", - "url": "https://v1.108.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.107.2", - "url": "https://v1.107.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.106.4", - "url": "https://v1.106.4.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.105.1", - "url": "https://v1.105.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.104.0", - "url": "https://v1.104.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.103.1", - "url": "https://v1.103.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.102.3", - "url": "https://v1.102.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.101.0", - "url": "https://v1.101.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.100.0", - "url": "https://v1.100.0.archive.immich.app", - "rootPath": "/docs" - } -] diff --git a/packages/scripts/src/commands/release.spec.ts b/packages/scripts/src/commands/release.spec.ts index 0b1be22302..dc3f82517d 100644 --- a/packages/scripts/src/commands/release.spec.ts +++ b/packages/scripts/src/commands/release.spec.ts @@ -1,23 +1,10 @@ import semver from 'semver'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - resolveArchivedVersions, - ArchivedVersion, - getMobileBuild, - getNewVersion, - ReleaseError, -} from './release'; +import { describe, expect, it } from 'vitest'; +import { getMobileBuild, getNewVersion, ReleaseError } from './release'; const mobileBuild = (version: string) => getMobileBuild(semver.parse(version) as semver.SemVer); -const archived = (label: string): ArchivedVersion => ({ - label, - url: `https://docs.${label}.archive.immich.app`, -}); - -const labels = (versions: ArchivedVersion[]) => versions.map((v) => v.label); - describe(getNewVersion.name, () => { describe('transitions', () => { const valid = [ @@ -82,67 +69,6 @@ describe(getNewVersion.name, () => { }); }); -describe(resolveArchivedVersions.name, () => { - beforeEach(() => { - // silence the "Removed ..." progress logging - vi.spyOn(console, 'log').mockImplementation(() => {}); - }); - - it('should prepend the new version to the front', () => { - const result = resolveArchivedVersions([archived('v2.9.0')], '3.0.0'); - - expect(result[0]).toEqual({ - label: 'v3.0.0', - url: 'https://docs.v3.0.0.archive.immich.app', - }); - expect(labels(result)).toEqual(['v3.0.0', 'v2.9.0']); - }); - - it('should handle an empty list', () => { - expect(labels(resolveArchivedVersions([], '3.0.0'))).toEqual(['v3.0.0']); - }); - - it('should drop older patch releases of the new version minor', () => { - const versions = ['v3.0.4', 'v3.0.3', 'v3.0.2'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([ - 'v3.0.5', - ]); - }); - - it('should keep the latest patch of each older minor', () => { - const versions = ['v3.1.2', 'v3.1.1', 'v3.0.9', 'v2.0.0'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.2.0'))).toEqual([ - 'v3.2.0', - 'v3.1.2', - 'v3.0.9', - 'v2.0.0', - ]); - }); - - it('should keep an older minor when bumping a patch', () => { - const versions = ['v3.0.4', 'v3.0.3', 'v2.9.1', 'v2.9.0', 'v1.5.0'].map( - archived, - ); - - expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([ - 'v3.0.5', - 'v2.9.1', - 'v1.5.0', - ]); - }); - - it('should replace a prerelease with its release', () => { - const versions = ['v3.0.0-rc.2', 'v3.0.0-rc.1', 'v2.9.0'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.0.0'))).toEqual([ - 'v3.0.0', - 'v2.9.0', - ]); - }); -}); - describe(getMobileBuild.name, () => { it('should encode the version', () => { expect(mobileBuild('3.1.0')).toBe(3_010_099); diff --git a/packages/scripts/src/commands/release.ts b/packages/scripts/src/commands/release.ts index 307b66165a..2c0d82d5f6 100644 --- a/packages/scripts/src/commands/release.ts +++ b/packages/scripts/src/commands/release.ts @@ -26,7 +26,6 @@ const Files = { Docs: { Env: join(root, 'docs/docs/install/environment-variables.md'), Upgrading: join(root, 'docs/docs/install/upgrading.md'), - ArchivedVersions: join(root, 'docs/static/archived-versions.json'), }, Mobile: { Pubspec: join(root, 'mobile/pubspec.yaml'), @@ -92,13 +91,6 @@ export const handleRelease = ({ type }: ReleaseOptions) => { pump(Files.Docs.Upgrading, /:v\d+/, `:${major}`); } - // update archived versions list - const archivedFile = new JsonFile( - Files.Docs.ArchivedVersions, - ); - const versions = archivedFile.read(); - archivedFile.write(resolveArchivedVersions(versions, newVersionRaw)); - if (process.env.GITHUB_ENV) { // make available for following steps appendFileSync( @@ -222,50 +214,6 @@ const pump = (path: string, pattern: RegExp, replacement: string) => { file.write(update); }; -export interface ArchivedVersion { - label: string; - url: string; -} - -export const resolveArchivedVersions = ( - versions: ArchivedVersion[], - nextVersion: string, -): ArchivedVersion[] => { - const newVersion: ArchivedVersion = { - label: `v${nextVersion}`, - url: `https://docs.v${nextVersion}.archive.immich.app`, - }; - - let result = versions; - let lastVersion = asVersion(newVersion); - for (const item of versions) { - const version = asVersion(item); - // only keep the latest patch version for each minor release - if ( - lastVersion.major === version.major && - lastVersion.minor === version.minor && - lastVersion.patch >= version.patch - ) { - result = result.filter((item) => item.label !== version.label); - console.log( - `Removed ${version.label} (replaced with ${lastVersion.label})`, - ); - continue; - } - - lastVersion = version; - } - - return [newVersion, ...result]; -}; - -const asVersion = (item: ArchivedVersion) => { - const { label, url } = item; - const [version] = label.substring(1).split('-'); - const [major, minor, patch] = version.split('.').map(Number); - return { major, minor, patch, label, url }; -}; - const isPrerelease = (version: SemVer) => version.prerelease.length > 0; /** From 610bcfa6d09e6bfdb2bbaf16df8e77b83fc34356 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Wed, 12 Aug 2026 04:53:14 -0700 Subject: [PATCH 62/89] chore(mobile): add Cocoapods to mise (#30722) * chore(mobile): add Cocoapods to mise * Use new mise setup in CI --- .github/workflows/build-mobile.yml | 19 +++++++------------ mobile/mise.toml | 21 +++++++++++++++++++-- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index b2ba244ab9..6a9a01d41d 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -225,6 +225,13 @@ jobs: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile + - name: Setup Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: ./mobile/ios + - name: Install dependencies run: mise //mobile:install:ci @@ -235,18 +242,6 @@ jobs: working-directory: ./mobile run: flutter build ios --config-only --no-codesign - - name: Setup Ruby - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 - with: - ruby-version: '3.3' - bundler-cache: true - working-directory: ./mobile/ios - - - name: Install CocoaPods dependencies - working-directory: ./mobile/ios - run: | - pod install - - name: Create API Key env: API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} diff --git a/mobile/mise.toml b/mobile/mise.toml index 08a26c0c3b..f021cd8747 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -29,7 +29,7 @@ sources = [ ] outputs = { auto = true } run = [ - "dart run build_runner build", + "dart run build_runner build", "dart format lib/routing/router.gr.dart", ] depends = ["//:open-api-dart"] @@ -99,7 +99,11 @@ run = "dart run drift_dev make-migrations" alias = "install" description = "Install flutter dependencies" depends = ["//:open-api-dart"] -run = "flutter pub get" +run = [ + # This receives passed `--enforce-lockfile` args + "flutter pub get", + { task = "install:ios" }, +] [tasks."install:ci"] description = "Install flutter dependencies in CI" @@ -132,6 +136,19 @@ run = [ ] wait_for = ["//:i18n:format-fix"] +[tasks."install:ios"] +description = "Install CocoaPods dependencies" +hide = true +sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugin-dependencies"] +outputs = ["ios/Pods/Manifest.lock"] +run = ''' +echo "Running pod install" +if [ "$(uname)" != "Darwin" ]; then + exit 0 +fi +cd ios && pod install +''' + [tasks."analyze:dart"] description = "Run Dart analysis" hide = true From ce705dcc068146fca2c6ecb050ab94ec6b54cc85 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:30:06 +0530 Subject: [PATCH 63/89] fix: riverpod reactivity regression from lint fixes (#30734) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../lib/pages/backup/drift_backup.page.dart | 12 +-- .../pages/drift_album_options.page.dart | 10 +- .../pages/drift_slideshow.page.dart | 97 +++++++++++++------ .../pages/drift_user_selection.page.dart | 4 +- .../pages/search/drift_search.page.dart | 9 +- .../widgets/common/immich_sliver_app_bar.dart | 20 +++- 6 files changed, 100 insertions(+), 52 deletions(-) diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index ad2674b81b..a0e55e58de 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -316,12 +316,11 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final backupAlbums = ref.watch(backupAlbumProvider); + Widget buildSelectedAlbumName() { String text = context.t.backup_controller_page_backup_selected; - final albums = ref - .read(backupAlbumProvider) - .where((album) => album.backupSelection == BackupSelection.selected) - .toList(); + final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.selected).toList(); if (albums.isNotEmpty) { for (var album in albums) { @@ -352,10 +351,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { Widget buildExcludedAlbumName() { String text = context.t.backup_controller_page_excluded; - final albums = ref - .read(backupAlbumProvider) - .where((album) => album.backupSelection == BackupSelection.excluded) - .toList(); + final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.excluded).toList(); if (albums.isNotEmpty) { for (var album in albums) { diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 59cabf8daf..078b9cc325 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -32,6 +32,8 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { final userId = ref.watch(authProvider).userId; final activityEnabled = useState(album.isActivityEnabled); final isOwner = album.ownerId == userId; + final owner = isOwner ? ref.watch(currentUserProvider) : null; + final allUsers = isOwner ? null : ref.watch(driftUsersProvider); void showErrorMessage() { ContextHelper(context).pop(); @@ -141,7 +143,6 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { Widget buildOwnerInfo() { if (isOwner) { - final owner = ref.read(currentUserProvider); return ListTile( leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(), title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)), @@ -149,8 +150,11 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { trailing: Text(context.t.owner, style: context.textTheme.labelLarge), ); } else { - final usersProvider = ref.read(driftUsersProvider); - return usersProvider.maybeWhen( + if (allUsers == null) { + return const SizedBox(); + } + + return allUsers.maybeWhen( data: (users) { final user = users.firstWhereOrNull((u) => u.id == album.ownerId); diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart index 824d7d7c6b..2662c60a30 100644 --- a/mobile/lib/presentation/pages/drift_slideshow.page.dart +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -295,14 +296,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si color: context.colorScheme.primary, ); } else { - return LinearProgressIndicator( - color: context.colorScheme.primary, - borderRadius: BorderRadius.zero, - minHeight: 5, - value: - ref.read(videoPlayerProvider(asset.id).select((s) => s.position)).inMilliseconds / - asset.duration.inMilliseconds, - ); + return _VideoProgressBar(asset: asset); } } @@ -374,25 +368,13 @@ class _DriftSlideshowPageState extends ConsumerState with Si builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)), ); } else { - final status = ref.read(videoPlayerProvider(asset.id).select((s) => s.status)); - final position = ref.read(videoPlayerProvider(asset.id)).position; - - if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) { - unawaited(_nextPage()); - } else if (status == VideoPlaybackStatus.playing) { - unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); - } - - return PhotoView.customChild( - onTapUp: (_, _, _) => _onTapUp(), - disableScaleGestures: true, - filterQuality: FilterQuality.high, - initialScale: scale, - child: NativeVideoViewer( - asset: asset, - isCurrent: isCurrent, - image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), - ), + return _VideoChild( + asset: asset, + isCurrent: isCurrent, + scale: scale, + imageProvider: imageProvider, + onTapUp: _onTapUp, + onCompleted: _nextPage, ); } } @@ -477,6 +459,67 @@ class _DriftSlideshowPageState extends ConsumerState with Si } } +class _VideoChild extends ConsumerWidget { + final BaseAsset asset; + final bool isCurrent; + final PhotoViewComputedScale scale; + final ImageProvider imageProvider; + final VoidCallback onTapUp; + final VoidCallback onCompleted; + + const _VideoChild({ + required this.asset, + required this.isCurrent, + required this.scale, + required this.imageProvider, + required this.onTapUp, + required this.onCompleted, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) { + if (status == VideoPlaybackStatus.completed) { + if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) { + onCompleted(); + } + } else if (status == VideoPlaybackStatus.playing) { + unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); + } + }); + + return PhotoView.customChild( + onTapUp: (_, _, _) => onTapUp(), + disableScaleGestures: true, + filterQuality: FilterQuality.high, + initialScale: scale, + child: NativeVideoViewer( + asset: asset, + isCurrent: isCurrent, + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), + ), + ); + } +} + +class _VideoProgressBar extends ConsumerWidget { + final BaseAsset asset; + + const _VideoProgressBar({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position)); + + return LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: BorderRadius.zero, + minHeight: 5, + value: position.inMilliseconds / asset.duration.inMilliseconds, + ); + } +} + /// Progress bar for image slides, driven by an explicit [AnimationController]. /// /// [TweenAnimationBuilder] creates its controller internally with the default diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart index 215a5d1071..c11d5a1fd6 100644 --- a/mobile/lib/presentation/pages/drift_user_selection.page.dart +++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart @@ -52,6 +52,7 @@ class DriftUserSelectionPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final AsyncValue> suggestedShareUsers = ref.watch(driftUsersProvider); + final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id)); final sharedUsersList = useState>({}); void addNewUsersHandler() { @@ -136,9 +137,6 @@ class DriftUserSelectionPage extends HookConsumerWidget { ), body: suggestedShareUsers.widgetWhen( onData: (users) { - // Get shared users for this album from the database - final sharedUsers = ref.read(remoteAlbumSharedUsersProvider(album.id)); - return sharedUsers.when( data: (albumSharedUsers) { // Filter out users that are already shared with this album and the owner diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0c6b0d8ae4..dfcb5abd4d 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -742,9 +742,7 @@ class _SearchResultGrid extends ConsumerWidget { return false; } - Widget? _bottomWidget(BuildContext context, WidgetRef ref) { - final isLoading = ref.read(paginatedSearchProvider.select((s) => s.isLoading)); - + Widget? _bottomWidget(BuildContext context, {required bool isLoading, required bool hasMore}) { if (isLoading) { return const SliverFillRemaining( hasScrollBody: false, @@ -755,8 +753,6 @@ class _SearchResultGrid extends ConsumerWidget { ); } - final hasMore = ref.read(paginatedSearchProvider.select((s) => s.nextPage != null)); - if (hasMore) { return null; } @@ -778,6 +774,7 @@ class _SearchResultGrid extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty)); final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); + final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null)); if (!hasAssets && !isLoading) { return const _SearchNoResults(); @@ -807,7 +804,7 @@ class _SearchResultGrid extends ConsumerWidget { bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), snapToMonth: false, loadingWidget: const SizedBox.shrink(), - bottomSliverWidget: _bottomWidget(context, ref), + bottomSliverWidget: _bottomWidget(context, isLoading: isLoading, hasMore: hasMore), ), ), ), diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 4c6b2a7211..6a119f8ca3 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -177,7 +177,15 @@ class _BackupIndicator extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final indicatorIcon = _getBackupBadgeIcon(context, ref); + final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled)); + final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none)); + final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); + final indicatorIcon = _getBackupBadgeIcon( + context, + backupEnabled: backupEnabled, + hasError: hasError, + isUploading: isUploading, + ); return IconButton( onPressed: () => context.pushRoute(const DriftBackupRoute()), @@ -192,12 +200,14 @@ class _BackupIndicator extends ConsumerWidget { ); } - Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) { - final backupEnabled = ref.read(appConfigProvider.select((c) => c.backup.enabled)); - final hasError = ref.read(driftBackupProvider.select((state) => state.error != BackupError.none)); + Widget? _getBackupBadgeIcon( + BuildContext context, { + required bool backupEnabled, + required bool hasError, + required bool isUploading, + }) { final isDarkTheme = context.isDarkTheme; final iconColor = isDarkTheme ? Colors.white : Colors.black; - final isUploading = ref.read(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); if (!backupEnabled) { return _BadgeLabel( From 943c11c0196e7424e5e488131a2fb7f7522037ed Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:32:08 +0530 Subject: [PATCH 64/89] chore: label local only delete as trash (#30726) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/presentation/actions/delete.action.dart | 3 ++- .../unit/presentation/actions/delete_action_test.dart | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index ea7423354a..23a2ef2a2f 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -37,7 +37,8 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); // Assets already in the trash or in the locked folder are deleted outright, irrespective of the server setting. - final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked); + final trash = + ownedRemote.isEmpty || (trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked)); return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash); }, dependencies: [assetsActionProvider]); diff --git a/mobile/test/unit/presentation/actions/delete_action_test.dart b/mobile/test/unit/presentation/actions/delete_action_test.dart index 29d81a5c2c..4c1bf9306c 100644 --- a/mobile/test/unit/presentation/actions/delete_action_test.dart +++ b/mobile/test/unit/presentation/actions/delete_action_test.dart @@ -170,6 +170,17 @@ void main() { verifyNever(() => assetService.trash(any())); verifyNever(() => assetService.delete(any())); }); + + testWidgets('is labelled trash', (tester) async { + await tester.pumpTestWidget( + context, + const ActionButton(action: DeleteAction(source: .timeline)), + overrides: context.selected({LocalAssetFactory.create()}), + ); + + expect(find.text(StaticTranslations.instance.trash), findsOneWidget); + expect(find.text(StaticTranslations.instance.delete), findsNothing); + }); }); group('prompt handling', () { From 52edcc0c74c2430418f88fe94700b549dac84e90 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:12:11 +0530 Subject: [PATCH 65/89] refactor: unify person model (#30659) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/models/person.model.dart | 29 ++----------------- .../lib/domain/services/people.service.dart | 6 ++-- .../repositories/people.repository.dart | 21 +++----------- .../models/search/search_filter.model.dart | 4 +-- .../presentation/pages/drift_person.page.dart | 4 +-- .../pages/search/drift_search.page.dart | 2 +- .../asset_details/people_details.widget.dart | 4 +-- .../person_edit_birthday_modal.widget.dart | 2 +- .../people/person_edit_name_modal.widget.dart | 2 +- .../infrastructure/people.provider.dart | 4 +-- .../lib/providers/search/people.provider.dart | 2 +- .../repositories/person_api.repository.dart | 14 +++------ mobile/lib/services/person.service.dart | 4 +-- mobile/lib/utils/people.utils.dart | 4 +-- .../widgets/common/person_sliver_app_bar.dart | 4 +-- .../search/search_filter/people_picker.dart | 6 ++-- 16 files changed, 34 insertions(+), 78 deletions(-) diff --git a/mobile/lib/domain/models/person.model.dart b/mobile/lib/domain/models/person.model.dart index 56e6d3990d..94ead6a2e9 100644 --- a/mobile/lib/domain/models/person.model.dart +++ b/mobile/lib/domain/models/person.model.dart @@ -3,32 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'person.model.freezed.dart'; -// TODO: Remove PersonDto once Isar is removed @freezed -abstract class PersonDto with _$PersonDto { - const factory PersonDto({ - required String id, - DateTime? birthDate, - required bool isHidden, - required String name, - required String thumbnailPath, - DateTime? updatedAt, - }) = _PersonDto; -} - -// Model for a person stored in the server -@freezed -abstract class DriftPerson with _$DriftPerson { - const factory DriftPerson({ - required String id, - required DateTime createdAt, - required DateTime updatedAt, - required String ownerId, - required String name, - String? faceAssetId, - required bool isFavorite, - required bool isHidden, - required String? color, - DateTime? birthDate, - }) = _DriftPerson; +abstract class Person with _$Person { + const factory Person({required String id, required String name, DateTime? updatedAt, DateTime? birthDate}) = _Person; } diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart index 73972dbdc0..e86d43b283 100644 --- a/mobile/lib/domain/services/people.service.dart +++ b/mobile/lib/domain/services/people.service.dart @@ -10,15 +10,15 @@ class DriftPeopleService { const DriftPeopleService(this._repository, this._personApiRepository); - Future get(String personId) { + Future get(String personId) { return _repository.get(personId); } - Future> getAssetPeople(String assetId) { + Future> getAssetPeople(String assetId) { return _repository.getAssetPeople(assetId); } - Future> getAllPeople({int minFaces = 3}) { + Future> getAllPeople({int minFaces = 3}) { return _repository.getAllPeople(minFaces: minFaces); } diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index 0dd73cc81d..b539c6b2fd 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -8,14 +8,14 @@ class DriftPeopleRepository extends DriftDatabaseRepository { final Drift _db; const DriftPeopleRepository(this._db) : super(_db); - Future get(String personId) async { + Future get(String personId) async { final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId)); final result = await query.getSingleOrNull(); return result?.toDto(); } - Future> getAssetPeople(String assetId) async { + Future> getAssetPeople(String assetId) async { // An asset can have multiple face records for the same person (e.g., metadata // imports alongside ML detections). Use a subquery instead of a join so each // person is returned once, regardless of how many of their faces are on the asset @@ -33,7 +33,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } - Future> getAllPeople({int minFaces = 3}) async { + Future> getAllPeople({int minFaces = 3}) async { final people = _db.personEntity; final faces = _db.assetFaceEntity; final assets = _db.remoteAssetEntity; @@ -76,18 +76,5 @@ class DriftPeopleRepository extends DriftDatabaseRepository { } extension on PersonEntityData { - DriftPerson toDto() { - return DriftPerson( - id: id, - createdAt: createdAt, - updatedAt: updatedAt, - ownerId: ownerId, - name: name, - faceAssetId: faceAssetId, - isFavorite: isFavorite, - isHidden: isHidden, - color: color, - birthDate: birthDate, - ); - } + Person toDto() => Person(id: id, updatedAt: updatedAt, name: name, birthDate: birthDate); } diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 7e5e1b819e..a9cc13ba6a 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -194,7 +194,7 @@ class SearchFilter { String? language; String? assetId; List? tagIds; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -250,7 +250,7 @@ class SearchFilter { String? language, String? ocr, String? assetId, - Set? people, + Set? people, List? tagIds, SearchLocationFilter? location, SearchCameraFilter? camera, diff --git a/mobile/lib/presentation/pages/drift_person.page.dart b/mobile/lib/presentation/pages/drift_person.page.dart index 7389f506d5..cad3b7b6a6 100644 --- a/mobile/lib/presentation/pages/drift_person.page.dart +++ b/mobile/lib/presentation/pages/drift_person.page.dart @@ -14,7 +14,7 @@ import 'package:immich_mobile/widgets/common/person_sliver_app_bar.dart'; @RoutePage() class DriftPersonPage extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonPage({super.key, required this.person}); @@ -23,7 +23,7 @@ class DriftPersonPage extends ConsumerStatefulWidget { } class _DriftPersonPageState extends ConsumerState { - late DriftPerson _person; + late Person _person; @override void initState() { diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index dfcb5abd4d..e364e0d2f8 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -129,7 +129,7 @@ class DriftSearchPage extends HookConsumerWidget { void showPeoplePicker() { var people = filter.value.people; - void handleOnSelect(Set value) { + void handleOnSelect(Set value) { people = value; } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 739fe0a0d9..00c20f35bd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -30,7 +30,7 @@ class PeopleDetails extends ConsumerWidget { final peopleFuture = ref.watch(driftPeopleAssetProvider(asset.id)); - Future showNameEditModal(DriftPerson person) async { + Future showNameEditModal(Person person) async { await showDialog( context: context, useRootNavigator: false, @@ -96,7 +96,7 @@ class PeopleDetails extends ConsumerWidget { } class _Avatar extends StatelessWidget { - final DriftPerson person; + final Person person; final DateTime assetFileCreatedAt; final VoidCallback? onTap; final VoidCallback? onNameTap; diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart index 0b8a08a5d8..a4903c7a29 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:scroll_date_picker/scroll_date_picker.dart'; class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonBirthdayEditForm({super.key, required this.person}); diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart index 3b0e62ab1a..5e8c634181 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class DriftPersonNameEditForm extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonNameEditForm({super.key, required this.person}); diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart index 1939940975..73a35958bd 100644 --- a/mobile/lib/providers/infrastructure/people.provider.dart +++ b/mobile/lib/providers/infrastructure/people.provider.dart @@ -14,12 +14,12 @@ final driftPeopleServiceProvider = Provider( (ref) => DriftPeopleService(ref.watch(driftPeopleRepositoryProvider), ref.watch(personApiRepositoryProvider)), ); -final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async { +final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async { final service = ref.watch(driftPeopleServiceProvider); return service.getAssetPeople(assetId); }); -final driftGetAllPeopleProvider = FutureProvider>((ref) async { +final driftGetAllPeopleProvider = FutureProvider>((ref) async { final service = ref.watch(driftPeopleServiceProvider); final prefs = await ref.watch(userMetadataPreferencesProvider.future); return service.getAllPeople(minFaces: prefs?.minimumFaces ?? 3); diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart index 1bd58509f5..f9fc1ec133 100644 --- a/mobile/lib/providers/search/people.provider.dart +++ b/mobile/lib/providers/search/people.provider.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/services/person.service.dart'; -final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async { +final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async { final PersonService personService = ref.read(personServiceProvider); final people = await personService.getAllPeople(); diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 158e3ac333..5804e1da3b 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -11,12 +11,12 @@ class PersonApiRepository extends ApiRepository { PersonApiRepository(this._api); - Future> getAll() async { + Future> getAll() async { final dto = await checkNull(_api.getAllPeople()); return dto.people.map(_toPerson).toList(); } - Future update(String id, {String? name, DateTime? birthday}) async { + Future update(String id, {String? name, DateTime? birthday}) async { final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day); final dto = PersonUpdateDto( name: name == null ? const Optional.absent() : Optional.present(name), @@ -26,12 +26,6 @@ class PersonApiRepository extends ApiRepository { return _toPerson(response); } - static PersonDto _toPerson(PersonResponseDto dto) => PersonDto( - birthDate: dto.birthDate, - id: dto.id, - isHidden: dto.isHidden, - name: dto.name, - thumbnailPath: dto.thumbnailPath, - updatedAt: dto.updatedAt.orElse(null), - ); + static Person _toPerson(PersonResponseDto dto) => + .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.value); } diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart index 0d589ea71d..6a9226a893 100644 --- a/mobile/lib/services/person.service.dart +++ b/mobile/lib/services/person.service.dart @@ -12,7 +12,7 @@ class PersonService { final PersonApiRepository _personApiRepository; PersonService(this._personApiRepository); - Future> getAllPeople() async { + Future> getAllPeople() async { try { return await _personApiRepository.getAll(); } catch (error, stack) { @@ -21,7 +21,7 @@ class PersonService { } } - Future updateName(String id, String name) async { + Future updateName(String id, String name) async { try { return await _personApiRepository.update(id, name: name); } catch (error, stack) { diff --git a/mobile/lib/utils/people.utils.dart b/mobile/lib/utils/people.utils.dart index 8da5a5a3eb..bd2b607e0b 100644 --- a/mobile/lib/utils/people.utils.dart +++ b/mobile/lib/utils/people.utils.dart @@ -34,7 +34,7 @@ int _calculateAgeInMonths(DateTime birthDate, DateTime referenceDate) { (referenceDate.day < birthDate.day ? 1 : 0); } -Future showNameEditModal(BuildContext context, DriftPerson person) { +Future showNameEditModal(BuildContext context, Person person) { return showDialog( context: context, useRootNavigator: false, @@ -44,7 +44,7 @@ Future showNameEditModal(BuildContext context, DriftPerson person) { ); } -Future showBirthdayEditModal(BuildContext context, DriftPerson person) { +Future showBirthdayEditModal(BuildContext context, Person person) { return showDialog( context: context, useRootNavigator: false, diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index 4b159447e6..56eb3c6094 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -28,7 +28,7 @@ class PersonSliverAppBar extends ConsumerStatefulWidget { required this.onBirthdayTap, }); - final DriftPerson person; + final Person person; final VoidCallback onNameTap; final VoidCallback onBirthdayTap; final VoidCallback onShowOptions; @@ -137,7 +137,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState { class _ExpandedBackground extends ConsumerStatefulWidget { final double scrollProgress; - final DriftPerson person; + final Person person; final VoidCallback onNameTap; final VoidCallback onBirthdayTap; diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index b4e73e97f9..1995851904 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -15,8 +15,8 @@ import 'package:immich_mobile/widgets/common/search_field.dart'; class PeoplePicker extends HookConsumerWidget { const PeoplePicker({super.key, required this.onSelect, this.filter}); - final Function(Set) onSelect; - final Set? filter; + final Function(Set) onSelect; + final Set? filter; @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,7 +24,7 @@ class PeoplePicker extends HookConsumerWidget { const imageSize = 60.0; final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>(filter ?? {}); return Column( children: [ From 303a9f15b1a78e2aeb3c4195f6a97608152eb799 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:12:12 +0530 Subject: [PATCH 66/89] refactor: reactive driftGetAllPeopleProvider (#30660) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/services/people.service.dart | 6 +++--- .../infrastructure/repositories/people.repository.dart | 4 ++-- mobile/lib/pages/common/tab_shell.page.dart | 2 -- .../people/person_edit_birthday_modal.widget.dart | 9 ++------- .../widgets/people/person_edit_name_modal.widget.dart | 7 +------ mobile/lib/providers/infrastructure/people.provider.dart | 4 ++-- 6 files changed, 10 insertions(+), 22 deletions(-) diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart index e86d43b283..196cf1edbb 100644 --- a/mobile/lib/domain/services/people.service.dart +++ b/mobile/lib/domain/services/people.service.dart @@ -18,8 +18,8 @@ class DriftPeopleService { return _repository.getAssetPeople(assetId); } - Future> getAllPeople({int minFaces = 3}) { - return _repository.getAllPeople(minFaces: minFaces); + Stream> watch({int minFaces = 3}) { + return _repository.watch(minFaces: minFaces); } Future updateName(String personId, String name) async { @@ -27,7 +27,7 @@ class DriftPeopleService { return _repository.updateName(personId, name); } - Future updateBrithday(String personId, DateTime birthday) async { + Future updateBirthday(String personId, DateTime birthday) async { await _personApiRepository.update(personId, birthday: birthday); return _repository.updateBirthday(personId, birthday); } diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index b539c6b2fd..c5c7bfdb9c 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -33,7 +33,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } - Future> getAllPeople({int minFaces = 3}) async { + Stream> watch({int minFaces = 3}) { final people = _db.personEntity; final faces = _db.assetFaceEntity; final assets = _db.remoteAssetEntity; @@ -59,7 +59,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) { final person = row.readTable(people); return person.toDto(); - }).get(); + }).watch(); } Future updateName(String personId, String name) { diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 5b620e9c91..240c4abe7a 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/pages/search/paginated_search.provide import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; @@ -132,7 +131,6 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { // Library page if (index == kLibraryTabIndex) { ref.invalidate(localAlbumProvider); - ref.invalidate(driftGetAllPeopleProvider); } ref.read(hapticFeedbackProvider.notifier).selectionClick(); diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart index a4903c7a29..e56a21e021 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart @@ -30,14 +30,9 @@ class _DriftPersonNameEditFormState extends ConsumerState saveBirthday() async { try { - final result = await ref.read(driftPeopleServiceProvider).updateBrithday(widget.person.id, _selectedDate); - - if (result != 0) { - ref.invalidate(driftGetAllPeopleProvider); - if (!mounted) { - return; - } + final result = await ref.read(driftPeopleServiceProvider).updateBirthday(widget.person.id, _selectedDate); + if (result != 0 && mounted) { context.pop(_selectedDate); } } catch (error) { diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart index 5e8c634181..4ae41bc640 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart @@ -29,12 +29,7 @@ class _DriftPersonNameEditFormState extends ConsumerState onEdit(String personId, String newName) async { try { final result = await ref.read(driftPeopleServiceProvider).updateName(personId, newName); - if (result != 0) { - ref.invalidate(driftGetAllPeopleProvider); - if (!mounted) { - return; - } - + if (result != 0 && mounted) { context.pop(newName); } } catch (error) { diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart index 73a35958bd..26b145255c 100644 --- a/mobile/lib/providers/infrastructure/people.provider.dart +++ b/mobile/lib/providers/infrastructure/people.provider.dart @@ -19,8 +19,8 @@ final driftPeopleAssetProvider = FutureProvider.family, String>((re return service.getAssetPeople(assetId); }); -final driftGetAllPeopleProvider = FutureProvider>((ref) async { +final driftGetAllPeopleProvider = StreamProvider>((ref) async* { final service = ref.watch(driftPeopleServiceProvider); final prefs = await ref.watch(userMetadataPreferencesProvider.future); - return service.getAllPeople(minFaces: prefs?.minimumFaces ?? 3); + yield* service.watch(minFaces: prefs?.minimumFaces ?? 3); }); From 1c3a5cf5087182a929b16d71460e093af5b844a6 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:12:12 +0530 Subject: [PATCH 67/89] chore: remove old people provider (#30662) * chore: remove old people provider * refactor: rename driftGetAllPeopleProvider * minor fixes --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../pages/drift_library.page.dart | 2 +- .../pages/drift_people_collection.page.dart | 2 +- .../infrastructure/people.provider.dart | 2 +- .../lib/providers/search/people.provider.dart | 24 -------------- .../repositories/person_api.repository.dart | 7 +--- mobile/lib/services/person.service.dart | 32 ------------------- .../search/search_filter/people_picker.dart | 4 +-- 7 files changed, 6 insertions(+), 67 deletions(-) delete mode 100644 mobile/lib/providers/search/people.provider.dart delete mode 100644 mobile/lib/services/person.service.dart diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 3118f2c4f0..137f46659f 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -145,7 +145,7 @@ class _PeopleCollectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final people = ref.watch(driftGetAllPeopleProvider); + final people = ref.watch(getAllPeopleProvider); return LayoutBuilder( builder: (context, constraints) { diff --git a/mobile/lib/presentation/pages/drift_people_collection.page.dart b/mobile/lib/presentation/pages/drift_people_collection.page.dart index cb305395cc..569cb99b01 100644 --- a/mobile/lib/presentation/pages/drift_people_collection.page.dart +++ b/mobile/lib/presentation/pages/drift_people_collection.page.dart @@ -33,7 +33,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState, String>((re return service.getAssetPeople(assetId); }); -final driftGetAllPeopleProvider = StreamProvider>((ref) async* { +final getAllPeopleProvider = StreamProvider>((ref) async* { final service = ref.watch(driftPeopleServiceProvider); final prefs = await ref.watch(userMetadataPreferencesProvider.future); yield* service.watch(minFaces: prefs?.minimumFaces ?? 3); diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart deleted file mode 100644 index f9fc1ec133..0000000000 --- a/mobile/lib/providers/search/people.provider.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/services/person.service.dart'; - -final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async { - final PersonService personService = ref.read(personServiceProvider); - - final people = await personService.getAllPeople(); - - return people; -}); - -final updatePersonNameProvider = FutureProvider.autoDispose( - (ref) => (String personId, String updatedName) async { - final PersonService personService = ref.read(personServiceProvider); - final person = await personService.updateName(personId, updatedName); - - if (person != null && person.name == updatedName) { - ref.invalidate(getAllPeopleProvider); - return true; - } - return false; - }, -); diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 5804e1da3b..262868eea1 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -11,11 +11,6 @@ class PersonApiRepository extends ApiRepository { PersonApiRepository(this._api); - Future> getAll() async { - final dto = await checkNull(_api.getAllPeople()); - return dto.people.map(_toPerson).toList(); - } - Future update(String id, {String? name, DateTime? birthday}) async { final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day); final dto = PersonUpdateDto( @@ -27,5 +22,5 @@ class PersonApiRepository extends ApiRepository { } static Person _toPerson(PersonResponseDto dto) => - .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.value); + .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.orElse(null)); } diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart deleted file mode 100644 index 6a9226a893..0000000000 --- a/mobile/lib/services/person.service.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/repositories/person_api.repository.dart'; -import 'package:logging/logging.dart'; - -final personServiceProvider = Provider.autoDispose( - (ref) => PersonService(ref.watch(personApiRepositoryProvider)), -); - -class PersonService { - final Logger _log = Logger("PersonService"); - final PersonApiRepository _personApiRepository; - PersonService(this._personApiRepository); - - Future> getAllPeople() async { - try { - return await _personApiRepository.getAll(); - } catch (error, stack) { - _log.severe("Error while fetching curated people", error, stack); - return []; - } - } - - Future updateName(String id, String name) async { - try { - return await _personApiRepository.update(id, name: name); - } catch (error, stack) { - _log.severe("Error while updating person name", error, stack); - } - return null; - } -} diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index 1995851904..ee738265b2 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/providers/search/people.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; @@ -65,7 +65,7 @@ class PeoplePicker extends HookConsumerWidget { padding: const EdgeInsets.only(bottom: 2.0), child: LargeLeadingTile( title: Text( - person.name, + person.name.nullIfEmpty ?? context.t.no_name, style: context.textTheme.bodyLarge?.copyWith( fontSize: 20, fontWeight: FontWeight.w500, From 2a1691868e71aec6bd66eee82d1a6eb2e7aec636 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:01:30 +0200 Subject: [PATCH 68/89] chore: use import.meta.dirname instead of __dirname (#30738) --- e2e/eslint.config.mjs | 7 +------ packages/cli/eslint.config.mjs | 7 +------ server/eslint.config.mjs | 7 +------ web/eslint.config.js | 10 +++------- web/vite.config.ts | 4 ++-- 5 files changed, 8 insertions(+), 27 deletions(-) diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs index d5da8f8445..3d69d1f503 100644 --- a/e2e/eslint.config.mjs +++ b/e2e/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index bcc74c654d..cd32bf6023 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 2c11b7d3cb..949acaf139 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/web/eslint.config.js b/web/eslint.config.js index 5974eaf68f..fc69385c93 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -10,10 +10,6 @@ import parser from 'svelte-eslint-parser'; import typescriptEslint from 'typescript-eslint'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); export default typescriptEslint.config( ...eslintPluginSvelte.configs.recommended, @@ -29,7 +25,7 @@ export default typescriptEslint.config( 'error', { browserslist: fs - .readFileSync(path.join(__dirname, '.browserslistrc'), 'utf8') + .readFileSync(path.join(import.meta.dirname, '.browserslistrc'), 'utf8') .split('\n') .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')), @@ -40,7 +36,7 @@ export default typescriptEslint.config( parser, parserOptions: { project: ['./tsconfig.json'], - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, // ignores: ['**/service-worker/**'], @@ -96,7 +92,7 @@ export default typescriptEslint.config( parserOptions: { extraFileExtensions: ['.svelte'], - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, project: ['./tsconfig.json'], }, }, diff --git a/web/vite.config.ts b/web/vite.config.ts index c2b2b5bf25..07e7efaf2a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -28,8 +28,8 @@ export default defineConfig({ alias: { 'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js', // eslint-disable-next-line unicorn/prefer-module - '@test-data': path.resolve(__dirname, './src/test-data'), - // '@immich/ui': path.resolve(__dirname, '../../ui/packages/ui'), + '@test-data': path.resolve(import.meta.dirname, './src/test-data'), + // '@immich/ui': path.resolve(import.meta.dirname, '../../ui/packages/ui'), }, }, server: { From ff5da0f84fc79d3167fbb6a6a193ed53dc36c987 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:00:08 +0530 Subject: [PATCH 69/89] chore: immich_mobile path in openapi pubspec (#30643) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- open-api/patch/pubspec_immich_mobile.yaml.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-api/patch/pubspec_immich_mobile.yaml.patch b/open-api/patch/pubspec_immich_mobile.yaml.patch index ad3bbc2edd..be210f864f 100644 --- a/open-api/patch/pubspec_immich_mobile.yaml.patch +++ b/open-api/patch/pubspec_immich_mobile.yaml.patch @@ -6,4 +6,4 @@ -dev_dependencies: - test: '>=1.21.6 <1.22.0' + immich_mobile: -+ path: ../ ++ path: ../../ From db9e7c20d71043a5a5a438781e3d9bebcf35bf97 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Wed, 12 Aug 2026 11:30:05 -0700 Subject: [PATCH 70/89] chore(mobile): add a single mise checkout command for reoccuring tasks (#30737) * chore(mobile): add a single mise checkout command for reoccuring tasks * Fix .flutter-plugins-dependencies path Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> --- mobile/mise.toml | 14 ++++++++- mobile/scripts/checkout_ios.sh | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 mobile/scripts/checkout_ios.sh diff --git a/mobile/mise.toml b/mobile/mise.toml index f021cd8747..d8ddc8ab21 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -111,6 +111,18 @@ run = [ { task = "install", args = ["--enforce-lockfile"] }, ] +[tasks.checkout] +description = "Bring a checkout up to date after switching branches" +run = [ + { task = "install" }, + { task = "checkout:ios" }, + { task = "codegen" }, +] + +[tasks."checkout:ios"] +description = "Bring the iOS project up to date after switching branches" +run = "bash ./scripts/checkout_ios.sh" + [tasks.start] alias = "start" description = "Start flutter app" @@ -139,7 +151,7 @@ wait_for = ["//:i18n:format-fix"] [tasks."install:ios"] description = "Install CocoaPods dependencies" hide = true -sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugin-dependencies"] +sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugins-dependencies"] outputs = ["ios/Pods/Manifest.lock"] run = ''' echo "Running pod install" diff --git a/mobile/scripts/checkout_ios.sh b/mobile/scripts/checkout_ios.sh new file mode 100644 index 0000000000..74706e0d23 --- /dev/null +++ b/mobile/scripts/checkout_ios.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Reconciles the iOS project with the current Flutter setup +# +# `flutter build ios --config-only` is relatively expensive, so we attempt to avoid running it when possible + +set -euo pipefail + +stamp="build/ios_checkout.stamp" + +# All tracked files that can invalidate the Xcode + Flutter build state +inputs_digest() { + git ls-files -z ios pubspec.yaml pubspec.lock mise.toml mise.lock \ + | xargs -0 shasum \ + | shasum \ + | cut -d ' ' -f 1 +} + +# Echoes why the iOS project needs reconciling, or returns 1 if it does not. +# The first three cases can happen with no tracked file having changed at all, +# so comparing the digest alone is not enough. +staleness() { + local flutter_root + flutter_root="$(sed -n 's/^FLUTTER_ROOT=//p' ios/Flutter/Generated.xcconfig 2>/dev/null || true)" + + if [[ -z $flutter_root ]]; then + echo "misconfigured FLUTTER_ROOT" + elif [[ ! -d $flutter_root ]]; then + echo "the Flutter SDK has changed" + elif ! cmp -s ios/Podfile.lock ios/Pods/Manifest.lock; then + # This is also done by Xcode's "Check Pods Manifest.lock" build phase + echo "Cocoapods out of sync" + elif [[ ! -f $stamp ]]; then + echo "first checkout" + elif [[ $(<"$stamp") != "$(inputs_digest)" ]]; then + echo "tracked build state has changed" + else + return 1 + fi +} + +[[ $(uname) == Darwin ]] || exit 0 + +if ! reason="$(staleness)"; then + exit 0 +fi + +echo "Reconciling iOS project: $reason" +flutter build ios --config-only --debug + +# Save tracked files manifest +mkdir -p "$(dirname "$stamp")" +inputs_digest > "$stamp" From b82d4805525f68dbb07e160163e2a582e9dfab91 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Wed, 12 Aug 2026 12:57:45 -0700 Subject: [PATCH 71/89] fix(mobile): prevent iOS status bar scroll to top during transitions (#30717) --- mobile/lib/main.dart | 2 +- .../widgets/timeline/timeline.widget.dart | 10 +++++++++- .../lib/routing/app_navigation_observer.dart | 19 +++++++++++++++++++ .../timeline/timeline_scroll_to_top_test.dart | 14 +++++++++++--- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index d2fd945cbf..2f1cfdaf6a 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -277,7 +277,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve ), routerConfig: router.config( deepLinkBuilder: _deepLinkBuilder, - navigatorObservers: () => [AppNavigationObserver(ref: ref)], + navigatorObservers: () => [AppNavigationObserver(ref: ref), TransitioningRouteObserver()], ), ), ); diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 9e65dcb72c..c41f88fcc0 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; 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'; @@ -190,7 +191,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi // may be in a background tab. In either case, `handleStatusBarTap()` still fires // Make sure the timeline is the primary route before scrolling to the top final routeData = context.findAncestorWidgetOfExactType()?.routeData; - if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true) { + // The tap is generated async, so it can arrive after a route pop has started (due to a back button or similar) + // Check if route is alive and not exiting before taking action + final observers = Navigator.maybeOf(context)?.widget.observers ?? const []; + final isRouteTransitioning = observers.whereType().any( + (observer) => observer.hasTransitioningRoute, + ); + + if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true && !isRouteTransitioning) { _scrollToTop(); } } diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index a7f6d532e9..90381cf49c 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -30,3 +30,22 @@ class AppNavigationObserver extends AutoRouterObserver { ref.invalidate(isAssetViewerOpenProvider); } } + +/// Tracks routes that are undergoing a pop transition +class TransitioningRouteObserver extends NavigatorObserver { + int _transitioningRoutes = 0; + + /// Whether a "popping" route is still on screen + bool get hasTransitioningRoute => _transitioningRoutes > 0; + + @override + void didPop(Route route, Route? previousRoute) { + if (route is! TransitionRoute) { + return; + } + + _transitioningRoutes += 1; + // Transition completed and route disposed + unawaited(route.completed.whenComplete(() => _transitioningRoutes -= 1)); + } +} diff --git a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart index 28644d6702..f72af61a0a 100644 --- a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart +++ b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import '../../../fixtures/asset.stub.dart'; @@ -62,7 +63,9 @@ void main() { timelineServiceProvider.overrideWithValue(service), appConfigProvider.overrideWithValue(const AppConfig()), ], - child: MaterialApp.router(routerConfig: router.config()), + child: MaterialApp.router( + routerConfig: router.config(navigatorObservers: () => [TransitioningRouteObserver()]), + ), ), ); // Segment stream resolves @@ -96,9 +99,14 @@ void main() { await tester.pumpAndSettle(); expect(position.pixels, initialScrolledPosition, reason: 'ignored while behind a pushed route'); - // Once the timeline is visible, taps on the status bar should scroll to the top - await router.maybePop(); + // The tap is async, so it can arrive while a pushed route is transitioning to popped + unawaited(router.maybePop()); + await tester.pump(); + await tapStatusBar(); await tester.pumpAndSettle(); + expect(position.pixels, initialScrolledPosition, reason: 'ignored while the pushed route pops'); + + // Once the timeline is visible, taps on the status bar should scroll to the top await tapStatusBar(); await tester.pumpAndSettle(); expect(position.pixels, 0, reason: 'scrolls the foreground timeline'); From a939561e70f437dc7ab3ee2d8b859cc654d6cb3e Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 12 Aug 2026 16:26:03 -0700 Subject: [PATCH 72/89] feat: workflow asset tag trigger/filter/action (#29043) * feat: workflow asset tag trigger and filter * feat(web): show tag names in workflow editor * fix(web): tag picker in schema config editor * fix: invalid plugin manifest * chore: update workflow method wrapper type * chore: update tag filter method declaration * feat: workflow action to add tags to assets --- i18n/en.json | 2 + open-api/immich-openapi-specs.json | 3 +- packages/plugin-core/manifest.json | 50 +++++++++++++++++++ packages/plugin-core/src/index.ts | 27 ++++++++++ packages/plugin-sdk/src/host-functions.ts | 9 ++++ packages/plugin-sdk/src/types.ts | 9 +++- packages/sdk/src/fetch-client.ts | 3 +- server/src/repositories/event.repository.ts | 2 +- .../src/repositories/workflow.repository.ts | 2 + server/src/services/tag.service.ts | 4 +- .../services/workflow-execution.service.ts | 13 +++++ server/src/utils/workflow.ts | 1 + .../lib/components/SchemaConfiguration.svelte | 3 ++ web/src/lib/components/SchemaTagPicker.svelte | 32 ++++++++++++ web/src/lib/types.ts | 2 +- web/src/lib/utils/workflow.ts | 9 ++++ .../[workflowId]/WorkflowStepCard.svelte | 12 ++++- 17 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/components/SchemaTagPicker.svelte diff --git a/i18n/en.json b/i18n/en.json index 536b7677ac..1022a6f851 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2139,6 +2139,8 @@ "trigger": "Trigger", "trigger_asset_metadata_extraction": "Asset Metadata Extraction", "trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted", + "trigger_asset_tagged": "Asset Tagged", + "trigger_asset_tagged_description": "Triggered when a tag is added to an asset", "trigger_asset_uploaded": "Asset Upload", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", "trigger_person_recognized": "Person Recognized", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bc3bf82094..3596eaf4b1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -28066,7 +28066,8 @@ "description": "Plugin trigger type", "enum": [ "AssetCreate", - "AssetMetadataExtraction" + "AssetMetadataExtraction", + "AssetTagged" ], "type": "string" }, diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index a72d60c04d..d7f7830a0e 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -308,6 +308,56 @@ }, "uiHints": ["Filter"] }, + { + "name": "assetAddTags", + "title": "Add Tags", + "description": "Add tags to an asset", + "types": ["AssetV1"], + "hostFunctions": true, + "schema": { + "type": "object", + "properties": { + "tags": { + "title": "Tags", + "description": "Tags to add to the asset", + "type": "string", + "array": true, + "uiHint": { + "type": "TagId" + } + } + }, + "required": ["tags"] + } + }, + { + "name": "assetTagFilter", + "title": "Filter by tags", + "description": "Filter assets by which tags they have", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "tags": { + "title": "Tags", + "description": "Which tags the asset must have", + "type": "string", + "array": true, + "uiHint": { + "type": "TagId" + } + }, + "matching": { + "title": "Matching", + "description": "Whether assets must have all, any, or none of the listed tags", + "type": "string", + "enum": ["all", "any", "none"] + } + }, + "required": ["tags", "matching"] + }, + "uiHints": ["Filter"] + }, { "name": "assetExifFilter", "title": "Filter by EXIF metadata", diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 7b91ed7111..251393ace5 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -39,6 +39,11 @@ const matchValueResult = (value: string, config: MatchValueConfig) => { }; const methods = wrapper({ + assetAddTags: ({ config, data, functions }) => { + functions.bulkTagAssets({ assetIds: [data.asset.id], tagIds: config.tags }); + return {}; + }, + assetAddToAlbums: ({ config, data, functions }) => { const assetId = data.asset.id; @@ -176,6 +181,24 @@ const methods = wrapper({ return { workflow: { continue: hasTimeZone === needsTimeZone } }; }, + assetTagFilter: ({ config, data }) => { + const assetTags = data.asset.tags.map((tag) => tag.id); + + for (const tag of config.tags) { + if (assetTags.includes(tag)) { + if (config.matching === 'any') { + break; + } else if (config.matching === 'none') { + return { workflow: { continue: false } }; + } + } else if (config.matching === 'all') { + return { workflow: { continue: false } }; + } + } + + return { workflow: { continue: true } }; + }, + assetTypeFilter: ({ config, data }) => { return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; }, @@ -208,6 +231,7 @@ const methods = wrapper({ }); const { + assetAddTags, assetAddToAlbums, assetArchive, assetFavorite, @@ -217,6 +241,7 @@ const { assetDateFilter, assetLock, assetMissingTimeZoneFilter, + assetTagFilter, assetTypeFilter, assetVisibility, webhook, @@ -226,6 +251,7 @@ const { } = methods; export { + assetAddTags, assetAddToAlbums, assetArchive, assetFavorite, @@ -235,6 +261,7 @@ export { assetDateFilter, assetLock, assetMissingTimeZoneFilter, + assetTagFilter, assetTypeFilter, assetVisibility, webhook, diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts index 9d52630438..e0b6e34da9 100644 --- a/packages/plugin-sdk/src/host-functions.ts +++ b/packages/plugin-sdk/src/host-functions.ts @@ -4,6 +4,8 @@ import { type BulkIdResponseDto, type BulkIdsDto, type CreateAlbumDto, + type TagBulkAssetsDto, + type TagBulkAssetsResponseDto, } from '@immich/sdk'; declare module 'extism:host' { @@ -47,6 +49,7 @@ export const availableFunctions = [ 'addAssetsToAlbum', 'addAssetsToAlbums', 'httpRequest', + 'bulkTagAssets', ] as const; export const hostFunctions = (authToken: string) => { @@ -96,5 +99,11 @@ export const hostFunctions = (authToken: string) => { authToken, [url, options], ), + bulkTagAssets: (dto: TagBulkAssetsDto) => + call<[TagBulkAssetsDto], TagBulkAssetsResponseDto>( + 'bulkTagAssets', + authToken, + [dto], + ), } satisfies Record<(typeof availableFunctions)[number], unknown>; }; diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index 9756d652c3..94c968978d 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -1,4 +1,9 @@ -import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk'; +import type { + AssetTypeEnum, + AssetVisibility, + TagResponseDto, + WorkflowType, +} from '@immich/sdk'; type DeepPartial = T extends Date ? T @@ -18,6 +23,7 @@ export type WorkflowEventData = WorkflowEventMap[T]; export enum WorkflowTrigger { AssetCreate = 'AssetCreate', AssetMetadataExtraction = 'AssetMetadataExtraction', + AssetTagged = 'AssetTagged', // PersonRecognized = 'PersonRecognized', } @@ -88,6 +94,7 @@ export type AssetV1 = { duplicateId: string | null; visibility: AssetVisibility; isEdited: boolean; + tags: TagResponseDto[]; exifInfo: { make: string | null; model: string | null; diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3ac958ce2d..af24b2d21b 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -7423,7 +7423,8 @@ export enum WorkflowType { } export enum WorkflowTrigger { AssetCreate = "AssetCreate", - AssetMetadataExtraction = "AssetMetadataExtraction" + AssetMetadataExtraction = "AssetMetadataExtraction", + AssetTagged = "AssetTagged" } export enum QueueJobStatus { Active = "active", diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 416f823952..7fedc4eb3a 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -43,7 +43,7 @@ type EventMap = { // asset events AssetCreate: [{ asset: Pick; file?: UploadFile }]; - AssetTag: [{ assetId: string }]; + AssetTag: [{ assetId: string; userId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; AssetShow: [{ assetId: string; userId: string }]; diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index 9c946fe583..888ca94440 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -8,6 +8,7 @@ import { WorkflowSearchDto } from 'src/dtos/workflow.dto'; import { DB } from 'src/schema'; import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; import { WorkflowTable } from 'src/schema/tables/workflow.table'; +import { withTags } from 'src/utils/database'; export type WorkflowStepUpsert = Omit, 'workflowId' | 'order'>; @@ -143,6 +144,7 @@ export class WorkflowRepository { .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .select((eb) => [ ...columns.workflowAssetV1, + withTags, jsonObjectFrom( eb .selectFrom('asset_exif') diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 3cd442b6a7..08a9b00104 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -93,7 +93,7 @@ export class TagService extends BaseService { const results = await this.tagRepository.upsertAssetIds(items); for (const assetId of new Set(results.map((item) => item.assetId))) { await this.updateTags(assetId); - await this.eventRepository.emit('AssetTag', { assetId }); + await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id }); } return { count: results.length }; @@ -114,7 +114,7 @@ export class TagService extends BaseService { } await this.updateTags(assetId); - await this.eventRepository.emit('AssetTag', { assetId }); + await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id }); } return results; diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts index 318f36a4b6..4995eada9c 100644 --- a/server/src/services/workflow-execution.service.ts +++ b/server/src/services/workflow-execution.service.ts @@ -13,6 +13,7 @@ import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { TagBulkAssetsDto } from 'src/dtos/tag.dto'; import { BootstrapEventPriority, DatabaseLock, @@ -27,6 +28,7 @@ import { ArgOf } from 'src/repositories/event.repository'; import { AlbumService } from 'src/services/album.service'; import { AssetService } from 'src/services/asset.service'; import { BaseService } from 'src/services/base.service'; +import { TagService } from 'src/services/tag.service'; import { JobOf } from 'src/types'; const dummy = () => { @@ -69,6 +71,7 @@ export class WorkflowExecutionService extends BaseService { this.jwtSecret = this.cryptoRepository.randomBytesAsText(32); const albumService = BaseService.create(AlbumService, this); + const tagService = BaseService.create(TagService, this); const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args)); const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args)); @@ -106,6 +109,9 @@ export class WorkflowExecutionService extends BaseService { throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest'); }); + const bulkTagAssets = this.wrap<[dto: TagBulkAssetsDto]>((authDto, ctx, args) => + tagService.bulkTagAssets(authDto, ...args), + ); const functions = { searchAlbums, @@ -113,6 +119,7 @@ export class WorkflowExecutionService extends BaseService { addAssetsToAlbum, addAssetsToAlbums, httpRequest, + bulkTagAssets, }; const stubs: typeof functions = { @@ -121,6 +128,7 @@ export class WorkflowExecutionService extends BaseService { addAssetsToAlbum: dummy, addAssetsToAlbums: dummy, httpRequest: dummy, + bulkTagAssets: dummy, }; const plugins = await this.pluginRepository.getForLoad(); @@ -309,6 +317,11 @@ export class WorkflowExecutionService extends BaseService { return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction }); } + @OnEvent({ name: 'AssetTag' }) + onAssetTagged({ assetId, userId }: ArgOf<'AssetTag'>) { + return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetTagged }); + } + private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) { const items = await this.workflowRepository.search({ userId, trigger }); await this.jobRepository.queueAll( diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts index c892239c80..a3a9d56c42 100644 --- a/server/src/utils/workflow.ts +++ b/server/src/utils/workflow.ts @@ -6,6 +6,7 @@ export const triggerMap: Record = { [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], // [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1], [WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1], + [WorkflowTrigger.AssetTagged]: [WorkflowType.AssetV1], }; export const getWorkflowTriggers = () => diff --git a/web/src/lib/components/SchemaConfiguration.svelte b/web/src/lib/components/SchemaConfiguration.svelte index 65d115179e..c4e20081dd 100644 --- a/web/src/lib/components/SchemaConfiguration.svelte +++ b/web/src/lib/components/SchemaConfiguration.svelte @@ -1,6 +1,7 @@ + + { + if (option && !tagIds.includes(option.value)) { + tagIds.push(option.value); + } + }} + label={$t('tags')} + defaultFirstOption + options={tags.map((tag) => ({ label: tag.value, value: tag.id }))} + placeholder={$t('search_tags')} +/> + +
+ {#each tagIds as id, index (id)} + t.id === id)?.name ?? id} onRemove={() => tagIds.splice(index, 1)} /> + {/each} +
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 09d882dd7d..f2451b1bc5 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -100,7 +100,7 @@ export type JSONSchemaProperty = { properties?: Record; required?: string[]; uiHint?: { - type?: 'AlbumId' | 'AssetId' | 'PersonId'; + type?: 'AlbumId' | 'AssetId' | 'PersonId' | 'TagId'; order?: number; }; }; diff --git a/web/src/lib/utils/workflow.ts b/web/src/lib/utils/workflow.ts index b8dc44cc67..3a2100e3a4 100644 --- a/web/src/lib/utils/workflow.ts +++ b/web/src/lib/utils/workflow.ts @@ -13,6 +13,9 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => { case WorkflowTrigger.AssetMetadataExtraction: { return $t('trigger_asset_metadata_extraction'); } + case WorkflowTrigger.AssetTagged: { + return $t('trigger_asset_tagged'); + } default: { return type; } @@ -30,6 +33,12 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge case WorkflowTrigger.AssetMetadataExtraction: { return $t('trigger_asset_metadata_extraction_description'); } + case WorkflowTrigger.AssetTagged: { + return $t('trigger_asset_tagged_description'); + } + default: { + return type; + } } }; diff --git a/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte b/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte index d3dec41f48..2b8c0d7ffe 100644 --- a/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte +++ b/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte @@ -1,6 +1,6 @@ + + + + {#if workflow.logging} + + + {$t('date')} + {$t('result')} + + + {#each entries as entry (entry.id)} + + + +

+ {DateTime.fromISO(entry.at).toLocaleString(DateTime.DATETIME_MED, { + locale: $locale, + })} +

+ {#if entry.triggerDataId} + + + + {/if} +
+
+ + + {#if entry.result === WorkflowResult.Completed} +

+ {$t('workflow_logging_completed')} +

+ {:else if entry.result === WorkflowResult.Halted} +

+ {#if entry.lastStep} + {$t('workflow_logging_halted_step', { values: { step: entry.lastStep.index + 1 } })} + {:else} + {$t('workflow_logging_halted')} + {/if} +

+ {:else} +

+ {#if entry.lastStep} + {$t('workflow_logging_error_step', { values: { step: entry.lastStep.index + 1 } })} + {:else} + {$t('error')} + {/if} +

+ {/if} +
+
+
+ {/each} + {#if hasNext} +
...
+ {/if} +
+
+
+