Juho Mäkinen 2025-12-18 10:15:36 +02:00 committed by GitHub
commit 3fa65000b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 130 additions and 2 deletions

View File

@ -962,6 +962,7 @@
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
"cant_apply_changes": "Can't apply changes",
"cant_change_activity": "Can't {enabled, select, true {disable} other {enable}} activity",
"cant_change_timeline_visibility": "Unable to change timeline visibility",
"cant_change_asset_favorite": "Can't change favorite for asset",
"cant_change_metadata_assets_count": "Can't change metadata of {count, plural, one {# asset} other {# assets}}",
"cant_get_faces": "Can't get faces",
@ -1177,6 +1178,8 @@
"hide_person": "Hide person",
"hide_text_recognition": "Hide text recognition",
"hide_unnamed_people": "Hide unnamed people",
"hide_from_timeline": "Hide from timeline",
"hide_from_timeline_description": "Photos in this album will not appear in your main timeline",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
@ -2081,6 +2084,7 @@
"time_based_memories": "Time-based memories",
"time_based_memories_duration": "Number of seconds to display each image.",
"timeline": "Timeline",
"timeline_visibility_changed": "Album is now {hidden, select, true {hidden from} other {visible in}} timeline",
"timezone": "Timezone",
"to_archive": "Archive",
"to_change_password": "Change password",

View File

@ -14671,6 +14671,9 @@
"hasSharedLink": {
"type": "boolean"
},
"hideFromTimeline": {
"type": "boolean"
},
"id": {
"type": "string"
},
@ -14715,6 +14718,7 @@
"createdAt",
"description",
"hasSharedLink",
"hideFromTimeline",
"id",
"isActivityEnabled",
"owner",
@ -16143,6 +16147,9 @@
},
"description": {
"type": "string"
},
"hideFromTimeline": {
"type": "boolean"
}
},
"required": [
@ -22528,6 +22535,9 @@
"description": {
"type": "string"
},
"hideFromTimeline": {
"type": "boolean"
},
"isActivityEnabled": {
"type": "boolean"
},

View File

@ -53,6 +53,9 @@ export class CreateAlbumDto {
@ValidateUUID({ optional: true, each: true })
assetIds?: string[];
@ValidateBoolean({ optional: true })
hideFromTimeline?: boolean;
}
export class AlbumsAddAssetsDto {
@ -86,6 +89,9 @@ export class UpdateAlbumDto {
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true })
order?: AssetOrder;
@ValidateBoolean({ optional: true })
hideFromTimeline?: boolean;
}
export class GetAlbumsDto {
@ -157,6 +163,7 @@ export class AlbumResponseDto {
isActivityEnabled!: boolean;
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true })
order?: AssetOrder;
hideFromTimeline!: boolean;
// Optional per-user contribution counts for shared albums
@Type(() => ContributorCountResponseDto)
@ -178,6 +185,7 @@ export type MapAlbumDto = {
owner: User;
isActivityEnabled: boolean;
order: AssetOrder;
hideFromTimeline: boolean;
};
export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
@ -225,6 +233,7 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt
assetCount: entity.assets?.length || 0,
isActivityEnabled: entity.isActivityEnabled,
order: entity.order,
hideFromTimeline: entity.hideFromTimeline,
};
};

View File

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { columns, Exif } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
@ -294,9 +295,10 @@ export class AlbumRepository {
}
update(id: string, album: Updateable<AlbumTable>) {
const value = omitBy(album, isUndefined);
return this.db
.updateTable('album')
.set(album)
.set(value)
.where('id', '=', id)
.returningAll('album')
.returning(withOwner)

View File

@ -328,6 +328,18 @@ export class AssetRepository {
.where('asset_file.type', '=', AssetFileType.Preview),
),
)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('album_asset')
.innerJoin('album', 'album.id', 'album_asset.albumId')
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('album.hideFromTimeline', '=', true)
.where('album.deletedAt', 'is', null),
),
),
)
.where('asset.deletedAt', 'is', null)
.orderBy(sql`(asset."localDateTime" at time zone 'UTC')::date`, 'desc')
.limit(20)
@ -624,7 +636,21 @@ export class AssetRepository {
.$if(options.isDuplicate !== undefined, (qb) =>
qb.where('asset.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
)
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)),
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.$if(!options.albumId, (qb) =>
qb.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('album_asset')
.innerJoin('album', 'album.id', 'album_asset.albumId')
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('album.hideFromTimeline', '=', true)
.where('album.deletedAt', 'is', null),
),
),
),
),
)
.selectFrom('asset')
.select(sql<string>`("timeBucket" AT TIME ZONE 'UTC')::date::text`.as('timeBucket'))
@ -725,6 +751,20 @@ export class AssetRepository {
)
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.$if(!options.albumId, (qb) =>
qb.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('album_asset')
.innerJoin('album', 'album.id', 'album_asset.albumId')
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('album.hideFromTimeline', '=', true)
.where('album.deletedAt', 'is', null),
),
),
),
)
.orderBy('asset.fileCreatedAt', options.order ?? 'desc'),
)
.with('agg', (qb) =>

View File

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album" ADD "hideFromTimeline" boolean NOT NULL DEFAULT false;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album" DROP COLUMN "hideFromTimeline";`.execute(db);
}

View File

@ -60,6 +60,9 @@ export class AlbumTable {
@Column({ default: AssetOrder.Desc })
order!: Generated<AssetOrder>;
@Column({ type: 'boolean', default: false })
hideFromTimeline!: Generated<boolean>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View File

@ -123,6 +123,7 @@ export class AlbumService extends BaseService {
description: dto.description,
albumThumbnailAssetId: assetIds[0] || null,
order: getPreferences(userMetadata).albums.defaultAssetOrder,
hideFromTimeline: dto.hideFromTimeline,
},
assetIds,
albumUsers,
@ -153,6 +154,7 @@ export class AlbumService extends BaseService {
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
hideFromTimeline: dto.hideFromTimeline,
});
return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets });

View File

@ -44,6 +44,34 @@ describe(TimelineService.name, () => {
]);
});
it('should exclude assets from albums with hideFromTimeline=true', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
// Create two assets
const { asset: asset1 } = await ctx.newAsset({
ownerId: user.id,
localDateTime: new Date('1970-01-01'),
});
const { asset: asset2 } = await ctx.newAsset({
ownerId: user.id,
localDateTime: new Date('1970-01-01'),
});
// Create an album with hideFromTimeline=true and add asset1
const { album } = await ctx.newAlbum({
ownerId: user.id,
hideFromTimeline: true,
});
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset1.id });
// Get time buckets - should only include asset2, not asset1
const response = await sut.getTimeBuckets(auth, {});
expect(response).toEqual([{ count: 1, timeBucket: '1970-01-01' }]);
});
it('should return error if time bucket is requested with partners asset and archived', async () => {
const { sut } = setup();
const auth = factory.auth();

View File

@ -73,6 +73,21 @@
}
};
const handleToggleHideFromTimeline = async () => {
try {
album = await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
hideFromTimeline: !album.hideFromTimeline,
},
});
toastManager.success($t('timeline_visibility_changed', { values: { hidden: album.hideFromTimeline } }));
} catch (error) {
handleError(error, $t('errors.cant_change_timeline_visibility'));
}
};
const handleRemoveUser = async (user: UserResponseDto): Promise<void> => {
const confirmed = await modalManager.showDialog({
title: $t('album_remove_user'),
@ -127,6 +142,12 @@
checked={album.isActivityEnabled}
onToggle={handleToggleActivity}
/>
<SettingSwitch
title={$t('hide_from_timeline')}
subtitle={$t('hide_from_timeline_description')}
checked={album.hideFromTimeline}
onToggle={handleToggleHideFromTimeline}
/>
</div>
</div>
<div class="py-2">