chore!: migrate album owner to album_user (#27467)

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Daniel Dietzler
2026-04-22 16:52:23 +02:00
committed by GitHub
parent dfacde5af8
commit 4bfb8b36c2
75 changed files with 14750 additions and 1104 deletions
@@ -3,7 +3,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
import { getShortDateRange } from '$lib/utils/date-time';
import type { AlbumResponseDto } from '@immich/sdk';
import { type AlbumResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -85,12 +85,13 @@
{/if}
{#if showOwner}
{#if authManager.user.id === album.ownerId}
{@const owner = album.albumUsers[0].user}
{#if owner.id === authManager.user.id}
<p>{$t('owned')}</p>
{:else if album.owner}
<p>{$t('shared_by_user', { values: { user: album.owner.name } })}</p>
{:else}
<p>{$t('shared')}</p>
<p>
{$t('shared_by_user', { values: { user: owner.name } })}
</p>
{/if}
{:else if album.shared}
<p>{$t('shared')}</p>
@@ -20,7 +20,7 @@
import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { AlbumUserRole, type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
import { groupBy } from 'lodash-es';
@@ -98,24 +98,26 @@
/** Group by owner */
[AlbumGroupBy.Owner]: (order, albums): AlbumGroup[] => {
const currentUserId = authManager.user.id;
const groupedByOwnerIds = groupBy(albums, 'ownerId');
const groupedByOwnerIds = groupBy(albums, (album) => album.albumUsers[0].user.id);
const sortSign = order === SortOrder.Desc ? -1 : 1;
const sortedByOwnerNames = Object.entries(groupedByOwnerIds).sort(([ownerA, albumsA], [ownerB, albumsB]) => {
const sortedByOwnerNames = Object.entries(groupedByOwnerIds).sort(([ownerIdA, albumsA], [ownerIdB, albumsB]) => {
// We make sure owned albums stay either at the beginning or the end
// of the list
if (ownerA === currentUserId) {
if (ownerIdA === currentUserId) {
return -sortSign;
} else if (ownerB === currentUserId) {
} else if (ownerIdB === currentUserId) {
return sortSign;
} else {
return albumsA[0].owner.name.localeCompare(albumsB[0].owner.name, $locale) * sortSign;
const ownerA = albumsA[0].albumUsers[0].user;
const ownerB = albumsB[0].albumUsers[0].user;
return ownerA.name.localeCompare(ownerB.name, $locale) * sortSign;
}
});
return sortedByOwnerNames.map(([ownerId, albums]) => ({
id: ownerId,
name: ownerId === currentUserId ? $t('my_albums') : albums[0].owner.name,
name: ownerId === currentUserId ? $t('my_albums') : albums[0].albumUsers[0].user.name,
albums,
}));
},
@@ -130,7 +132,10 @@
return sharedAlbums;
}
default: {
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== authManager.user.id);
const nonOwnedAlbums = sharedAlbums.filter(
(album) =>
album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role !== AlbumUserRole.Owner,
);
return nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
}
}
@@ -167,7 +172,9 @@
albumGroupIds = groupedAlbums.map(({ id }) => id);
});
let showFullContextMenu = $derived(allowEdit && selectedAlbum && selectedAlbum.ownerId === authManager.user.id);
let showFullContextMenu = $derived(
allowEdit && selectedAlbum && selectedAlbum.albumUsers[0].user.id === authManager.user.id,
);
onMount(async () => {
if (allowEdit) {
@@ -5,7 +5,7 @@
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import type { ContextMenuPosition } from '$lib/utils/context-menu';
import type { AlbumResponseDto } from '@immich/sdk';
import { AlbumUserRole, type AlbumResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -43,9 +43,11 @@
icon={mdiShareVariantOutline}
size="16"
class="inline ms-1 opacity-70"
title={album.ownerId === authManager.user.id
title={album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role === AlbumUserRole.Owner
? $t('shared_by_you')
: $t('shared_by_user', { values: { user: album.owner.name } })}
: $t('shared_by_user', {
values: { user: album.albumUsers[0].user.name },
})}
/>
{/if}
</td>
@@ -12,7 +12,7 @@
import { getAssetType } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum } from '@immich/sdk';
import { ReactionType, type ActivityResponseDto, type AlbumUserResponseDto, type AssetTypeEnum } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
import * as luxon from 'luxon';
@@ -43,11 +43,11 @@
assetId?: string | undefined;
albumId: string;
assetType?: AssetTypeEnum | undefined;
albumOwnerId: string;
albumUsers: AlbumUserResponseDto[];
disabled: boolean;
}
let { assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled }: Props = $props();
let { assetId = undefined, albumId, assetType = undefined, albumUsers, disabled }: Props = $props();
let innerHeight: number = $state(0);
let activityHeight: number = $state(0);
@@ -56,6 +56,7 @@
let previousAssetId: string | undefined = $state(assetId);
let message = $state('');
let isSendingMessage = $state(false);
const isAlbumOwner = $derived(albumUsers[0].user.id === authManager.user.id);
const timeOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
@@ -147,7 +148,7 @@
/>
</a>
{/if}
{#if reaction.user.id === authManager.user.id || albumOwnerId === authManager.user.id}
{#if reaction.user.id === authManager.user.id || isAlbumOwner}
<div class="me-4">
<ButtonContextMenu
icon={mdiDotsVertical}
@@ -200,7 +201,7 @@
/>
</a>
{/if}
{#if reaction.user.id === authManager.user.id || albumOwnerId === authManager.user.id}
{#if reaction.user.id === authManager.user.id || isAlbumOwner}
<div class="me-4">
<ButtonContextMenu
icon={mdiDotsVertical}
@@ -638,7 +638,7 @@
<ActivityViewer
disabled={!album.isActivityEnabled}
assetType={asset.type}
albumOwnerId={album.ownerId}
albumUsers={album.albumUsers}
albumId={album.id}
assetId={asset.id}
/>
@@ -82,7 +82,7 @@
}: Props = $props();
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
const isAlbumOwner = $derived(authManager.authenticated && album?.ownerId === authManager.user.id);
const isAlbumOwner = $derived(authManager.authenticated && album?.albumUsers[0].user.id === authManager.user.id);
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
+1 -1
View File
@@ -20,7 +20,7 @@
const { album, onClose }: Props = $props();
let users: UserResponseDto[] = $state([]);
const excludedUserIds = $derived([album.ownerId, ...album.albumUsers.map(({ user: { id } }) => id)]);
const excludedUserIds = $derived(album.albumUsers.map(({ user: { id } }) => id));
const filteredUsers = $derived(
sortBy(
users.filter(
+2 -11
View File
@@ -105,16 +105,6 @@
<HeaderActionButton action={AddUsers} />
</HStack>
<div class="ps-2">
<div class="flex items-center gap-2 mb-2">
<div>
<UserAvatar user={album.owner} size="md" />
</div>
<Text class="w-full" size="small">{album.owner.name}</Text>
<Field disabled class="w-32 shrink-0">
<Select options={[{ label: $t('owner'), value: 'owner' }]} value="owner" />
</Field>
</div>
{#each album.albumUsers as { user, role } (user.id)}
<div class="flex items-center justify-between gap-4 py-2">
<div class="flex flex-row items-center gap-2">
@@ -123,12 +113,13 @@
</div>
<Text size="small">{user.name}</Text>
</div>
<Field class="w-32">
<Field class="w-32" disabled={role === AlbumUserRole.Owner}>
<Select
value={role}
options={[
{ label: $t('role_editor'), value: AlbumUserRole.Editor },
{ label: $t('role_viewer'), value: AlbumUserRole.Viewer },
{ label: $t('owner'), value: AlbumUserRole.Owner },
{ label: $t('remove_user'), value: 'none' },
] as SelectOption<AlbumUserRole | 'none'>[]}
onChange={(value) => handleRoleSelect(user, value)}
+1 -1
View File
@@ -42,7 +42,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
};
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
const isOwned = authManager.user.id === album.ownerId;
const isOwned = album.albumUsers[0].user.id === authManager.user.id;
const Share: ActionItem = {
title: $t('share'),
@@ -210,9 +210,7 @@
let albumId = $derived(album.id);
const containsEditors = $derived(album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor));
const albumUsers = $derived(
showAlbumUsers && containsEditors ? [album.owner, ...album.albumUsers.map(({ user }) => user)] : [],
);
const albumUsers = $derived(showAlbumUsers && containsEditors ? album.albumUsers.map(({ user }) => user) : []);
$effect(() => {
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
@@ -231,7 +229,7 @@
return { albumId, order: album.order };
});
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 1);
$effect(() => {
if (assetViewerManager.isViewing || !isShared) {
@@ -243,16 +241,15 @@
onDestroy(() => activityManager.reset());
let isOwned = $derived(authManager.user.id == album.ownerId);
const isOwned = $derived(album.albumUsers[0].user.id === authManager.user.id);
let showActivityStatus = $derived(
album.albumUsers.length > 0 &&
album.albumUsers.length > 1 &&
!assetViewerManager.isViewing &&
(album.isActivityEnabled || activityManager.commentCount > 0),
);
let isEditor = $derived(
album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role === AlbumUserRole.Editor ||
album.ownerId === authManager.user.id,
const isEditor = $derived(
album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role === AlbumUserRole.Editor || isOwned,
);
let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
@@ -374,7 +371,7 @@
{/if}
<!-- ALBUM SHARING -->
{#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned)}
{#if album.albumUsers.length > 1 || (album.hasSharedLink && isOwned)}
<div class="my-3 flex gap-x-1">
<!-- link -->
{#if album.hasSharedLink && isOwned}
@@ -388,13 +385,8 @@
/>
{/if}
<!-- owner -->
<button type="button" onclick={() => modalManager.show(AlbumOptionsModal, { album })}>
<UserAvatar user={album.owner} size="md" />
</button>
<!-- users with write access (collaborators) -->
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)}
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor || role === AlbumUserRole.Owner) as { user } (user.id)}
<button type="button" onclick={() => modalManager.show(AlbumOptionsModal, { album })}>
<UserAvatar {user} size="md" />
</button>
@@ -620,7 +612,7 @@
{/if}
{/if}
</div>
{#if album.albumUsers.length > 0 && album && assetViewerManager.isShowActivityPanel && authManager.authenticated && !assetViewerManager.isViewing}
{#if album.albumUsers.length > 1 && album && assetViewerManager.isShowActivityPanel && authManager.authenticated && !assetViewerManager.isViewing}
<div class="flex">
<div
transition:fly={{ duration: 150 }}
@@ -628,7 +620,7 @@
class="z-2 w-90 md:w-115 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<ActivityViewer disabled={!album.isActivityEnabled} albumOwnerId={album.ownerId} albumId={album.id} />
<ActivityViewer disabled={!album.isActivityEnabled} albumUsers={album.albumUsers} albumId={album.id} />
</div>
</div>
{/if}
@@ -1,7 +1,6 @@
import { faker } from '@faker-js/faker';
import { AssetOrder, type AlbumResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts';
import { userFactory } from './user-factory';
export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
albumName: Sync.each(() => faker.commerce.product()),
@@ -11,8 +10,6 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
createdAt: Sync.each(() => faker.date.past().toISOString()),
updatedAt: Sync.each(() => faker.date.past().toISOString()),
id: Sync.each(() => faker.string.uuid()),
ownerId: Sync.each(() => faker.string.uuid()),
owner: userFactory.build(),
shared: false,
albumUsers: [],
hasSharedLink: false,