mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 10:12:33 -04:00 
			
		
		
		
	Adapt web client to consume new server response format
This commit is contained in:
		
							parent
							
								
									077703adcc
								
							
						
					
					
						commit
						bc5d4b45a6
					
				
							
								
								
									
										25
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										25
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @ -145,8 +145,15 @@ Class | Method | HTTP request | Description | ||||
| *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets |  | ||||
| *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories |  | ||||
| *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} |  | ||||
| *NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /notifications/admin/templates/{name} |  | ||||
| *NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /notifications/admin/test-email |  | ||||
| *NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} |  | ||||
| *NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications |  | ||||
| *NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} |  | ||||
| *NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications |  | ||||
| *NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} |  | ||||
| *NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications |  | ||||
| *NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications |  | ||||
| *NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} |  | ||||
| *NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email |  | ||||
| *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback |  | ||||
| *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |  | ||||
| *OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect |  | ||||
| @ -300,7 +307,6 @@ Class | Method | HTTP request | Description | ||||
|  - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) | ||||
|  - [AssetTypeEnum](doc//AssetTypeEnum.md) | ||||
|  - [AudioCodec](doc//AudioCodec.md) | ||||
|  - [AvatarResponse](doc//AvatarResponse.md) | ||||
|  - [AvatarUpdate](doc//AvatarUpdate.md) | ||||
|  - [BulkIdResponseDto](doc//BulkIdResponseDto.md) | ||||
|  - [BulkIdsDto](doc//BulkIdsDto.md) | ||||
| @ -361,6 +367,13 @@ Class | Method | HTTP request | Description | ||||
|  - [MemoryUpdateDto](doc//MemoryUpdateDto.md) | ||||
|  - [MergePersonDto](doc//MergePersonDto.md) | ||||
|  - [MetadataSearchDto](doc//MetadataSearchDto.md) | ||||
|  - [NotificationCreateDto](doc//NotificationCreateDto.md) | ||||
|  - [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md) | ||||
|  - [NotificationDto](doc//NotificationDto.md) | ||||
|  - [NotificationLevel](doc//NotificationLevel.md) | ||||
|  - [NotificationType](doc//NotificationType.md) | ||||
|  - [NotificationUpdateAllDto](doc//NotificationUpdateAllDto.md) | ||||
|  - [NotificationUpdateDto](doc//NotificationUpdateDto.md) | ||||
|  - [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md) | ||||
|  - [OAuthCallbackDto](doc//OAuthCallbackDto.md) | ||||
|  - [OAuthConfigDto](doc//OAuthConfigDto.md) | ||||
| @ -475,8 +488,12 @@ Class | Method | HTTP request | Description | ||||
|  - [TemplateDto](doc//TemplateDto.md) | ||||
|  - [TemplateResponseDto](doc//TemplateResponseDto.md) | ||||
|  - [TestEmailResponseDto](doc//TestEmailResponseDto.md) | ||||
|  - [TimeBucketAssetResponseDto](doc//TimeBucketAssetResponseDto.md) | ||||
|  - [TimeBucketAssetResponseDtoDurationInner](doc//TimeBucketAssetResponseDtoDurationInner.md) | ||||
|  - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) | ||||
|  - [TimeBucketSize](doc//TimeBucketSize.md) | ||||
|  - [TimeBucketsResponseDto](doc//TimeBucketsResponseDto.md) | ||||
|  - [TimelineAssetDescriptionDto](doc//TimelineAssetDescriptionDto.md) | ||||
|  - [TimelineStackResponseDto](doc//TimelineStackResponseDto.md) | ||||
|  - [ToneMapping](doc//ToneMapping.md) | ||||
|  - [TranscodeHWAccel](doc//TranscodeHWAccel.md) | ||||
|  - [TranscodePolicy](doc//TranscodePolicy.md) | ||||
|  | ||||
							
								
								
									
										15
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @ -44,6 +44,7 @@ part 'api/jobs_api.dart'; | ||||
| part 'api/libraries_api.dart'; | ||||
| part 'api/map_api.dart'; | ||||
| part 'api/memories_api.dart'; | ||||
| part 'api/notifications_api.dart'; | ||||
| part 'api/notifications_admin_api.dart'; | ||||
| part 'api/o_auth_api.dart'; | ||||
| part 'api/partners_api.dart'; | ||||
| @ -107,7 +108,6 @@ part 'model/asset_stack_response_dto.dart'; | ||||
| part 'model/asset_stats_response_dto.dart'; | ||||
| part 'model/asset_type_enum.dart'; | ||||
| part 'model/audio_codec.dart'; | ||||
| part 'model/avatar_response.dart'; | ||||
| part 'model/avatar_update.dart'; | ||||
| part 'model/bulk_id_response_dto.dart'; | ||||
| part 'model/bulk_ids_dto.dart'; | ||||
| @ -168,6 +168,13 @@ part 'model/memory_type.dart'; | ||||
| part 'model/memory_update_dto.dart'; | ||||
| part 'model/merge_person_dto.dart'; | ||||
| part 'model/metadata_search_dto.dart'; | ||||
| part 'model/notification_create_dto.dart'; | ||||
| part 'model/notification_delete_all_dto.dart'; | ||||
| part 'model/notification_dto.dart'; | ||||
| part 'model/notification_level.dart'; | ||||
| part 'model/notification_type.dart'; | ||||
| part 'model/notification_update_all_dto.dart'; | ||||
| part 'model/notification_update_dto.dart'; | ||||
| part 'model/o_auth_authorize_response_dto.dart'; | ||||
| part 'model/o_auth_callback_dto.dart'; | ||||
| part 'model/o_auth_config_dto.dart'; | ||||
| @ -282,8 +289,12 @@ part 'model/tags_update.dart'; | ||||
| part 'model/template_dto.dart'; | ||||
| part 'model/template_response_dto.dart'; | ||||
| part 'model/test_email_response_dto.dart'; | ||||
| part 'model/time_bucket_asset_response_dto.dart'; | ||||
| part 'model/time_bucket_asset_response_dto_duration_inner.dart'; | ||||
| part 'model/time_bucket_response_dto.dart'; | ||||
| part 'model/time_bucket_size.dart'; | ||||
| part 'model/time_buckets_response_dto.dart'; | ||||
| part 'model/timeline_asset_description_dto.dart'; | ||||
| part 'model/timeline_stack_response_dto.dart'; | ||||
| part 'model/tone_mapping.dart'; | ||||
| part 'model/transcode_hw_accel.dart'; | ||||
| part 'model/transcode_policy.dart'; | ||||
|  | ||||
							
								
								
									
										28
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										28
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							| @ -270,8 +270,6 @@ class ApiClient { | ||||
|           return AssetTypeEnumTypeTransformer().decode(value); | ||||
|         case 'AudioCodec': | ||||
|           return AudioCodecTypeTransformer().decode(value); | ||||
|         case 'AvatarResponse': | ||||
|           return AvatarResponse.fromJson(value); | ||||
|         case 'AvatarUpdate': | ||||
|           return AvatarUpdate.fromJson(value); | ||||
|         case 'BulkIdResponseDto': | ||||
| @ -392,6 +390,20 @@ class ApiClient { | ||||
|           return MergePersonDto.fromJson(value); | ||||
|         case 'MetadataSearchDto': | ||||
|           return MetadataSearchDto.fromJson(value); | ||||
|         case 'NotificationCreateDto': | ||||
|           return NotificationCreateDto.fromJson(value); | ||||
|         case 'NotificationDeleteAllDto': | ||||
|           return NotificationDeleteAllDto.fromJson(value); | ||||
|         case 'NotificationDto': | ||||
|           return NotificationDto.fromJson(value); | ||||
|         case 'NotificationLevel': | ||||
|           return NotificationLevelTypeTransformer().decode(value); | ||||
|         case 'NotificationType': | ||||
|           return NotificationTypeTypeTransformer().decode(value); | ||||
|         case 'NotificationUpdateAllDto': | ||||
|           return NotificationUpdateAllDto.fromJson(value); | ||||
|         case 'NotificationUpdateDto': | ||||
|           return NotificationUpdateDto.fromJson(value); | ||||
|         case 'OAuthAuthorizeResponseDto': | ||||
|           return OAuthAuthorizeResponseDto.fromJson(value); | ||||
|         case 'OAuthCallbackDto': | ||||
| @ -620,10 +632,18 @@ class ApiClient { | ||||
|           return TemplateResponseDto.fromJson(value); | ||||
|         case 'TestEmailResponseDto': | ||||
|           return TestEmailResponseDto.fromJson(value); | ||||
|         case 'TimeBucketAssetResponseDto': | ||||
|           return TimeBucketAssetResponseDto.fromJson(value); | ||||
|         case 'TimeBucketAssetResponseDtoDurationInner': | ||||
|           return TimeBucketAssetResponseDtoDurationInner.fromJson(value); | ||||
|         case 'TimeBucketResponseDto': | ||||
|           return TimeBucketResponseDto.fromJson(value); | ||||
|         case 'TimeBucketSize': | ||||
|           return TimeBucketSizeTypeTransformer().decode(value); | ||||
|         case 'TimeBucketsResponseDto': | ||||
|           return TimeBucketsResponseDto.fromJson(value); | ||||
|         case 'TimelineAssetDescriptionDto': | ||||
|           return TimelineAssetDescriptionDto.fromJson(value); | ||||
|         case 'TimelineStackResponseDto': | ||||
|           return TimelineStackResponseDto.fromJson(value); | ||||
|         case 'ToneMapping': | ||||
|           return ToneMappingTypeTransformer().decode(value); | ||||
|         case 'TranscodeHWAccel': | ||||
|  | ||||
							
								
								
									
										9
									
								
								mobile/openapi/lib/api_helper.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										9
									
								
								mobile/openapi/lib/api_helper.dart
									
									
									
										generated
									
									
									
								
							| @ -100,6 +100,12 @@ String parameterToString(dynamic value) { | ||||
|   if (value is MemoryType) { | ||||
|     return MemoryTypeTypeTransformer().encode(value).toString(); | ||||
|   } | ||||
|   if (value is NotificationLevel) { | ||||
|     return NotificationLevelTypeTransformer().encode(value).toString(); | ||||
|   } | ||||
|   if (value is NotificationType) { | ||||
|     return NotificationTypeTypeTransformer().encode(value).toString(); | ||||
|   } | ||||
|   if (value is PartnerDirection) { | ||||
|     return PartnerDirectionTypeTransformer().encode(value).toString(); | ||||
|   } | ||||
| @ -133,9 +139,6 @@ String parameterToString(dynamic value) { | ||||
|   if (value is SyncRequestType) { | ||||
|     return SyncRequestTypeTypeTransformer().encode(value).toString(); | ||||
|   } | ||||
|   if (value is TimeBucketSize) { | ||||
|     return TimeBucketSizeTypeTransformer().encode(value).toString(); | ||||
|   } | ||||
|   if (value is ToneMapping) { | ||||
|     return ToneMappingTypeTransformer().encode(value).toString(); | ||||
|   } | ||||
|  | ||||
| @ -13,6 +13,7 @@ part of openapi.api; | ||||
| class TimeBucketAssetResponseDto { | ||||
|   /// Returns a new [TimeBucketAssetResponseDto] instance. | ||||
|   TimeBucketAssetResponseDto({ | ||||
|     this.description = const [], | ||||
|     this.duration = const [], | ||||
|     this.id = const [], | ||||
|     this.isArchived = const [], | ||||
| @ -29,6 +30,8 @@ class TimeBucketAssetResponseDto { | ||||
|     this.thumbhash = const [], | ||||
|   }); | ||||
| 
 | ||||
|   List<TimelineAssetDescriptionDto> description; | ||||
| 
 | ||||
|   List<TimeBucketAssetResponseDtoDurationInner> duration; | ||||
| 
 | ||||
|   List<String> id; | ||||
| @ -59,6 +62,7 @@ class TimeBucketAssetResponseDto { | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is TimeBucketAssetResponseDto && | ||||
|     _deepEquality.equals(other.description, description) && | ||||
|     _deepEquality.equals(other.duration, duration) && | ||||
|     _deepEquality.equals(other.id, id) && | ||||
|     _deepEquality.equals(other.isArchived, isArchived) && | ||||
| @ -77,6 +81,7 @@ class TimeBucketAssetResponseDto { | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (description.hashCode) + | ||||
|     (duration.hashCode) + | ||||
|     (id.hashCode) + | ||||
|     (isArchived.hashCode) + | ||||
| @ -93,10 +98,11 @@ class TimeBucketAssetResponseDto { | ||||
|     (thumbhash.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'TimeBucketAssetResponseDto[duration=$duration, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, isVideo=$isVideo, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash]'; | ||||
|   String toString() => 'TimeBucketAssetResponseDto[description=$description, duration=$duration, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, isVideo=$isVideo, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'description'] = this.description; | ||||
|       json[r'duration'] = this.duration; | ||||
|       json[r'id'] = this.id; | ||||
|       json[r'isArchived'] = this.isArchived; | ||||
| @ -123,6 +129,7 @@ class TimeBucketAssetResponseDto { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
| 
 | ||||
|       return TimeBucketAssetResponseDto( | ||||
|         description: TimelineAssetDescriptionDto.listFromJson(json[r'description']), | ||||
|         duration: TimeBucketAssetResponseDtoDurationInner.listFromJson(json[r'duration']), | ||||
|         id: json[r'id'] is Iterable | ||||
|             ? (json[r'id'] as Iterable).cast<String>().toList(growable: false) | ||||
| @ -200,6 +207,7 @@ class TimeBucketAssetResponseDto { | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'description', | ||||
|     'duration', | ||||
|     'id', | ||||
|     'isArchived', | ||||
|  | ||||
							
								
								
									
										115
									
								
								mobile/openapi/lib/model/timeline_asset_description_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								mobile/openapi/lib/model/timeline_asset_description_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @ -0,0 +1,115 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.18 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| part of openapi.api; | ||||
| 
 | ||||
| class TimelineAssetDescriptionDto { | ||||
|   /// Returns a new [TimelineAssetDescriptionDto] instance. | ||||
|   TimelineAssetDescriptionDto({ | ||||
|     required this.city, | ||||
|     required this.country, | ||||
|   }); | ||||
| 
 | ||||
|   String? city; | ||||
| 
 | ||||
|   String? country; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is TimelineAssetDescriptionDto && | ||||
|     other.city == city && | ||||
|     other.country == country; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (city == null ? 0 : city!.hashCode) + | ||||
|     (country == null ? 0 : country!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'TimelineAssetDescriptionDto[city=$city, country=$country]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|     if (this.city != null) { | ||||
|       json[r'city'] = this.city; | ||||
|     } else { | ||||
|     //  json[r'city'] = null; | ||||
|     } | ||||
|     if (this.country != null) { | ||||
|       json[r'country'] = this.country; | ||||
|     } else { | ||||
|     //  json[r'country'] = null; | ||||
|     } | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [TimelineAssetDescriptionDto] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static TimelineAssetDescriptionDto? fromJson(dynamic value) { | ||||
|     upgradeDto(value, "TimelineAssetDescriptionDto"); | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
| 
 | ||||
|       return TimelineAssetDescriptionDto( | ||||
|         city: mapValueOfType<String>(json, r'city'), | ||||
|         country: mapValueOfType<String>(json, r'country'), | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<TimelineAssetDescriptionDto> listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <TimelineAssetDescriptionDto>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = TimelineAssetDescriptionDto.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, TimelineAssetDescriptionDto> mapFromJson(dynamic json) { | ||||
|     final map = <String, TimelineAssetDescriptionDto>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = TimelineAssetDescriptionDto.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of TimelineAssetDescriptionDto-objects as value to a dart map | ||||
|   static Map<String, List<TimelineAssetDescriptionDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<TimelineAssetDescriptionDto>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       // ignore: parameter_assignments | ||||
|       json = json.cast<String, dynamic>(); | ||||
|       for (final entry in json.entries) { | ||||
|         map[entry.key] = TimelineAssetDescriptionDto.listFromJson(entry.value, growable: growable,); | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'city', | ||||
|     'country', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| @ -13847,6 +13847,13 @@ | ||||
|       }, | ||||
|       "TimeBucketAssetResponseDto": { | ||||
|         "properties": { | ||||
|           "description": { | ||||
|             "default": [], | ||||
|             "items": { | ||||
|               "$ref": "#/components/schemas/TimelineAssetDescriptionDto" | ||||
|             }, | ||||
|             "type": "array" | ||||
|           }, | ||||
|           "duration": { | ||||
|             "default": [], | ||||
|             "items": { | ||||
| @ -13976,6 +13983,7 @@ | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "description", | ||||
|           "duration", | ||||
|           "id", | ||||
|           "isArchived", | ||||
| @ -14023,6 +14031,23 @@ | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "TimelineAssetDescriptionDto": { | ||||
|         "properties": { | ||||
|           "city": { | ||||
|             "nullable": true, | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "country": { | ||||
|             "nullable": true, | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "city", | ||||
|           "country" | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "TimelineStackResponseDto": { | ||||
|         "properties": { | ||||
|           "assetCount": { | ||||
|  | ||||
| @ -1407,12 +1407,17 @@ export type TagBulkAssetsResponseDto = { | ||||
| export type TagUpdateDto = { | ||||
|     color?: string | null; | ||||
| }; | ||||
| export type TimelineAssetDescriptionDto = { | ||||
|     city: string | null; | ||||
|     country: string | null; | ||||
| }; | ||||
| export type TimelineStackResponseDto = { | ||||
|     assetCount: number; | ||||
|     id: string; | ||||
|     primaryAssetId: string; | ||||
| }; | ||||
| export type TimeBucketAssetResponseDto = { | ||||
|     description: TimelineAssetDescriptionDto[]; | ||||
|     duration: (string | number)[]; | ||||
|     id: string[]; | ||||
|     isArchived: number[]; | ||||
|  | ||||
| @ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; | ||||
| 
 | ||||
| import { IsEnum, IsInt, IsString, Min } from 'class-validator'; | ||||
| import { AssetOrder } from 'src/enum'; | ||||
| import { TimeBucketAssets, TimelineStack } from 'src/services/timeline.service.types'; | ||||
| import { AssetDescription, TimeBucketAssets, TimelineStack } from 'src/services/timeline.service.types'; | ||||
| import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; | ||||
| 
 | ||||
| export class TimeBucketDto { | ||||
| @ -64,6 +64,13 @@ export class TimelineStackResponseDto implements TimelineStack { | ||||
|   assetCount!: number; | ||||
| } | ||||
| 
 | ||||
| export class TimelineAssetDescriptionDto implements AssetDescription { | ||||
|   @ApiProperty() | ||||
|   city!: string | null; | ||||
|   @ApiProperty() | ||||
|   country!: string | null; | ||||
| } | ||||
| 
 | ||||
| export class TimeBucketAssetResponseDto implements TimeBucketAssets { | ||||
|   @ApiProperty({ type: [String] }) | ||||
|   id: string[] = []; | ||||
| @ -154,6 +161,9 @@ export class TimeBucketAssetResponseDto implements TimeBucketAssets { | ||||
|     }, | ||||
|   }) | ||||
|   livePhotoVideoId: (string | number)[] = []; | ||||
| 
 | ||||
|   @ApiProperty() | ||||
|   description: TimelineAssetDescriptionDto[] = []; | ||||
| } | ||||
| 
 | ||||
| export class TimeBucketsResponseDto { | ||||
|  | ||||
| @ -701,7 +701,14 @@ export class AssetRepository { | ||||
|         'livePhotoVideoId', | ||||
|       ]) | ||||
|       .leftJoin('exif', 'assets.id', 'exif.assetId') | ||||
|       .select(['exif.exifImageHeight as height', 'exifImageWidth as width', 'exif.orientation', 'exif.projectionType']) | ||||
|       .select([ | ||||
|         'exif.exifImageHeight as height', | ||||
|         'exifImageWidth as width', | ||||
|         'exif.orientation', | ||||
|         'exif.projectionType', | ||||
|         'exif.city as city', | ||||
|         'exif.country as country', | ||||
|       ]) | ||||
|       .$if(!!options.albumId, (qb) => | ||||
|         qb | ||||
|           .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') | ||||
|  | ||||
| @ -58,6 +58,7 @@ export class TimelineService extends BaseService { | ||||
|       duration: [], | ||||
|       projectionType: [], | ||||
|       livePhotoVideoId: [], | ||||
|       description: [], | ||||
|     }; | ||||
|     for (const item of items) { | ||||
|       let width = item.width!; | ||||
| @ -82,6 +83,10 @@ export class TimelineService extends BaseService { | ||||
|       bucketAssets.livePhotoVideoId.push(item.livePhotoVideoId || 0); | ||||
|       bucketAssets.isImage.push(item.type === AssetType.IMAGE ? 1 : 0); | ||||
|       bucketAssets.isVideo.push(item.type === AssetType.VIDEO ? 1 : 0); | ||||
|       bucketAssets.description.push({ | ||||
|         city: item.city, | ||||
|         country: item.country, | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|  | ||||
| @ -4,6 +4,11 @@ export type TimelineStack = { | ||||
|   assetCount: number; | ||||
| }; | ||||
| 
 | ||||
| export type AssetDescription = { | ||||
|   city: string | null; | ||||
|   country: string | null; | ||||
| }; | ||||
| 
 | ||||
| export type TimeBucketAssets = { | ||||
|   id: string[]; | ||||
|   ownerId: string[]; | ||||
| @ -19,4 +24,5 @@ export type TimeBucketAssets = { | ||||
|   duration: (string | number)[]; | ||||
|   projectionType: (string | number)[]; | ||||
|   livePhotoVideoId: (string | number)[]; | ||||
|   description: AssetDescription[]; | ||||
| }; | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| import { sdkMock } from '$lib/__mocks__/sdk.mock'; | ||||
| import { AbortError } from '$lib/utils'; | ||||
| import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; | ||||
| import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory'; | ||||
| import { AssetStore } from './assets-store.svelte'; | ||||
| import { type AssetResponseDto, type TimeBucketResponseDto } from '@immich/sdk'; | ||||
| import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; | ||||
| import { AssetStore, type TimelineAsset } from './assets-store.svelte'; | ||||
| 
 | ||||
| describe('AssetStore', () => { | ||||
|   beforeEach(() => { | ||||
| @ -11,18 +11,22 @@ describe('AssetStore', () => { | ||||
| 
 | ||||
|   describe('init', () => { | ||||
|     let assetStore: AssetStore; | ||||
|     const bucketAssets: Record<string, AssetResponseDto[]> = { | ||||
|       '2024-03-01T00:00:00.000Z': assetFactory | ||||
|     const bucketAssets: Record<string, TimelineAsset[]> = { | ||||
|       '2024-03-01T00:00:00.000Z': timelineAssetFactory | ||||
|         .buildList(1) | ||||
|         .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), | ||||
|       '2024-02-01T00:00:00.000Z': assetFactory | ||||
|       '2024-02-01T00:00:00.000Z': timelineAssetFactory | ||||
|         .buildList(100) | ||||
|         .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })), | ||||
|       '2024-01-01T00:00:00.000Z': assetFactory | ||||
|       '2024-01-01T00:00:00.000Z': timelineAssetFactory | ||||
|         .buildList(3) | ||||
|         .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), | ||||
|     }; | ||||
| 
 | ||||
|     const bucketAssetsResponse: Record<string, TimeBucketResponseDto> = Object.fromEntries( | ||||
|       Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), | ||||
|     ); | ||||
| 
 | ||||
|     beforeEach(async () => { | ||||
|       assetStore = new AssetStore(); | ||||
|       sdkMock.getTimeBuckets.mockResolvedValue([ | ||||
| @ -30,13 +34,14 @@ describe('AssetStore', () => { | ||||
|         { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' }, | ||||
|         { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, | ||||
|       ]); | ||||
|       sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); | ||||
| 
 | ||||
|       sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); | ||||
|       await assetStore.updateViewport({ width: 1588, height: 1000 }); | ||||
|     }); | ||||
| 
 | ||||
|     it('should load buckets in viewport', () => { | ||||
|       expect(sdkMock.getTimeBuckets).toBeCalledTimes(1); | ||||
|       expect(sdkMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.Month }); | ||||
| 
 | ||||
|       expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2); | ||||
|     }); | ||||
| 
 | ||||
| @ -48,29 +53,31 @@ describe('AssetStore', () => { | ||||
| 
 | ||||
|       expect(plainBuckets).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 304 }), | ||||
|           expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4515.333_333_333_333 }), | ||||
|           expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 186.5 }), | ||||
|           expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 12_017 }), | ||||
|           expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), | ||||
|         ]), | ||||
|       ); | ||||
|     }); | ||||
| 
 | ||||
|     it('calculates timeline height', () => { | ||||
|       expect(assetStore.timelineHeight).toBe(5105.333_333_333_333); | ||||
|       expect(assetStore.timelineHeight).toBe(12_489.5); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('loadBucket', () => { | ||||
|     let assetStore: AssetStore; | ||||
|     const bucketAssets: Record<string, AssetResponseDto[]> = { | ||||
|       '2024-01-03T00:00:00.000Z': assetFactory | ||||
|     const bucketAssets: Record<string, TimelineAsset[]> = { | ||||
|       '2024-01-03T00:00:00.000Z': timelineAssetFactory | ||||
|         .buildList(1) | ||||
|         .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), | ||||
|       '2024-01-01T00:00:00.000Z': assetFactory | ||||
|       '2024-01-01T00:00:00.000Z': timelineAssetFactory | ||||
|         .buildList(3) | ||||
|         .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), | ||||
|     }; | ||||
| 
 | ||||
|     const bucketAssetsResponse: Record<string, TimeBucketResponseDto> = Object.fromEntries( | ||||
|       Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), | ||||
|     ); | ||||
|     beforeEach(async () => { | ||||
|       assetStore = new AssetStore(); | ||||
|       sdkMock.getTimeBuckets.mockResolvedValue([ | ||||
| @ -82,7 +89,7 @@ describe('AssetStore', () => { | ||||
|         if (signal?.aborted) { | ||||
|           throw new AbortError(); | ||||
|         } | ||||
|         return bucketAssets[timeBucket]; | ||||
|         return bucketAssetsResponse[timeBucket]; | ||||
|       }); | ||||
|       await assetStore.updateViewport({ width: 1588, height: 0 }); | ||||
|     }); | ||||
| @ -296,7 +303,9 @@ describe('AssetStore', () => { | ||||
|     }); | ||||
| 
 | ||||
|     it('removes asset from bucket', () => { | ||||
|       const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); | ||||
|       const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { | ||||
|         localDateTime: '2024-01-20T12:00:00.000Z', | ||||
|       }); | ||||
|       assetStore.addAssets([assetOne, assetTwo]); | ||||
|       assetStore.removeAssets([assetOne.id]); | ||||
| 
 | ||||
| @ -342,17 +351,20 @@ describe('AssetStore', () => { | ||||
| 
 | ||||
|   describe('getPreviousAsset', () => { | ||||
|     let assetStore: AssetStore; | ||||
|     const bucketAssets: Record<string, AssetResponseDto[]> = { | ||||
|       '2024-03-01T00:00:00.000Z': assetFactory | ||||
|     const bucketAssets: Record<string, TimelineAsset[]> = { | ||||
|       '2024-03-01T00:00:00.000Z': timelineAssetFactory | ||||
|         .buildList(1) | ||||
|         .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), | ||||
|       '2024-02-01T00:00:00.000Z': assetFactory | ||||
|       '2024-02-01T00:00:00.000Z': timelineAssetFactory | ||||
|         .buildList(6) | ||||
|         .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })), | ||||
|       '2024-01-01T00:00:00.000Z': assetFactory | ||||
|       '2024-01-01T00:00:00.000Z': timelineAssetFactory | ||||
|         .buildList(3) | ||||
|         .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), | ||||
|     }; | ||||
|     const bucketAssetsResponse: Record<string, TimeBucketResponseDto> = Object.fromEntries( | ||||
|       Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), | ||||
|     ); | ||||
| 
 | ||||
|     beforeEach(async () => { | ||||
|       assetStore = new AssetStore(); | ||||
| @ -361,8 +373,7 @@ describe('AssetStore', () => { | ||||
|         { count: 6, timeBucket: '2024-02-01T00:00:00.000Z' }, | ||||
|         { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, | ||||
|       ]); | ||||
|       sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); | ||||
| 
 | ||||
|       sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); | ||||
|       await assetStore.updateViewport({ width: 1588, height: 1000 }); | ||||
|     }); | ||||
| 
 | ||||
|  | ||||
| @ -14,9 +14,8 @@ import { | ||||
|   getAssetInfo, | ||||
|   getTimeBucket, | ||||
|   getTimeBuckets, | ||||
|   TimeBucketSize, | ||||
|   type AssetResponseDto, | ||||
|   type AssetStackResponseDto, | ||||
|   type TimeBucketResponseDto, | ||||
| } from '@immich/sdk'; | ||||
| import { clamp, debounce, isEqual, throttle } from 'lodash-es'; | ||||
| import { DateTime } from 'luxon'; | ||||
| @ -84,7 +83,7 @@ export type TimelineAsset = { | ||||
|   duration: string | null; | ||||
|   projectionType: string | null; | ||||
|   livePhotoVideoId: string | null; | ||||
|   text: { | ||||
|   description: { | ||||
|     city: string | null; | ||||
|     country: string | null; | ||||
|     people: string[]; | ||||
| @ -418,11 +417,34 @@ export class AssetBucket { | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   #decodeString(stringOrNumber: string | number) { | ||||
|     return typeof stringOrNumber === 'number' ? null : (stringOrNumber as string); | ||||
|   } | ||||
| 
 | ||||
|   // note - if the assets are not part of this bucket, they will not be added
 | ||||
|   addAssets(bucketResponse: AssetResponseDto[]) { | ||||
|   addAssets(bucketResponse: TimeBucketResponseDto) { | ||||
|     const addContext = new AddContext(); | ||||
|     for (const asset of bucketResponse) { | ||||
|       const timelineAsset = toTimelineAsset(asset); | ||||
|     for (let i = 0; i < bucketResponse.bucketAssets.id.length; i++) { | ||||
|       const timelineAsset: TimelineAsset = { | ||||
|         description: { | ||||
|           ...bucketResponse.bucketAssets.description[i], | ||||
|           people: [], | ||||
|         }, | ||||
|         duration: this.#decodeString(bucketResponse.bucketAssets.duration[i]), | ||||
|         id: bucketResponse.bucketAssets.id[i], | ||||
|         isArchived: !!bucketResponse.bucketAssets.isArchived[i], | ||||
|         isFavorite: !!bucketResponse.bucketAssets.isFavorite[i], | ||||
|         isImage: !!bucketResponse.bucketAssets.isImage[i], | ||||
|         isTrashed: !!bucketResponse.bucketAssets.isTrashed[i], | ||||
|         isVideo: !!bucketResponse.bucketAssets.isVideo[i], | ||||
|         livePhotoVideoId: this.#decodeString(bucketResponse.bucketAssets.livePhotoVideoId[i]), | ||||
|         localDateTime: bucketResponse.bucketAssets.localDateTime[i], | ||||
|         ownerId: bucketResponse.bucketAssets.ownerId[i], | ||||
|         projectionType: this.#decodeString(bucketResponse.bucketAssets.projectionType[i]), | ||||
|         ratio: bucketResponse.bucketAssets.ratio[i], | ||||
|         stack: bucketResponse.bucketAssets.stack[i], | ||||
|         thumbhash: this.#decodeString(bucketResponse.bucketAssets.thumbhash[i]), | ||||
|       }; | ||||
|       this.addTimelineAsset(timelineAsset, addContext); | ||||
|     } | ||||
| 
 | ||||
| @ -878,7 +900,6 @@ export class AssetStore { | ||||
|   async #initialiazeTimeBuckets() { | ||||
|     const timebuckets = await getTimeBuckets({ | ||||
|       ...this.#options, | ||||
|       size: TimeBucketSize.Month, | ||||
|       key: authManager.key, | ||||
|     }); | ||||
| 
 | ||||
| @ -1086,7 +1107,7 @@ export class AssetStore { | ||||
|         { | ||||
|           ...this.#options, | ||||
|           timeBucket: bucketDate, | ||||
|           size: TimeBucketSize.Month, | ||||
| 
 | ||||
|           key: authManager.key, | ||||
|         }, | ||||
|         { signal }, | ||||
| @ -1097,12 +1118,11 @@ export class AssetStore { | ||||
|             { | ||||
|               albumId: this.#options.timelineAlbumId, | ||||
|               timeBucket: bucketDate, | ||||
|               size: TimeBucketSize.Month, | ||||
|               key: authManager.key, | ||||
|             }, | ||||
|             { signal }, | ||||
|           ); | ||||
|           for (const { id } of albumAssets) { | ||||
|           for (const id of albumAssets.bucketAssets.id) { | ||||
|             this.albumAssets.add(id); | ||||
|           } | ||||
|         } | ||||
|  | ||||
| @ -38,15 +38,10 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number | ||||
|   return 300; | ||||
| } | ||||
| 
 | ||||
| export const getAltTextForTimelineAsset = (_: TimelineAsset) => { | ||||
|   // TODO: implement this in a performant way
 | ||||
|   return ''; | ||||
| }; | ||||
| 
 | ||||
| export const getAltText = derived(t, ($t) => { | ||||
|   return (asset: TimelineAsset) => { | ||||
|     const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) }); | ||||
|     const { city, country, people: names } = asset.text; | ||||
|     const { city, country, people: names } = asset.description; | ||||
|     const hasPlace = city && country; | ||||
| 
 | ||||
|     const peopleCount = names.length; | ||||
|  | ||||
| @ -71,7 +71,8 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): | ||||
|   const city = assetResponse.exifInfo?.city; | ||||
|   const country = assetResponse.exifInfo?.country; | ||||
|   const people = assetResponse.people?.map((person) => person.name) || []; | ||||
|   const text = { | ||||
| 
 | ||||
|   const description = { | ||||
|     city: city || null, | ||||
|     country: country || null, | ||||
|     people, | ||||
| @ -91,7 +92,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): | ||||
|     duration: assetResponse.duration || null, | ||||
|     projectionType: assetResponse.exifInfo?.projectionType || null, | ||||
|     livePhotoVideoId: assetResponse.livePhotoVideoId || null, | ||||
|     text, | ||||
|     description, | ||||
|   }; | ||||
| }; | ||||
| export const isTimelineAsset = (arg: AssetResponseDto | TimelineAsset): arg is TimelineAsset => | ||||
|  | ||||
| @ -1,6 +1,12 @@ | ||||
| import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; | ||||
| import { faker } from '@faker-js/faker'; | ||||
| import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; | ||||
| import { | ||||
|   AssetTypeEnum, | ||||
|   type AssetResponseDto, | ||||
|   type TimeBucketAssetResponseDto, | ||||
|   type TimeBucketResponseDto, | ||||
|   type TimelineStackResponseDto, | ||||
| } from '@immich/sdk'; | ||||
| import { Sync } from 'factory.ts'; | ||||
| 
 | ||||
| export const assetFactory = Sync.makeFactory<AssetResponseDto>({ | ||||
| @ -42,9 +48,51 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({ | ||||
|   stack: null, | ||||
|   projectionType: null, | ||||
|   livePhotoVideoId: Sync.each(() => faker.string.uuid()), | ||||
|   text: Sync.each(() => ({ | ||||
|   description: Sync.each(() => ({ | ||||
|     city: faker.location.city(), | ||||
|     country: faker.location.country(), | ||||
|     people: [faker.person.fullName()], | ||||
|   })), | ||||
| }); | ||||
| 
 | ||||
| export const toResponseDto = (...timelineAsset: TimelineAsset[]) => { | ||||
|   const bucketAssets: TimeBucketAssetResponseDto = { | ||||
|     description: [], | ||||
|     duration: [], | ||||
|     id: [], | ||||
|     isArchived: [], | ||||
|     isFavorite: [], | ||||
|     isImage: [], | ||||
|     isTrashed: [], | ||||
|     isVideo: [], | ||||
|     livePhotoVideoId: [], | ||||
|     localDateTime: [], | ||||
|     ownerId: [], | ||||
|     projectionType: [], | ||||
|     ratio: [], | ||||
|     stack: [], | ||||
|     thumbhash: [], | ||||
|   }; | ||||
|   for (const asset of timelineAsset) { | ||||
|     bucketAssets.description.push(asset.description); | ||||
|     bucketAssets.duration.push(asset.duration || 0); | ||||
|     bucketAssets.id.push(asset.id); | ||||
|     bucketAssets.isArchived.push(asset.isArchived ? 1 : 0); | ||||
|     bucketAssets.isFavorite.push(asset.isFavorite ? 1 : 0); | ||||
|     bucketAssets.isImage.push(asset.isImage ? 1 : 0); | ||||
|     bucketAssets.isTrashed.push(asset.isTrashed ? 1 : 0); | ||||
|     bucketAssets.isVideo.push(asset.isVideo ? 1 : 0); | ||||
|     bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId || 0); | ||||
|     bucketAssets.localDateTime.push(asset.localDateTime); | ||||
|     bucketAssets.ownerId.push(asset.ownerId); | ||||
|     bucketAssets.projectionType.push(asset.projectionType || 0); | ||||
|     bucketAssets.ratio.push(asset.ratio); | ||||
|     bucketAssets.stack.push(asset.stack as TimelineStackResponseDto); | ||||
|     bucketAssets.thumbhash.push(asset.thumbhash || 0); | ||||
|   } | ||||
|   const response: TimeBucketResponseDto = { | ||||
|     bucketAssets, | ||||
|     hasNextPage: false, | ||||
|   }; | ||||
|   return response; | ||||
| }; | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user