Browse Source

feat(web): show original uploader in shared album photo details (#3977)

* feat(web): show original uploader in shared album photo details

* feat: send owner in asset by id response

* chore: open api

* fix: linting

* fix: change to Shared By

* openapi

* openapi

* api

* styling

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
Maarten Rijke 1 year ago
parent
commit
b4fa60d4fd

+ 6 - 0
cli/src/api/open-api/api.ts

@@ -645,6 +645,12 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      * @memberof AssetResponseDto
      */
      */
     'originalPath': string;
     'originalPath': string;
+    /**
+     * 
+     * @type {UserResponseDto}
+     * @memberof AssetResponseDto
+     */
+    'owner'?: UserResponseDto;
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}

+ 1 - 0
mobile/openapi/doc/AssetResponseDto.md

@@ -21,6 +21,7 @@ Name | Type | Description | Notes
 **livePhotoVideoId** | **String** |  | [optional] 
 **livePhotoVideoId** | **String** |  | [optional] 
 **originalFileName** | **String** |  | 
 **originalFileName** | **String** |  | 
 **originalPath** | **String** |  | 
 **originalPath** | **String** |  | 
+**owner** | [**UserResponseDto**](UserResponseDto.md) |  | [optional] 
 **ownerId** | **String** |  | 
 **ownerId** | **String** |  | 
 **people** | [**List<PersonResponseDto>**](PersonResponseDto.md) |  | [optional] [default to const []]
 **people** | [**List<PersonResponseDto>**](PersonResponseDto.md) |  | [optional] [default to const []]
 **resized** | **bool** |  | 
 **resized** | **bool** |  | 

+ 18 - 1
mobile/openapi/lib/model/asset_response_dto.dart

@@ -26,6 +26,7 @@ class AssetResponseDto {
     this.livePhotoVideoId,
     this.livePhotoVideoId,
     required this.originalFileName,
     required this.originalFileName,
     required this.originalPath,
     required this.originalPath,
+    this.owner,
     required this.ownerId,
     required this.ownerId,
     this.people = const [],
     this.people = const [],
     required this.resized,
     required this.resized,
@@ -69,6 +70,14 @@ class AssetResponseDto {
 
 
   String originalPath;
   String originalPath;
 
 
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  UserResponseDto? owner;
+
   String ownerId;
   String ownerId;
 
 
   List<PersonResponseDto> people;
   List<PersonResponseDto> people;
@@ -107,6 +116,7 @@ class AssetResponseDto {
      other.livePhotoVideoId == livePhotoVideoId &&
      other.livePhotoVideoId == livePhotoVideoId &&
      other.originalFileName == originalFileName &&
      other.originalFileName == originalFileName &&
      other.originalPath == originalPath &&
      other.originalPath == originalPath &&
+     other.owner == owner &&
      other.ownerId == ownerId &&
      other.ownerId == ownerId &&
      other.people == people &&
      other.people == people &&
      other.resized == resized &&
      other.resized == resized &&
@@ -132,6 +142,7 @@ class AssetResponseDto {
     (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
     (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
     (originalFileName.hashCode) +
     (originalFileName.hashCode) +
     (originalPath.hashCode) +
     (originalPath.hashCode) +
+    (owner == null ? 0 : owner!.hashCode) +
     (ownerId.hashCode) +
     (ownerId.hashCode) +
     (people.hashCode) +
     (people.hashCode) +
     (resized.hashCode) +
     (resized.hashCode) +
@@ -142,7 +153,7 @@ class AssetResponseDto {
     (updatedAt.hashCode);
     (updatedAt.hashCode);
 
 
   @override
   @override
-  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, livePhotoVideoId=$livePhotoVideoId, originalFileName=$originalFileName, originalPath=$originalPath, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
+  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, livePhotoVideoId=$livePhotoVideoId, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -167,6 +178,11 @@ class AssetResponseDto {
     }
     }
       json[r'originalFileName'] = this.originalFileName;
       json[r'originalFileName'] = this.originalFileName;
       json[r'originalPath'] = this.originalPath;
       json[r'originalPath'] = this.originalPath;
+    if (this.owner != null) {
+      json[r'owner'] = this.owner;
+    } else {
+    //  json[r'owner'] = null;
+    }
       json[r'ownerId'] = this.ownerId;
       json[r'ownerId'] = this.ownerId;
       json[r'people'] = this.people;
       json[r'people'] = this.people;
       json[r'resized'] = this.resized;
       json[r'resized'] = this.resized;
@@ -207,6 +223,7 @@ class AssetResponseDto {
         livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
         livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
         originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
         originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
         originalPath: mapValueOfType<String>(json, r'originalPath')!,
         originalPath: mapValueOfType<String>(json, r'originalPath')!,
+        owner: UserResponseDto.fromJson(json[r'owner']),
         ownerId: mapValueOfType<String>(json, r'ownerId')!,
         ownerId: mapValueOfType<String>(json, r'ownerId')!,
         people: PersonResponseDto.listFromJson(json[r'people']),
         people: PersonResponseDto.listFromJson(json[r'people']),
         resized: mapValueOfType<bool>(json, r'resized')!,
         resized: mapValueOfType<bool>(json, r'resized')!,

+ 5 - 0
mobile/openapi/test/asset_response_dto_test.dart

@@ -82,6 +82,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // UserResponseDto owner
+    test('to test the property `owner`', () async {
+      // TODO
+    });
+
     // String ownerId
     // String ownerId
     test('to test the property `ownerId`', () async {
     test('to test the property `ownerId`', () async {
       // TODO
       // TODO

+ 4 - 1
server/immich-openapi-specs.json

@@ -5208,6 +5208,9 @@
           "originalPath": {
           "originalPath": {
             "type": "string"
             "type": "string"
           },
           },
+          "owner": {
+            "$ref": "#/components/schemas/UserResponseDto"
+          },
           "ownerId": {
           "ownerId": {
             "type": "string"
             "type": "string"
           },
           },
@@ -5246,8 +5249,8 @@
           "type",
           "type",
           "id",
           "id",
           "deviceAssetId",
           "deviceAssetId",
-          "ownerId",
           "deviceId",
           "deviceId",
+          "ownerId",
           "originalPath",
           "originalPath",
           "originalFileName",
           "originalFileName",
           "resized",
           "resized",

+ 11 - 26
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -2,14 +2,16 @@ import { AssetEntity, AssetType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import { PersonResponseDto, mapFace } from '../../person/person.dto';
 import { PersonResponseDto, mapFace } from '../../person/person.dto';
 import { TagResponseDto, mapTag } from '../../tag';
 import { TagResponseDto, mapTag } from '../../tag';
+import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto';
 import { ExifResponseDto, mapExif } from './exif-response.dto';
 import { ExifResponseDto, mapExif } from './exif-response.dto';
 import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
 import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
 
 
 export class AssetResponseDto {
 export class AssetResponseDto {
   id!: string;
   id!: string;
   deviceAssetId!: string;
   deviceAssetId!: string;
-  ownerId!: string;
   deviceId!: string;
   deviceId!: string;
+  ownerId!: string;
+  owner?: UserResponseDto;
 
 
   @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
   @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
   type!: AssetType;
   type!: AssetType;
@@ -33,11 +35,12 @@ export class AssetResponseDto {
   checksum!: string;
   checksum!: string;
 }
 }
 
 
-export function mapAsset(entity: AssetEntity): AssetResponseDto {
+function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
   return {
   return {
     id: entity.id,
     id: entity.id,
     deviceAssetId: entity.deviceAssetId,
     deviceAssetId: entity.deviceAssetId,
     ownerId: entity.ownerId,
     ownerId: entity.ownerId,
+    owner: entity.owner ? mapUser(entity.owner) : undefined,
     deviceId: entity.deviceId,
     deviceId: entity.deviceId,
     type: entity.type,
     type: entity.type,
     originalPath: entity.originalPath,
     originalPath: entity.originalPath,
@@ -50,7 +53,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
     isFavorite: entity.isFavorite,
     isFavorite: entity.isFavorite,
     isArchived: entity.isArchived,
     isArchived: entity.isArchived,
     duration: entity.duration ?? '0:00:00.00000',
     duration: entity.duration ?? '0:00:00.00000',
-    exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
+    exifInfo: withExif ? (entity.exifInfo ? mapExif(entity.exifInfo) : undefined) : undefined,
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
     livePhotoVideoId: entity.livePhotoVideoId,
     livePhotoVideoId: entity.livePhotoVideoId,
     tags: entity.tags?.map(mapTag),
     tags: entity.tags?.map(mapTag),
@@ -59,30 +62,12 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
   };
   };
 }
 }
 
 
+export function mapAsset(entity: AssetEntity): AssetResponseDto {
+  return _map(entity, true);
+}
+
 export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
 export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
-  return {
-    id: entity.id,
-    deviceAssetId: entity.deviceAssetId,
-    ownerId: entity.ownerId,
-    deviceId: entity.deviceId,
-    type: entity.type,
-    originalPath: entity.originalPath,
-    originalFileName: entity.originalFileName,
-    resized: !!entity.resizePath,
-    thumbhash: entity.thumbhash?.toString('base64') || null,
-    fileCreatedAt: entity.fileCreatedAt,
-    fileModifiedAt: entity.fileModifiedAt,
-    updatedAt: entity.updatedAt,
-    isFavorite: entity.isFavorite,
-    isArchived: entity.isArchived,
-    duration: entity.duration ?? '0:00:00.00000',
-    exifInfo: undefined,
-    smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
-    livePhotoVideoId: entity.livePhotoVideoId,
-    tags: entity.tags?.map(mapTag),
-    people: entity.faces?.map(mapFace),
-    checksum: entity.checksum.toString('base64'),
-  };
+  return _map(entity, false);
 }
 }
 
 
 export class MemoryLaneResponseDto {
 export class MemoryLaneResponseDto {

+ 1 - 0
server/src/immich/api-v1/asset/asset-repository.ts

@@ -107,6 +107,7 @@ export class AssetRepository implements IAssetRepository {
         tags: true,
         tags: true,
         sharedLinks: true,
         sharedLinks: true,
         smartInfo: true,
         smartInfo: true,
+        owner: true,
         faces: {
         faces: {
           person: true,
           person: true,
         },
         },

+ 4 - 0
server/src/immich/api-v1/asset/asset.service.ts

@@ -199,6 +199,10 @@ export class AssetService {
       data.people = [];
       data.people = [];
     }
     }
 
 
+    if (authUser.isPublicUser) {
+      delete data.owner;
+    }
+
     return data;
     return data;
   }
   }
 
 

+ 2 - 2
server/test/fixtures/shared-link.stub.ts

@@ -1,5 +1,5 @@
 import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLinkResponseDto } from '@app/domain';
 import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLinkResponseDto } from '@app/domain';
-import { AssetType, SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
+import { AssetType, SharedLinkEntity, SharedLinkType, UserEntity } from '@app/infra/entities';
 import { assetStub } from './asset.stub';
 import { assetStub } from './asset.stub';
 import { authStub } from './auth.stub';
 import { authStub } from './auth.stub';
 import { userStub } from './user.stub';
 import { userStub } from './user.stub';
@@ -158,7 +158,7 @@ export const sharedLinkStub = {
       assets: [
       assets: [
         {
         {
           id: 'id_1',
           id: 'id_1',
-          owner: userStub.user1,
+          owner: undefined as unknown as UserEntity,
           ownerId: 'user_id_1',
           ownerId: 'user_id_1',
           deviceAssetId: 'device_asset_id_1',
           deviceAssetId: 'device_asset_id_1',
           deviceId: 'device_id_1',
           deviceId: 'device_id_1',

+ 6 - 0
web/src/api/open-api/api.ts

@@ -645,6 +645,12 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      * @memberof AssetResponseDto
      */
      */
     'originalPath': string;
     'originalPath': string;
+    /**
+     * 
+     * @type {UserResponseDto}
+     * @memberof AssetResponseDto
+     */
+    'owner'?: UserResponseDto;
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}

+ 29 - 13
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -13,6 +13,7 @@
   import { asByteUnitString } from '../../utils/byte-units';
   import { asByteUnitString } from '../../utils/byte-units';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
   import { getAssetFilename } from '$lib/utils/asset-utils';
   import { getAssetFilename } from '$lib/utils/asset-utils';
+  import UserAvatar from '../shared-components/user-avatar.svelte';
 
 
   export let asset: AssetResponseDto;
   export let asset: AssetResponseDto;
   export let albums: AlbumResponseDto[] = [];
   export let albums: AlbumResponseDto[] = [];
@@ -20,6 +21,8 @@
   let textarea: HTMLTextAreaElement;
   let textarea: HTMLTextAreaElement;
   let description: string;
   let description: string;
 
 
+  $: isOwner = $page?.data?.user?.id === asset.ownerId;
+
   $: {
   $: {
     // Get latest description from server
     // Get latest description from server
     if (asset.id && !api.isSharedLink) {
     if (asset.id && !api.isSharedLink) {
@@ -93,20 +96,17 @@
     <p class="text-lg text-immich-fg dark:text-immich-dark-fg">Info</p>
     <p class="text-lg text-immich-fg dark:text-immich-dark-fg">Info</p>
   </div>
   </div>
 
 
-  <section
-    class="mx-4 mt-10"
-    style:display={$page?.data?.user?.id !== asset.ownerId && textarea?.value == '' ? 'none' : 'block'}
-  >
+  <section class="mx-4 mt-10" style:display={!isOwner && textarea?.value == '' ? 'none' : 'block'}>
     <textarea
     <textarea
       bind:this={textarea}
       bind:this={textarea}
       class="max-h-[500px]
       class="max-h-[500px]
       w-full resize-none overflow-hidden border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary"
       w-full resize-none overflow-hidden border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary"
-      placeholder={$page?.data?.user?.id !== asset.ownerId ? '' : 'Add a description'}
+      placeholder={!isOwner ? '' : 'Add a description'}
       on:focusin={handleFocusIn}
       on:focusin={handleFocusIn}
       on:focusout={handleFocusOut}
       on:focusout={handleFocusOut}
       on:input={autoGrowHeight}
       on:input={autoGrowHeight}
       bind:value={description}
       bind:value={description}
-      disabled={$page?.data?.user?.id !== asset.ownerId}
+      disabled={!isOwner}
     />
     />
   </section>
   </section>
 
 
@@ -291,11 +291,27 @@
   </div>
   </div>
 {/if}
 {/if}
 
 
-<section class="p-2 dark:text-immich-dark-fg">
-  <div class="px-4 py-4">
-    {#if albums.length > 0}
-      <p class="pb-4 text-sm">APPEARS IN</p>
-    {/if}
+{#if asset.owner && !isOwner}
+  <section class="px-6 pt-6 dark:text-immich-dark-fg">
+    <p class="text-sm">SHARED BY</p>
+    <div class="flex gap-4 pt-4">
+      <div>
+        <UserAvatar user={asset.owner} size="md" autoColor />
+      </div>
+
+      <div class="mb-auto mt-auto">
+        <p>
+          {asset.owner.firstName}
+          {asset.owner.lastName}
+        </p>
+      </div>
+    </div>
+  </section>
+{/if}
+
+{#if albums.length > 0}
+  <section class="p-6 dark:text-immich-dark-fg">
+    <p class="pb-4 text-sm">APPEARS IN</p>
     {#each albums as album}
     {#each albums as album}
       <a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
       <a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
         <!-- svelte-ignore a11y-no-static-element-interactions -->
         <!-- svelte-ignore a11y-no-static-element-interactions -->
@@ -326,5 +342,5 @@
         </div>
         </div>
       </a>
       </a>
     {/each}
     {/each}
-  </div>
-</section>
+  </section>
+{/if}