merge main

This commit is contained in:
martabal 2023-11-08 14:47:58 +01:00
commit 97206faadb
No known key found for this signature in database
GPG key ID: C00196E3148A52BD
99 changed files with 1687 additions and 214 deletions

View file

@ -38,7 +38,7 @@ jobs:
-
name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.3.0
uses: stumpylog/image-cleaner-action/ephemeral@v0.4.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"
@ -70,7 +70,7 @@ jobs:
-
name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.3.0
uses: stumpylog/image-cleaner-action/untagged@v0.4.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"

View file

@ -2,7 +2,7 @@
<br/>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a>
<br/>
<br/>

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.84.0
* The version of the OpenAPI document: 1.85.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.84.0
* The version of the OpenAPI document: 1.85.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.84.0
* The version of the OpenAPI document: 1.85.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.84.0
* The version of the OpenAPI document: 1.85.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -1,3 +1,7 @@
# See:
# - https://immich.app/docs/developer/setup
# - https://immich.app/docs/developer/troubleshooting
version: "3.8"
services:
@ -71,10 +75,6 @@ services:
command: npm run dev --host
env_file:
- .env
environment:
# Rename these values for svelte public interface
- PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL}
- PUBLIC_IMMICH_API_URL_EXTERNAL=${IMMICH_API_URL_EXTERNAL}
ports:
- 3000:3000
- 24678:24678

View file

@ -7,9 +7,9 @@ services:
build:
context: ../server
dockerfile: Dockerfile
command: ["./start-server.sh"]
command: [ "./start-server.sh" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
@ -18,19 +18,6 @@ services:
- database
- typesense
immich-machine-learning:
container_name: immich_machine_learning
image: immich-machine-learning:latest
build:
context: ../machine-learning
dockerfile: Dockerfile
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- model-cache:/cache
env_file:
- .env
restart: always
immich-microservices:
container_name: immich_microservices
image: immich-microservices:latest
@ -40,9 +27,9 @@ services:
build:
context: ../server
dockerfile: Dockerfile
command: ["./start-microservices.sh"]
command: [ "./start-microservices.sh" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
@ -64,6 +51,18 @@ services:
depends_on:
- immich-server
immich-machine-learning:
container_name: immich_machine_learning
image: immich-machine-learning:latest
build:
context: ../machine-learning
dockerfile: Dockerfile
volumes:
- model-cache:/cache
env_file:
- .env
restart: always
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
@ -73,7 +72,7 @@ services:
# remove this to get debug messages
- GLOG_minloglevel=1
volumes:
- tsdata:/data
- ${UPLOAD_LOCATION}/typesense:/data
restart: always
redis:
@ -91,7 +90,7 @@ services:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
restart: always
immich-proxy:
@ -113,6 +112,4 @@ services:
restart: always
volumes:
pgdata:
model-cache:
tsdata:

View file

@ -0,0 +1,19 @@
# Troubleshooting
:::tip
A great option to get assistance with troubleshooting is to join our [Discord](https://discord.gg/D8JsnBEuKb) server, where we have a dedicated channel for `#contributing`.
:::
## Known Issues
### Running on Windows
Running Immich on Windows can be frustrating and there are lots of ways it can go wrong. Where possible we recommend using Docker on Linux. However, several people have had success running Immich on Windows using Docker via WSL2.
### NTFS Mounted Volumes
The docker-compose.dev.yml and docker-compose.prod.yml use volume mounts for the postgres database. On start-up, postgres will try to `chown` the data directory, but fail. See [this post](https://forums.docker.com/t/data-directory-var-lib-postgresql-data-pgdata-has-wrong-ownership/17963/24) for more information about this issue and possible solutions.
### `Cannot read properties of null (reading 'split')`
This error occurs when trying to access the app via port `3000` instead of `2283`. During development `immich-proxy` runs on port 2283, while `immich-web` runs on `3000`.

View file

@ -4,6 +4,10 @@ You can use the CLI to upload an existing gallery to the Immich server
[Immich CLI Repository](https://github.com/immich-app/CLI)
:::tip Google Photos Takeout
If you are looking to import your Google Photos takeout, we recommed this community maintained tool [immich-go](https://github.com/simulot/immich-go)
:::
## Requirements
- Node.js 16 or above

View file

@ -1,5 +1,7 @@
# Facial Recognition
## Overview
Immich recognizes faces in your photos and videos and groups them together. You can then assign names to the faces and search for them.
The list of people is shown in the Explore page.
@ -13,3 +15,16 @@ Upon clicking on a person, a list of assets that contain their face will be show
The asset detail view will also show the faces that are recognized in the asset.
<img src={require('./img/facial-recognition-3.png').default} title='Facial Recognition 3' />
## Actions
Additional actions you can do with a detected person are:
- Change the feature face photo of the person
- Set date of birth
- Merge two or more detected faces into one person
- Hide face
It can be found from the app bar when you access the detial view of a person
<img src={require('./img/facial-recognition-4.png').default} title='Facial Recognition 4' width="70%"/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

View file

@ -1,6 +1,6 @@
import MobileAppDownload from '../partials/_mobile-app-download.md';
import MobileAppLogin from '../partials/_mobile-app-login.md';
import MobileAppBackup from '../partials/_mobile-app-login.md';
import MobileAppBackup from '../partials/_mobile-app-backup.md';
# Mobile App

View file

@ -0,0 +1,42 @@
# Python File Upload
```python
#!/usr/bin/python3
import requests
import os
from datetime import datetime
API_KEY = 'YOUR_API_KEY' # replace with a valid api key
BASE_URL = 'http://127.0.0.1:2283/api' # replace as needed
def upload(file):
stats = os.stat(file)
headers = {
'Accept': 'application/json',
'x-api-key': API_KEY
}
data = {
'deviceAssetId': f'{file}-{stats.st_mtime}',
'deviceId': 'python',
'fileCreatedAt': datetime.fromtimestamp(stats.st_mtime),
'fileModifiedAt': datetime.fromtimestamp(stats.st_mtime),
'isFavorite': 'false',
}
files = {
'assetData': open(file, 'rb')
}
response = requests.post(
f'{BASE_URL}/asset/upload', headers=headers, data=data, files=files)
print(response.json())
# {'id': 'ef96f635-61c7-4639-9e60-61a11c4bbfba', 'duplicate': False}
upload('./test.jpg')
```

View file

@ -17,6 +17,12 @@ The default configuration looks like this:
"targetAudioCodec": "aac",
"targetResolution": "720",
"maxBitrate": "0",
"bframes": -1,
"refs": 0,
"gopSize": 0,
"npl": 0,
"temporalAQ": false,
"cqMode": "auto",
"twoPass": false,
"transcode": "required",
"tonemap": "hable",
@ -44,9 +50,15 @@ The default configuration looks like this:
"sidecar": {
"concurrency": 5
},
"library": {
"concurrency": 5
},
"storageTemplateMigration": {
"concurrency": 5
},
"migration": {
"concurrency": 5
},
"thumbnailGeneration": {
"concurrency": 5
},
@ -55,16 +67,16 @@ The default configuration looks like this:
}
},
"machineLearning": {
"classification": {
"minScore": 0.7,
"enabled": true,
"modelName": "microsoft/resnet-50"
},
"enabled": true,
"url": "http://immich-machine-learning:3003",
"classification": {
"enabled": true,
"modelName": "microsoft/resnet-50",
"minScore": 0.9
},
"clip": {
"enabled": true,
"modelName": "ViT-B-32::openai"
"modelName": "ViT-B-32__openai"
},
"facialRecognition": {
"enabled": true,
@ -74,6 +86,14 @@ The default configuration looks like this:
"minFaces": 1
}
},
"map": {
"enabled": true,
"tileUrl": "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
},
"reverseGeocoding": {
"enabled": true,
"citiesFileOverride": "cities500"
},
"oauth": {
"enabled": false,
"issuerUrl": "",
@ -96,8 +116,27 @@ The default configuration looks like this:
"thumbnail": {
"webpSize": 250,
"jpegSize": 1440,
"quality": 90,
"quality": 80,
"colorspace": "p3"
},
"newVersionCheck": {
"enabled": true
},
"trash": {
"enabled": true,
"days": 30
},
"theme": {
"customCss": ""
},
"library": {
"scan": {
"enabled": true,
"cronExpression": "0 0 * * *"
}
},
"stylesheets": {
"css": ""
}
}
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 334 KiB

View file

@ -34,7 +34,7 @@ function HomepageHeader() {
</Link>
</div>
<img src="/img/immich-screenshots.png" alt="logo" />
<img src="/img/immich-screenshots.png" alt="screenshots" width={'85%'} />
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-4 gap-1">
<div className="h-24">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.84.0"
version = "1.85.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 108,
"android.injected.version.name" => "1.84.0",
"android.injected.version.code" => 109,
"android.injected.version.name" => "1.85.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View file

@ -373,5 +373,11 @@
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"app_bar_signout_dialog_title": "Sign out",
"app_bar_signout_dialog_content": "Are you sure you wanna sign out?",
"app_bar_signout_dialog_ok": "Yes"
"app_bar_signout_dialog_ok": "Yes",
"shared_album_activities_input_hint": "Say something",
"shared_album_activity_remove_title": "Delete Activity",
"shared_album_activity_remove_content": "Do you want to delete this activity?",
"shared_album_activity_setting_title": "Comments & likes",
"shared_album_activity_setting_subtitle": "Let others respond",
"shared_album_activities_input_disable": "Comment is disabled"
}

View file

@ -169,4 +169,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
COCOAPODS: 1.12.1
COCOAPODS: 1.11.3

View file

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.84.0"
version_number: "1.85.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View file

@ -0,0 +1,90 @@
import 'package:immich_mobile/shared/models/user.dart';
import 'package:openapi/api.dart';
enum ActivityType { comment, like }
class Activity {
final String id;
final String? assetId;
final String? comment;
final DateTime createdAt;
final ActivityType type;
final User user;
const Activity({
required this.id,
this.assetId,
this.comment,
required this.createdAt,
required this.type,
required this.user,
});
Activity copyWith({
String? id,
String? assetId,
String? comment,
DateTime? createdAt,
ActivityType? type,
User? user,
}) {
return Activity(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
comment: comment ?? this.comment,
createdAt: createdAt ?? this.createdAt,
type: type ?? this.type,
user: user ?? this.user,
);
}
Activity.fromDto(ActivityResponseDto dto)
: id = dto.id,
assetId = dto.assetId,
comment = dto.comment,
createdAt = dto.createdAt,
type = dto.type == ActivityResponseDtoTypeEnum.comment
? ActivityType.comment
: ActivityType.like,
user = User(
email: dto.user.email,
firstName: dto.user.firstName,
lastName: dto.user.lastName,
profileImagePath: dto.user.profileImagePath,
id: dto.user.id,
// Placeholder values
isAdmin: false,
updatedAt: DateTime.now(),
isPartnerSharedBy: false,
isPartnerSharedWith: false,
memoryEnabled: false,
);
@override
String toString() {
return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Activity &&
other.id == id &&
other.assetId == assetId &&
other.comment == comment &&
other.createdAt == createdAt &&
other.type == type &&
other.user == user;
}
@override
int get hashCode {
return id.hashCode ^
assetId.hashCode ^
comment.hashCode ^
createdAt.hashCode ^
type.hashCode ^
user.hashCode;
}
}

View file

@ -0,0 +1,130 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/modules/activities/services/activity.service.dart';
class ActivityNotifier extends StateNotifier<AsyncValue<List<Activity>>> {
final Ref _ref;
final ActivityService _activityService;
final String albumId;
final String? assetId;
ActivityNotifier(
this._ref,
this._activityService,
this.albumId,
this.assetId,
) : super(
const AsyncData([]),
) {
fetchActivity();
}
Future<void> fetchActivity() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => _activityService.getAllActivities(albumId, assetId),
);
}
Future<void> removeActivity(String id) async {
final activities = state.asData?.value ?? [];
if (await _activityService.removeActivity(id)) {
final removedActivity = activities.firstWhere((a) => a.id == id);
activities.remove(removedActivity);
state = AsyncData(activities);
if (removedActivity.type == ActivityType.comment) {
_ref
.read(
activityStatisticsStateProvider(
(albumId: albumId, assetId: assetId),
).notifier,
)
.removeActivity();
}
}
}
Future<void> addComment(String comment) async {
final activity = await _activityService.addActivity(
albumId,
ActivityType.comment,
assetId: assetId,
comment: comment,
);
if (activity != null) {
final activities = state.asData?.value ?? [];
state = AsyncData([...activities, activity]);
_ref
.read(
activityStatisticsStateProvider(
(albumId: albumId, assetId: assetId),
).notifier,
)
.addActivity();
if (assetId != null) {
// Add a count to the current album's provider as well
_ref
.read(
activityStatisticsStateProvider(
(albumId: albumId, assetId: null),
).notifier,
)
.addActivity();
}
}
}
Future<void> addLike() async {
final activity = await _activityService
.addActivity(albumId, ActivityType.like, assetId: assetId);
if (activity != null) {
final activities = state.asData?.value ?? [];
state = AsyncData([...activities, activity]);
}
}
}
class ActivityStatisticsNotifier extends StateNotifier<int> {
final String albumId;
final String? assetId;
final ActivityService _activityService;
ActivityStatisticsNotifier(this._activityService, this.albumId, this.assetId)
: super(0) {
fetchStatistics();
}
Future<void> fetchStatistics() async {
state = await _activityService.getStatistics(albumId, assetId: assetId);
}
Future<void> addActivity() async {
state = state + 1;
}
Future<void> removeActivity() async {
state = state - 1;
}
}
typedef ActivityParams = ({String albumId, String? assetId});
final activityStateProvider = StateNotifierProvider.autoDispose
.family<ActivityNotifier, AsyncValue<List<Activity>>, ActivityParams>(
(ref, args) {
return ActivityNotifier(
ref,
ref.watch(activityServiceProvider),
args.albumId,
args.assetId,
);
});
final activityStatisticsStateProvider = StateNotifierProvider.autoDispose
.family<ActivityStatisticsNotifier, int, ActivityParams>((ref, args) {
return ActivityStatisticsNotifier(
ref.watch(activityServiceProvider),
args.albumId,
args.assetId,
);
});

View file

@ -0,0 +1,85 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final activityServiceProvider =
Provider((ref) => ActivityService(ref.watch(apiServiceProvider)));
class ActivityService {
final ApiService _apiService;
final Logger _log = Logger("ActivityService");
ActivityService(this._apiService);
Future<List<Activity>> getAllActivities(
String albumId,
String? assetId,
) async {
try {
final list = await _apiService.activityApi
.getActivities(albumId, assetId: assetId);
return list != null ? list.map(Activity.fromDto).toList() : [];
} catch (e) {
_log.severe(
"failed to fetch activities for albumId - $albumId; assetId - $assetId -> $e",
);
rethrow;
}
}
Future<int> getStatistics(String albumId, {String? assetId}) async {
try {
final dto = await _apiService.activityApi
.getActivityStatistics(albumId, assetId: assetId);
return dto?.comments ?? 0;
} catch (e) {
_log.severe(
"failed to fetch activity statistics for albumId - $albumId; assetId - $assetId -> $e",
);
}
return 0;
}
Future<bool> removeActivity(String id) async {
try {
await _apiService.activityApi.deleteActivity(id);
return true;
} catch (e) {
_log.severe(
"failed to remove activity id - $id -> $e",
);
}
return false;
}
Future<Activity?> addActivity(
String albumId,
ActivityType type, {
String? assetId,
String? comment,
}) async {
try {
final dto = await _apiService.activityApi.createActivity(
ActivityCreateDto(
albumId: albumId,
type: type == ActivityType.comment
? ReactionType.comment
: ReactionType.like,
assetId: assetId,
comment: comment,
),
);
if (dto != null) {
return Activity.fromDto(dto);
}
} catch (e) {
_log.severe(
"failed to add activity for albumId - $albumId; assetId - $assetId -> $e",
);
}
return null;
}
}

View file

@ -0,0 +1,320 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/utils/datetime_extensions.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class ActivitiesPage extends HookConsumerWidget {
final String albumId;
final String? assetId;
final bool withAssetThumbs;
final String appBarTitle;
final bool isOwner;
final bool isReadOnly;
const ActivitiesPage(
this.albumId, {
this.appBarTitle = "",
this.assetId,
this.withAssetThumbs = true,
this.isOwner = false,
this.isReadOnly = false,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider =
activityStateProvider((albumId: albumId, assetId: assetId));
final activities = ref.watch(provider);
final inputController = useTextEditingController();
final inputFocusNode = useFocusNode();
final listViewScrollController = useScrollController();
final currentUser = Store.tryGet(StoreKey.currentUser);
useEffect(
() {
inputFocusNode.requestFocus();
return null;
},
[],
);
buildTitleWithTimestamp(Activity activity, {bool leftAlign = true}) {
final textColor = Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black;
final textStyle = Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: textColor.withOpacity(0.6));
return Row(
mainAxisAlignment: leftAlign
? MainAxisAlignment.start
: MainAxisAlignment.spaceBetween,
mainAxisSize: leftAlign ? MainAxisSize.min : MainAxisSize.max,
children: [
Text(
"${activity.user.firstName} ${activity.user.lastName}",
style: textStyle,
overflow: TextOverflow.ellipsis,
),
if (leftAlign)
Text(
"",
style: textStyle,
),
Expanded(
child: Text(
activity.createdAt.copyWith().timeAgo(),
style: textStyle,
overflow: TextOverflow.ellipsis,
textAlign: leftAlign ? TextAlign.left : TextAlign.right,
),
),
],
);
}
buildAssetThumbnail(Activity activity) {
return withAssetThumbs && activity.assetId != null
? Container(
width: 40,
height: 30,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
image: DecorationImage(
image: CachedNetworkImageProvider(
getThumbnailUrlForRemoteId(
activity.assetId!,
),
cacheKey: getThumbnailCacheKeyForRemoteId(
activity.assetId!,
),
headers: {
"Authorization":
'Bearer ${Store.get(StoreKey.accessToken)}',
},
),
fit: BoxFit.cover,
),
),
child: const SizedBox.shrink(),
)
: null;
}
buildTextField(String? likedId) {
final liked = likedId != null;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: TextField(
controller: inputController,
enabled: !isReadOnly,
focusNode: inputFocusNode,
textInputAction: TextInputAction.send,
autofocus: false,
decoration: InputDecoration(
border: InputBorder.none,
focusedBorder: InputBorder.none,
prefixIcon: currentUser != null
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: UserCircleAvatar(
user: currentUser,
size: 30,
radius: 15,
),
)
: null,
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 10),
child: IconButton(
icon: Icon(
liked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
),
onPressed: () async {
liked
? await ref
.read(provider.notifier)
.removeActivity(likedId)
: await ref.read(provider.notifier).addLike();
},
),
),
suffixIconColor: liked ? Colors.red[700] : null,
hintText: isReadOnly
? 'shared_album_activities_input_disable'.tr()
: 'shared_album_activities_input_hint'.tr(),
hintStyle: TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
color: Colors.grey[600],
),
),
onEditingComplete: () async {
await ref.read(provider.notifier).addComment(inputController.text);
inputController.clear();
inputFocusNode.unfocus();
listViewScrollController.animateTo(
listViewScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 800),
curve: Curves.fastOutSlowIn,
);
},
onTapOutside: (_) => inputFocusNode.unfocus(),
),
);
}
getDismissibleWidget(
Widget widget,
Activity activity,
bool canDelete,
) {
return Dismissible(
key: Key(activity.id),
dismissThresholds: const {
DismissDirection.horizontal: 0.7,
},
direction: DismissDirection.horizontal,
confirmDismiss: (direction) => canDelete
? showDialog(
context: context,
builder: (context) => ConfirmDialog(
onOk: () {},
title: "shared_album_activity_remove_title",
content: "shared_album_activity_remove_content",
ok: "delete_dialog_ok",
),
)
: Future.value(false),
onDismissed: (direction) async =>
await ref.read(provider.notifier).removeActivity(activity.id),
background: Container(
color: canDelete ? Colors.red[400] : Colors.grey[600],
alignment: AlignmentDirectional.centerStart,
child: canDelete
? const Padding(
padding: EdgeInsets.all(15),
child: Icon(
Icons.delete_sweep_rounded,
color: Colors.black,
),
)
: null,
),
secondaryBackground: Container(
color: canDelete ? Colors.red[400] : Colors.grey[600],
alignment: AlignmentDirectional.centerEnd,
child: canDelete
? const Padding(
padding: EdgeInsets.all(15),
child: Icon(
Icons.delete_sweep_rounded,
color: Colors.black,
),
)
: null,
),
child: widget,
);
}
return Scaffold(
appBar: AppBar(title: Text(appBarTitle)),
body: activities.maybeWhen(
orElse: () {
return const Center(child: ImmichLoadingIndicator());
},
data: (data) {
final liked = data.firstWhereOrNull(
(a) =>
a.type == ActivityType.like &&
a.user.id == currentUser?.id &&
a.assetId == assetId,
);
return SafeArea(
child: Stack(
children: [
ListView.builder(
controller: listViewScrollController,
itemCount: data.length + 1,
itemBuilder: (context, index) {
// Vertical gap after the last element
if (index == data.length) {
return const SizedBox(
height: 80,
);
}
final activity = data[index];
final canDelete =
activity.user.id == currentUser?.id || isOwner;
return Padding(
padding: const EdgeInsets.all(5),
child: activity.type == ActivityType.comment
? getDismissibleWidget(
ListTile(
minVerticalPadding: 15,
leading: UserCircleAvatar(user: activity.user),
title: buildTitleWithTimestamp(
activity,
leftAlign: withAssetThumbs &&
activity.assetId != null,
),
titleAlignment: ListTileTitleAlignment.top,
trailing: buildAssetThumbnail(activity),
subtitle: Text(activity.comment!),
),
activity,
canDelete,
)
: getDismissibleWidget(
ListTile(
minVerticalPadding: 15,
leading: Container(
width: 44,
alignment: Alignment.center,
child: Icon(
Icons.favorite_rounded,
color: Colors.red[700],
),
),
title: buildTitleWithTimestamp(activity),
trailing: buildAssetThumbnail(activity),
),
activity,
canDelete,
),
);
},
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildTextField(liked?.id),
),
),
],
),
);
},
),
);
}
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@ -10,7 +11,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
SharedAlbumNotifier(this._albumService, Isar db) : super([]) {
SharedAlbumNotifier(this._albumService, Isar db, this._ref) : super([]) {
final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc();
query.findAll().then((value) => state = value);
_streamSub = query.watch().listen((data) => state = data);
@ -18,6 +19,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
final AlbumService _albumService;
late final StreamSubscription<List<Album>> _streamSub;
final Ref _ref;
Future<Album?> createSharedAlbum(
String albumName,
@ -66,6 +68,17 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
return result;
}
Future<bool> setActivityEnabled(Album album, bool activityEnabled) async {
final result =
await _albumService.setActivityEnabled(album, activityEnabled);
if (result) {
_ref.invalidate(albumDetailProvider(album.id));
}
return result;
}
@override
void dispose() {
_streamSub.cancel();
@ -78,5 +91,6 @@ final sharedAlbumProvider =
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
ref,
);
});

View file

@ -284,6 +284,23 @@ class AlbumService {
return false;
}
Future<bool> setActivityEnabled(Album album, bool enabled) async {
try {
final result = await _apiService.albumApi.updateAlbumInfo(
album.remoteId!,
UpdateAlbumDto(isActivityEnabled: enabled),
);
if (result != null) {
album.activityEnabled = enabled;
await _db.writeTxn(() => _db.albums.put(album));
return true;
}
} catch (e) {
debugPrint("Error setActivityEnabled ${e.toString()}");
}
return false;
}
Future<bool> deleteAlbum(Album album) async {
try {
final userId = Store.get(StoreKey.currentUser).isarId;

View file

@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
@ -26,6 +27,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
required this.titleFocusNode,
this.onAddPhotos,
this.onAddUsers,
required this.onActivities,
}) : super(key: key);
final Album album;
@ -35,11 +37,19 @@ class AlbumViewerAppbar extends HookConsumerWidget
final FocusNode titleFocusNode;
final Function(Album album)? onAddPhotos;
final Function(Album album)? onAddUsers;
final Function(Album album) onActivities;
@override
Widget build(BuildContext context, WidgetRef ref) {
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
final comments = album.shared
? ref.watch(
activityStatisticsStateProvider(
(albumId: album.remoteId!, assetId: null),
),
)
: 0;
deleteAlbum() async {
ImmichLoadingOverlayController.appLoader.show();
@ -206,32 +216,36 @@ class AlbumViewerAppbar extends HookConsumerWidget
).tr(),
onTap: () => onShareAssetsTo(),
),
album.ownerId == userId ? ListTile(
leading: const Icon(Icons.delete_sweep_rounded),
title: const Text(
'album_viewer_appbar_share_remove',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onRemoveFromAlbumPressed(),
) : const SizedBox(),
album.ownerId == userId
? ListTile(
leading: const Icon(Icons.delete_sweep_rounded),
title: const Text(
'album_viewer_appbar_share_remove',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onRemoveFromAlbumPressed(),
)
: const SizedBox(),
];
} else {
return [
album.ownerId == userId ? ListTile(
leading: const Icon(Icons.delete_forever_rounded),
title: const Text(
'album_viewer_appbar_share_delete',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onDeleteAlbumPressed(),
) : ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text(
'album_viewer_appbar_share_leave',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onLeaveAlbumPressed(),
),
album.ownerId == userId
? ListTile(
leading: const Icon(Icons.delete_forever_rounded),
title: const Text(
'album_viewer_appbar_share_delete',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onDeleteAlbumPressed(),
)
: ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text(
'album_viewer_appbar_share_leave',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
onTap: () => onLeaveAlbumPressed(),
),
];
}
}
@ -310,6 +324,33 @@ class AlbumViewerAppbar extends HookConsumerWidget
);
}
Widget buildActivitiesButton() {
return IconButton(
onPressed: () {
onActivities(album);
},
icon: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.mode_comment_outlined,
),
if (comments != 0)
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text(
comments.toString(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
],
),
);
}
buildLeadingButton() {
if (selected.isNotEmpty) {
return IconButton(
@ -353,6 +394,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
title: selected.isNotEmpty ? Text('${selected.length}') : null,
centerTitle: false,
actions: [
if (album.shared && (album.activityEnabled || comments != 0))
buildActivitiesButton(),
if (album.isRemote)
IconButton(
splashRadius: 25,

View file

@ -23,6 +23,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
final sharedUsers = useState(album.sharedUsers.toList());
final owner = album.owner.value;
final userId = ref.watch(authenticationProvider).userId;
final activityEnabled = useState(album.activityEnabled);
final isOwner = owner?.id == userId;
void showErrorMessage() {
@ -190,6 +191,31 @@ class AlbumOptionsPage extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isOwner && album.shared)
SwitchListTile.adaptive(
value: activityEnabled.value,
onChanged: (bool value) async {
activityEnabled.value = value;
if (await ref
.read(sharedAlbumProvider.notifier)
.setActivityEnabled(album, value)) {
album.activityEnabled = value;
}
},
activeColor: activityEnabled.value
? Theme.of(context).primaryColor
: Theme.of(context).disabledColor,
dense: true,
title: Text(
"shared_album_activity_setting_title",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
subtitle:
const Text("shared_album_activity_setting_subtitle").tr(),
),
buildSectionTitle("PEOPLE"),
buildOwnerInfo(),
buildSharedUsersList(),

View file

@ -231,6 +231,19 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
onActivitiesPressed(Album album) {
if (album.remoteId != null) {
AutoRouter.of(context).push(
ActivitiesRoute(
albumId: album.remoteId!,
appBarTitle: album.name,
isOwner: userId == album.ownerId,
isReadOnly: !album.activityEnabled,
),
);
}
}
return Scaffold(
appBar: album.when(
data: (data) => AlbumViewerAppbar(
@ -241,6 +254,7 @@ class AlbumViewerPage extends HookConsumerWidget {
selectionDisabled: disableSelection,
onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed,
onActivities: onActivitiesPressed,
),
error: (error, stackTrace) => AppBar(title: const Text("Error")),
loading: () => AppBar(),
@ -265,6 +279,8 @@ class AlbumViewerPage extends HookConsumerWidget {
],
),
isOwner: userId == data.ownerId,
sharedAlbumId:
data.shared && data.activityEnabled ? data.remoteId : null,
),
),
),

View file

@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/activities/providers/activity.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
@ -16,6 +17,8 @@ class TopControlAppBar extends HookConsumerWidget {
required this.onFavorite,
required this.onUploadPressed,
required this.isOwner,
required this.shareAlbumId,
required this.onActivitiesPressed,
}) : super(key: key);
final Asset asset;
@ -24,14 +27,23 @@ class TopControlAppBar extends HookConsumerWidget {
final VoidCallback? onDownloadPressed;
final VoidCallback onToggleMotionVideo;
final VoidCallback onAddToAlbumPressed;
final VoidCallback onActivitiesPressed;
final Function(Asset) onFavorite;
final bool isPlayingMotionVideo;
final bool isOwner;
final String? shareAlbumId;
@override
Widget build(BuildContext context, WidgetRef ref) {
const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset;
final comments = shareAlbumId != null
? ref.watch(
activityStatisticsStateProvider(
(albumId: shareAlbumId!, assetId: asset.remoteId),
),
)
: 0;
Widget buildFavoriteButton(a) {
return IconButton(
@ -94,6 +106,34 @@ class TopControlAppBar extends HookConsumerWidget {
);
}
Widget buildActivitiesButton() {
return IconButton(
onPressed: () {
onActivitiesPressed();
},
icon: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.mode_comment_outlined,
color: Colors.grey[200],
),
if (comments != 0)
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text(
comments.toString(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey[200],
),
),
),
],
),
);
}
Widget buildUploadButton() {
return IconButton(
onPressed: onUploadPressed,
@ -130,6 +170,7 @@ class TopControlAppBar extends HookConsumerWidget {
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
if (asset.isRemote && isOwner) buildAddToAlbumButtom(),
if (shareAlbumId != null) buildActivitiesButton(),
buildMoreInfoButton(),
],
);

View file

@ -49,6 +49,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final int heroOffset;
final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
GalleryViewerPage({
super.key,
@ -58,6 +59,7 @@ class GalleryViewerPage extends HookConsumerWidget {
this.heroOffset = 0,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
}) : controller = PageController(initialPage: initialIndex);
final PageController controller;
@ -327,6 +329,19 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
handleActivities() {
if (sharedAlbumId != null) {
AutoRouter.of(context).push(
ActivitiesRoute(
albumId: sharedAlbumId!,
assetId: asset().remoteId,
withAssetThumbs: false,
isOwner: isOwner,
),
);
}
}
buildAppBar() {
return IgnorePointer(
ignoring: !ref.watch(showControlsProvider),
@ -355,6 +370,8 @@ class GalleryViewerPage extends HookConsumerWidget {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}),
onAddToAlbumPressed: () => addToAlbum(asset()),
shareAlbumId: sharedAlbumId,
onActivitiesPressed: handleActivities,
),
),
),

View file

@ -34,6 +34,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
final bool showDragScroll;
final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
const ImmichAssetGrid({
super.key,
@ -55,6 +56,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.showDragScroll = true,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
});
@override
@ -120,6 +122,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
showDragScroll: showDragScroll,
showStack: showStack,
isOwner: isOwner,
sharedAlbumId: sharedAlbumId,
),
);
}

View file

@ -39,6 +39,7 @@ class ImmichAssetGridView extends StatefulWidget {
final bool showDragScroll;
final bool showStack;
final bool isOwner;
final String? sharedAlbumId;
const ImmichAssetGridView({
super.key,
@ -60,6 +61,7 @@ class ImmichAssetGridView extends StatefulWidget {
this.showDragScroll = true,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
});
@override
@ -141,6 +143,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
heroOffset: widget.heroOffset,
showStack: widget.showStack,
isOwner: widget.isOwner,
sharedAlbumId: widget.sharedAlbumId,
);
}

View file

@ -21,6 +21,7 @@ class ThumbnailImage extends StatelessWidget {
final Function? onSelect;
final Function? onDeselect;
final int heroOffset;
final String? sharedAlbumId;
const ThumbnailImage({
Key? key,
@ -31,6 +32,7 @@ class ThumbnailImage extends StatelessWidget {
this.showStorageIndicator = true,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
this.useGrayBoxPlaceholder = false,
this.isSelected = false,
this.multiselectEnabled = false,
@ -184,6 +186,7 @@ class ThumbnailImage extends StatelessWidget {
heroOffset: heroOffset,
showStack: showStack,
isOwner: isOwner,
sharedAlbumId: sharedAlbumId,
),
);
}

View file

@ -172,7 +172,7 @@ class SearchPage extends HookConsumerWidget {
),
ListTile(
leading: Icon(
Icons.star_outline,
Icons.favorite_border_rounded,
color: categoryIconColor,
),
title:

View file

@ -37,6 +37,7 @@ class TrashNotifier extends StateNotifier<bool> {
.remoteIdProperty()
.findAll();
// TODO: handle local asset removal on emptyTrash
_ref
.read(syncServiceProvider)
.handleRemoteAssetRemoval(idsToRemove.cast<String>().toList());

View file

@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/activities/views/activities_page.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/views/album_options_part.dart';
import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
@ -160,6 +161,12 @@ part 'router.gr.dart';
AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]),
CustomRoute(
page: ActivitiesPage,
guards: [AuthGuard, DuplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
durationInMilliseconds: 200,
),
],
)
class AppRouter extends _$AppRouter {

View file

@ -73,6 +73,7 @@ class _$AppRouter extends RootStackRouter {
heroOffset: args.heroOffset,
showStack: args.showStack,
isOwner: args.isOwner,
sharedAlbumId: args.sharedAlbumId,
),
);
},
@ -337,6 +338,25 @@ class _$AppRouter extends RootStackRouter {
),
);
},
ActivitiesRoute.name: (routeData) {
final args = routeData.argsAs<ActivitiesRouteArgs>();
return CustomPage<dynamic>(
routeData: routeData,
child: ActivitiesPage(
args.albumId,
appBarTitle: args.appBarTitle,
assetId: args.assetId,
withAssetThumbs: args.withAssetThumbs,
isOwner: args.isOwner,
isReadOnly: args.isReadOnly,
key: args.key,
),
transitionsBuilder: TransitionsBuilders.slideLeft,
durationInMilliseconds: 200,
opaque: true,
barrierDismissible: false,
);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
@ -674,6 +694,14 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
ActivitiesRoute.name,
path: '/activities-page',
guards: [
authGuard,
duplicateGuard,
],
),
];
}
@ -749,6 +777,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
int heroOffset = 0,
bool showStack = false,
bool isOwner = true,
String? sharedAlbumId,
}) : super(
GalleryViewerRoute.name,
path: '/gallery-viewer-page',
@ -760,6 +789,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
heroOffset: heroOffset,
showStack: showStack,
isOwner: isOwner,
sharedAlbumId: sharedAlbumId,
),
);
@ -775,6 +805,7 @@ class GalleryViewerRouteArgs {
this.heroOffset = 0,
this.showStack = false,
this.isOwner = true,
this.sharedAlbumId,
});
final Key? key;
@ -791,9 +822,11 @@ class GalleryViewerRouteArgs {
final bool isOwner;
final String? sharedAlbumId;
@override
String toString() {
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner}';
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner, sharedAlbumId: $sharedAlbumId}';
}
}
@ -1527,6 +1560,65 @@ class SharedLinkEditRouteArgs {
}
}
/// generated route for
/// [ActivitiesPage]
class ActivitiesRoute extends PageRouteInfo<ActivitiesRouteArgs> {
ActivitiesRoute({
required String albumId,
String appBarTitle = "",
String? assetId,
bool withAssetThumbs = true,
bool isOwner = false,
bool isReadOnly = false,
Key? key,
}) : super(
ActivitiesRoute.name,
path: '/activities-page',
args: ActivitiesRouteArgs(
albumId: albumId,
appBarTitle: appBarTitle,
assetId: assetId,
withAssetThumbs: withAssetThumbs,
isOwner: isOwner,
isReadOnly: isReadOnly,
key: key,
),
);
static const String name = 'ActivitiesRoute';
}
class ActivitiesRouteArgs {
const ActivitiesRouteArgs({
required this.albumId,
this.appBarTitle = "",
this.assetId,
this.withAssetThumbs = true,
this.isOwner = false,
this.isReadOnly = false,
this.key,
});
final String albumId;
final String appBarTitle;
final String? assetId;
final bool withAssetThumbs;
final bool isOwner;
final bool isReadOnly;
final Key? key;
@override
String toString() {
return 'ActivitiesRouteArgs{albumId: $albumId, appBarTitle: $appBarTitle, assetId: $assetId, withAssetThumbs: $withAssetThumbs, isOwner: $isOwner, isReadOnly: $isReadOnly, key: $key}';
}
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View file

@ -22,6 +22,7 @@ class Album {
this.endDate,
this.lastModifiedAssetTimestamp,
required this.shared,
required this.activityEnabled,
});
Id id = Isar.autoIncrement;
@ -36,6 +37,7 @@ class Album {
DateTime? endDate;
DateTime? lastModifiedAssetTimestamp;
bool shared;
bool activityEnabled;
final IsarLink<User> owner = IsarLink<User>();
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
final IsarLinks<User> sharedUsers = IsarLinks<User>();
@ -77,7 +79,8 @@ class Album {
}
Stream<void> watchRenderList(GroupAssetsBy groupAssetsBy) async* {
final query = assets.filter().sortByFileCreatedAtDesc();
final query =
assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc();
_renderList = await RenderList.fromQuery(query, groupAssetsBy);
yield _renderList;
await for (final _ in query.watchLazy()) {
@ -105,6 +108,7 @@ class Album {
modifiedAt.isAtSameMomentAs(other.modifiedAt) &&
lastModifiedAssetTimestampIsSetAndEqual &&
shared == other.shared &&
activityEnabled == other.activityEnabled &&
owner.value == other.owner.value &&
thumbnail.value == other.thumbnail.value &&
sharedUsers.length == other.sharedUsers.length &&
@ -122,6 +126,7 @@ class Album {
modifiedAt.hashCode ^
lastModifiedAssetTimestamp.hashCode ^
shared.hashCode ^
activityEnabled.hashCode ^
owner.value.hashCode ^
thumbnail.value.hashCode ^
sharedUsers.length.hashCode ^
@ -133,6 +138,7 @@ class Album {
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
activityEnabled: false,
);
a.owner.value = Store.get(StoreKey.currentUser);
a.localId = ape.id;
@ -150,6 +156,7 @@ class Album {
shared: dto.shared,
startDate: dto.startDate,
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
);
a.owner.value = await db.users.getById(dto.ownerId);
if (dto.albumThumbnailAssetId != null) {

View file

@ -17,48 +17,53 @@ const AlbumSchema = CollectionSchema(
name: r'Album',
id: -1355968412107120937,
properties: {
r'createdAt': PropertySchema(
r'activityEnabled': PropertySchema(
id: 0,
name: r'activityEnabled',
type: IsarType.bool,
),
r'createdAt': PropertySchema(
id: 1,
name: r'createdAt',
type: IsarType.dateTime,
),
r'endDate': PropertySchema(
id: 1,
id: 2,
name: r'endDate',
type: IsarType.dateTime,
),
r'lastModifiedAssetTimestamp': PropertySchema(
id: 2,
id: 3,
name: r'lastModifiedAssetTimestamp',
type: IsarType.dateTime,
),
r'localId': PropertySchema(
id: 3,
id: 4,
name: r'localId',
type: IsarType.string,
),
r'modifiedAt': PropertySchema(
id: 4,
id: 5,
name: r'modifiedAt',
type: IsarType.dateTime,
),
r'name': PropertySchema(
id: 5,
id: 6,
name: r'name',
type: IsarType.string,
),
r'remoteId': PropertySchema(
id: 6,
id: 7,
name: r'remoteId',
type: IsarType.string,
),
r'shared': PropertySchema(
id: 7,
id: 8,
name: r'shared',
type: IsarType.bool,
),
r'startDate': PropertySchema(
id: 8,
id: 9,
name: r'startDate',
type: IsarType.dateTime,
)
@ -157,15 +162,16 @@ void _albumSerialize(
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeDateTime(offsets[0], object.createdAt);
writer.writeDateTime(offsets[1], object.endDate);
writer.writeDateTime(offsets[2], object.lastModifiedAssetTimestamp);
writer.writeString(offsets[3], object.localId);
writer.writeDateTime(offsets[4], object.modifiedAt);
writer.writeString(offsets[5], object.name);
writer.writeString(offsets[6], object.remoteId);
writer.writeBool(offsets[7], object.shared);
writer.writeDateTime(offsets[8], object.startDate);
writer.writeBool(offsets[0], object.activityEnabled);
writer.writeDateTime(offsets[1], object.createdAt);
writer.writeDateTime(offsets[2], object.endDate);
writer.writeDateTime(offsets[3], object.lastModifiedAssetTimestamp);
writer.writeString(offsets[4], object.localId);
writer.writeDateTime(offsets[5], object.modifiedAt);
writer.writeString(offsets[6], object.name);
writer.writeString(offsets[7], object.remoteId);
writer.writeBool(offsets[8], object.shared);
writer.writeDateTime(offsets[9], object.startDate);
}
Album _albumDeserialize(
@ -175,15 +181,16 @@ Album _albumDeserialize(
Map<Type, List<int>> allOffsets,
) {
final object = Album(
createdAt: reader.readDateTime(offsets[0]),
endDate: reader.readDateTimeOrNull(offsets[1]),
lastModifiedAssetTimestamp: reader.readDateTimeOrNull(offsets[2]),
localId: reader.readStringOrNull(offsets[3]),
modifiedAt: reader.readDateTime(offsets[4]),
name: reader.readString(offsets[5]),
remoteId: reader.readStringOrNull(offsets[6]),
shared: reader.readBool(offsets[7]),
startDate: reader.readDateTimeOrNull(offsets[8]),
activityEnabled: reader.readBool(offsets[0]),
createdAt: reader.readDateTime(offsets[1]),
endDate: reader.readDateTimeOrNull(offsets[2]),
lastModifiedAssetTimestamp: reader.readDateTimeOrNull(offsets[3]),
localId: reader.readStringOrNull(offsets[4]),
modifiedAt: reader.readDateTime(offsets[5]),
name: reader.readString(offsets[6]),
remoteId: reader.readStringOrNull(offsets[7]),
shared: reader.readBool(offsets[8]),
startDate: reader.readDateTimeOrNull(offsets[9]),
);
object.id = id;
return object;
@ -197,22 +204,24 @@ P _albumDeserializeProp<P>(
) {
switch (propertyId) {
case 0:
return (reader.readDateTime(offset)) as P;
return (reader.readBool(offset)) as P;
case 1:
return (reader.readDateTimeOrNull(offset)) as P;
return (reader.readDateTime(offset)) as P;
case 2:
return (reader.readDateTimeOrNull(offset)) as P;
case 3:
return (reader.readStringOrNull(offset)) as P;
return (reader.readDateTimeOrNull(offset)) as P;
case 4:
return (reader.readDateTime(offset)) as P;
case 5:
return (reader.readString(offset)) as P;
case 6:
return (reader.readStringOrNull(offset)) as P;
case 5:
return (reader.readDateTime(offset)) as P;
case 6:
return (reader.readString(offset)) as P;
case 7:
return (reader.readBool(offset)) as P;
return (reader.readStringOrNull(offset)) as P;
case 8:
return (reader.readBool(offset)) as P;
case 9:
return (reader.readDateTimeOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@ -442,6 +451,16 @@ extension AlbumQueryWhere on QueryBuilder<Album, Album, QWhereClause> {
}
extension AlbumQueryFilter on QueryBuilder<Album, Album, QFilterCondition> {
QueryBuilder<Album, Album, QAfterFilterCondition> activityEnabledEqualTo(
bool value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'activityEnabled',
value: value,
));
});
}
QueryBuilder<Album, Album, QAfterFilterCondition> createdAtEqualTo(
DateTime value) {
return QueryBuilder.apply(this, (query) {
@ -1385,6 +1404,18 @@ extension AlbumQueryLinks on QueryBuilder<Album, Album, QFilterCondition> {
}
extension AlbumQuerySortBy on QueryBuilder<Album, Album, QSortBy> {
QueryBuilder<Album, Album, QAfterSortBy> sortByActivityEnabled() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'activityEnabled', Sort.asc);
});
}
QueryBuilder<Album, Album, QAfterSortBy> sortByActivityEnabledDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'activityEnabled', Sort.desc);
});
}
QueryBuilder<Album, Album, QAfterSortBy> sortByCreatedAt() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'createdAt', Sort.asc);
@ -1496,6 +1527,18 @@ extension AlbumQuerySortBy on QueryBuilder<Album, Album, QSortBy> {
}
extension AlbumQuerySortThenBy on QueryBuilder<Album, Album, QSortThenBy> {
QueryBuilder<Album, Album, QAfterSortBy> thenByActivityEnabled() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'activityEnabled', Sort.asc);
});
}
QueryBuilder<Album, Album, QAfterSortBy> thenByActivityEnabledDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'activityEnabled', Sort.desc);
});
}
QueryBuilder<Album, Album, QAfterSortBy> thenByCreatedAt() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'createdAt', Sort.asc);
@ -1619,6 +1662,12 @@ extension AlbumQuerySortThenBy on QueryBuilder<Album, Album, QSortThenBy> {
}
extension AlbumQueryWhereDistinct on QueryBuilder<Album, Album, QDistinct> {
QueryBuilder<Album, Album, QDistinct> distinctByActivityEnabled() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'activityEnabled');
});
}
QueryBuilder<Album, Album, QDistinct> distinctByCreatedAt() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'createdAt');
@ -1684,6 +1733,12 @@ extension AlbumQueryProperty on QueryBuilder<Album, Album, QQueryProperty> {
});
}
QueryBuilder<Album, bool, QQueryOperations> activityEnabledProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'activityEnabled');
});
}
QueryBuilder<Album, DateTime, QQueryOperations> createdAtProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'createdAt');

View file

@ -22,6 +22,7 @@ class ApiService {
late PersonApi personApi;
late AuditApi auditApi;
late SharedLinkApi sharedLinkApi;
late ActivityApi activityApi;
ApiService() {
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@ -47,6 +48,7 @@ class ApiService {
personApi = PersonApi(_apiClient);
auditApi = AuditApi(_apiClient);
sharedLinkApi = SharedLinkApi(_apiClient);
activityApi = ActivityApi(_apiClient);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {

View file

@ -62,20 +62,31 @@ class AssetService {
/// Returns `null` if the server state did not change, else list of assets
Future<List<Asset>?> _getRemoteAssets(User user) async {
const int chunkSize = 5000;
try {
final List<AssetResponseDto>? assets =
await _apiService.assetApi.getAllAssets(
userId: user.id,
);
if (assets == null) {
return null;
} else if (assets.isNotEmpty && assets.first.ownerId != user.id) {
log.warning("Make sure that server and app versions match!"
" The server returned assets for user ${assets.first.ownerId}"
" while requesting assets of user ${user.id}");
return null;
final DateTime now = DateTime.now().toUtc();
final List<Asset> allAssets = [];
for (int i = 0;; i += chunkSize) {
final List<AssetResponseDto>? assets =
await _apiService.assetApi.getAllAssets(
userId: user.id,
// updatedBefore is important! without it we could
// a) get the same Asset multiple times in different versions (when
// the asset is modified while the chunks are loaded from the server)
// b) miss assets when new assets are inserted in between the calls
updatedBefore: now,
skip: i,
take: chunkSize,
);
if (assets == null) {
return null;
}
allAssets.addAll(assets.map(Asset.remote));
if (assets.length < chunkSize) {
break;
}
}
return assets.map(Asset.remote).toList();
return allAssets;
} catch (error, stack) {
log.severe(
'Error while getting remote assets: ${error.toString()}',

View file

@ -197,7 +197,7 @@ class SyncService {
User user,
FutureOr<List<Asset>?> Function(User user) loadAssets,
) async {
final DateTime now = DateTime.now();
final DateTime now = DateTime.now().toUtc();
final List<Asset>? remote = await loadAssets(user);
if (remote == null) {
return false;
@ -210,6 +210,10 @@ class SyncService {
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
remote.sort(Asset.compareByChecksum);
// filter our duplicates that might be introduced by the chunked retrieval
remote.uniqueConsecutive(compare: Asset.compareByChecksum);
final (toAdd, toUpdate, toRemove) = _diffAssets(remote, inDb, remote: true);
if (toAdd.isEmpty && toUpdate.isEmpty && toRemove.isEmpty) {
await _updateUserAssetsETag(user, now);
@ -759,6 +763,12 @@ class SyncService {
final List<Asset> toAdd = [];
final List<Asset> toUpdate = [];
final List<Asset> toRemove = [];
if (assets.isEmpty || inDb.isEmpty) {
// fast path for trivial cases: halfes memory usage during initial sync
return assets.isEmpty
? (toAdd, toUpdate, inDb) // remove all from DB
: (assets, toUpdate, toRemove); // add all assets
}
diffSortedListsSync(
inDb,
assets,

View file

@ -25,6 +25,16 @@ class UserCircleAvatar extends ConsumerWidget {
bool isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}';
final textIcon = Text(
user.firstName[0].toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).brightness == Brightness.dark
? Colors.black
: Colors.white,
),
);
return CircleAvatar(
backgroundColor: user.avatarColor.toColor(isDarkTheme),
radius: radius,
@ -52,8 +62,7 @@ class UserCircleAvatar extends ConsumerWidget {
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
},
fadeInDuration: const Duration(milliseconds: 300),
errorWidget: (context, error, stackTrace) =>
Image.memory(kTransparentImage),
errorWidget: (context, error, stackTrace) => textIcon,
),
),
);

View file

@ -0,0 +1,36 @@
extension TimeAgoExtension on DateTime {
String timeAgo({bool numericDates = true}) {
DateTime date = toLocal();
final date2 = DateTime.now().toLocal();
final difference = date2.difference(date);
if (difference.inSeconds < 5) {
return 'Just now';
} else if (difference.inSeconds < 60) {
return '${difference.inSeconds} seconds ago';
} else if (difference.inMinutes <= 1) {
return (numericDates) ? '1 minute ago' : 'A minute ago';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes} minutes ago';
} else if (difference.inHours <= 1) {
return (numericDates) ? '1 hour ago' : 'An hour ago';
} else if (difference.inHours < 60) {
return '${difference.inHours} hours ago';
} else if (difference.inDays <= 1) {
return (numericDates) ? '1 day ago' : 'Yesterday';
} else if (difference.inDays < 6) {
return '${difference.inDays} days ago';
} else if ((difference.inDays / 7).ceil() <= 1) {
return (numericDates) ? '1 week ago' : 'Last week';
} else if ((difference.inDays / 7).ceil() < 4) {
return '${(difference.inDays / 7).ceil()} weeks ago';
} else if ((difference.inDays / 30).ceil() <= 1) {
return (numericDates) ? '1 month ago' : 'Last month';
} else if ((difference.inDays / 30).ceil() < 30) {
return '${(difference.inDays / 30).ceil()} months ago';
} else if ((difference.inDays / 365).ceil() <= 1) {
return (numericDates) ? '1 year ago' : 'Last year';
}
return '${(difference.inDays / 365).floor()} years ago';
}
}

View file

@ -17,6 +17,7 @@ Name | Type | Description | Notes
**endDate** | [**DateTime**](DateTime.md) | | [optional]
**hasSharedLink** | **bool** | |
**id** | **String** | |
**isActivityEnabled** | **bool** | |
**lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) | | [optional]
**owner** | [**UserResponseDto**](UserResponseDto.md) | |
**ownerId** | **String** | |

View file

@ -374,7 +374,7 @@ void (empty response body)
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAllAssets**
> List<AssetResponseDto> getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch)
> List<AssetResponseDto> getAllAssets(skip, take, userId, isFavorite, isArchived, updatedAfter, updatedBefore, ifNoneMatch)
@ -399,15 +399,17 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final skip = 56; // int |
final take = 56; // int |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final isFavorite = true; // bool |
final isArchived = true; // bool |
final skip = 8.14; // num |
final updatedAfter = 2013-10-20T19:20:30+01:00; // DateTime |
final updatedBefore = 2013-10-20T19:20:30+01:00; // DateTime |
final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
try {
final result = api_instance.getAllAssets(userId, isFavorite, isArchived, skip, updatedAfter, ifNoneMatch);
final result = api_instance.getAllAssets(skip, take, userId, isFavorite, isArchived, updatedAfter, updatedBefore, ifNoneMatch);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getAllAssets: $e\n');
@ -418,11 +420,13 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**skip** | **int**| | [optional]
**take** | **int**| | [optional]
**userId** | **String**| | [optional]
**isFavorite** | **bool**| | [optional]
**isArchived** | **bool**| | [optional]
**skip** | **num**| | [optional]
**updatedAfter** | **DateTime**| | [optional]
**updatedBefore** | **DateTime**| | [optional]
**ifNoneMatch** | **String**| ETag of data already cached on the client | [optional]
### Return type
@ -1696,7 +1700,7 @@ void (empty response body)
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **uploadFile**
> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)
> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isExternal, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData)
@ -1724,11 +1728,11 @@ final deviceAssetId = deviceAssetId_example; // String |
final deviceId = deviceId_example; // String |
final fileCreatedAt = 2013-10-20T19:20:30+01:00; // DateTime |
final fileModifiedAt = 2013-10-20T19:20:30+01:00; // DateTime |
final isFavorite = true; // bool |
final key = key_example; // String |
final duration = duration_example; // String |
final isArchived = true; // bool |
final isExternal = true; // bool |
final isFavorite = true; // bool |
final isOffline = true; // bool |
final isReadOnly = true; // bool |
final isVisible = true; // bool |
@ -1737,7 +1741,7 @@ final livePhotoData = BINARY_DATA_HERE; // MultipartFile |
final sidecarData = BINARY_DATA_HERE; // MultipartFile |
try {
final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData);
final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, duration, isArchived, isExternal, isFavorite, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData);
print(result);
} catch (e) {
print('Exception when calling AssetApi->uploadFile: $e\n');
@ -1753,11 +1757,11 @@ Name | Type | Description | Notes
**deviceId** | **String**| |
**fileCreatedAt** | **DateTime**| |
**fileModifiedAt** | **DateTime**| |
**isFavorite** | **bool**| |
**key** | **String**| | [optional]
**duration** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isExternal** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isOffline** | **bool**| | [optional]
**isReadOnly** | **bool**| | [optional]
**isVisible** | **bool**| | [optional]

View file

@ -16,7 +16,7 @@ Name | Type | Description | Notes
**fileModifiedAt** | [**DateTime**](DateTime.md) | |
**isArchived** | **bool** | | [optional]
**isExternal** | **bool** | | [optional]
**isFavorite** | **bool** | |
**isFavorite** | **bool** | | [optional]
**isOffline** | **bool** | | [optional]
**isReadOnly** | **bool** | | [optional] [default to true]
**isVisible** | **bool** | | [optional]

View file

@ -11,6 +11,7 @@ Name | Type | Description | Notes
**albumName** | **String** | | [optional]
**albumThumbnailAssetId** | **String** | | [optional]
**description** | **String** | | [optional]
**isActivityEnabled** | **bool** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -309,19 +309,23 @@ class AssetApi {
///
/// Parameters:
///
/// * [int] skip:
///
/// * [int] take:
///
/// * [String] userId:
///
/// * [bool] isFavorite:
///
/// * [bool] isArchived:
///
/// * [num] skip:
///
/// * [DateTime] updatedAfter:
///
/// * [DateTime] updatedBefore:
///
/// * [String] ifNoneMatch:
/// ETag of data already cached on the client
Future<Response> getAllAssetsWithHttpInfo({ String? userId, bool? isFavorite, bool? isArchived, num? skip, DateTime? updatedAfter, String? ifNoneMatch, }) async {
Future<Response> getAllAssetsWithHttpInfo({ int? skip, int? take, String? userId, bool? isFavorite, bool? isArchived, DateTime? updatedAfter, DateTime? updatedBefore, String? ifNoneMatch, }) async {
// ignore: prefer_const_declarations
final path = r'/asset';
@ -332,6 +336,12 @@ class AssetApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (skip != null) {
queryParams.addAll(_queryParams('', 'skip', skip));
}
if (take != null) {
queryParams.addAll(_queryParams('', 'take', take));
}
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
@ -341,12 +351,12 @@ class AssetApi {
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (skip != null) {
queryParams.addAll(_queryParams('', 'skip', skip));
}
if (updatedAfter != null) {
queryParams.addAll(_queryParams('', 'updatedAfter', updatedAfter));
}
if (updatedBefore != null) {
queryParams.addAll(_queryParams('', 'updatedBefore', updatedBefore));
}
if (ifNoneMatch != null) {
headerParams[r'if-none-match'] = parameterToString(ifNoneMatch);
@ -370,20 +380,24 @@ class AssetApi {
///
/// Parameters:
///
/// * [int] skip:
///
/// * [int] take:
///
/// * [String] userId:
///
/// * [bool] isFavorite:
///
/// * [bool] isArchived:
///
/// * [num] skip:
///
/// * [DateTime] updatedAfter:
///
/// * [DateTime] updatedBefore:
///
/// * [String] ifNoneMatch:
/// ETag of data already cached on the client
Future<List<AssetResponseDto>?> getAllAssets({ String? userId, bool? isFavorite, bool? isArchived, num? skip, DateTime? updatedAfter, String? ifNoneMatch, }) async {
final response = await getAllAssetsWithHttpInfo( userId: userId, isFavorite: isFavorite, isArchived: isArchived, skip: skip, updatedAfter: updatedAfter, ifNoneMatch: ifNoneMatch, );
Future<List<AssetResponseDto>?> getAllAssets({ int? skip, int? take, String? userId, bool? isFavorite, bool? isArchived, DateTime? updatedAfter, DateTime? updatedBefore, String? ifNoneMatch, }) async {
final response = await getAllAssetsWithHttpInfo( skip: skip, take: take, userId: userId, isFavorite: isFavorite, isArchived: isArchived, updatedAfter: updatedAfter, updatedBefore: updatedBefore, ifNoneMatch: ifNoneMatch, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@ -1660,8 +1674,6 @@ class AssetApi {
///
/// * [DateTime] fileModifiedAt (required):
///
/// * [bool] isFavorite (required):
///
/// * [String] key:
///
/// * [String] duration:
@ -1670,6 +1682,8 @@ class AssetApi {
///
/// * [bool] isExternal:
///
/// * [bool] isFavorite:
///
/// * [bool] isOffline:
///
/// * [bool] isReadOnly:
@ -1681,7 +1695,7 @@ class AssetApi {
/// * [MultipartFile] livePhotoData:
///
/// * [MultipartFile] sidecarData:
Future<Response> uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
Future<Response> uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isFavorite, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/upload';
@ -1790,8 +1804,6 @@ class AssetApi {
///
/// * [DateTime] fileModifiedAt (required):
///
/// * [bool] isFavorite (required):
///
/// * [String] key:
///
/// * [String] duration:
@ -1800,6 +1812,8 @@ class AssetApi {
///
/// * [bool] isExternal:
///
/// * [bool] isFavorite:
///
/// * [bool] isOffline:
///
/// * [bool] isReadOnly:
@ -1811,8 +1825,8 @@ class AssetApi {
/// * [MultipartFile] livePhotoData:
///
/// * [MultipartFile] sidecarData:
Future<AssetFileUploadResponseDto?> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key: key, duration: duration, isArchived: isArchived, isExternal: isExternal, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, libraryId: libraryId, livePhotoData: livePhotoData, sidecarData: sidecarData, );
Future<AssetFileUploadResponseDto?> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isFavorite, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async {
final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, duration: duration, isArchived: isArchived, isExternal: isExternal, isFavorite: isFavorite, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, libraryId: libraryId, livePhotoData: livePhotoData, sidecarData: sidecarData, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View file

@ -22,6 +22,7 @@ class AlbumResponseDto {
this.endDate,
required this.hasSharedLink,
required this.id,
required this.isActivityEnabled,
this.lastModifiedAssetTimestamp,
required this.owner,
required this.ownerId,
@ -55,6 +56,8 @@ class AlbumResponseDto {
String id;
bool isActivityEnabled;
///
/// 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
@ -92,6 +95,7 @@ class AlbumResponseDto {
other.endDate == endDate &&
other.hasSharedLink == hasSharedLink &&
other.id == id &&
other.isActivityEnabled == isActivityEnabled &&
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
other.owner == owner &&
other.ownerId == ownerId &&
@ -112,6 +116,7 @@ class AlbumResponseDto {
(endDate == null ? 0 : endDate!.hashCode) +
(hasSharedLink.hashCode) +
(id.hashCode) +
(isActivityEnabled.hashCode) +
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
(owner.hashCode) +
(ownerId.hashCode) +
@ -121,7 +126,7 @@ class AlbumResponseDto {
(updatedAt.hashCode);
@override
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]';
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -142,6 +147,7 @@ class AlbumResponseDto {
}
json[r'hasSharedLink'] = this.hasSharedLink;
json[r'id'] = this.id;
json[r'isActivityEnabled'] = this.isActivityEnabled;
if (this.lastModifiedAssetTimestamp != null) {
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
} else {
@ -177,6 +183,7 @@ class AlbumResponseDto {
endDate: mapDateTime(json, r'endDate', ''),
hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!,
id: mapValueOfType<String>(json, r'id')!,
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''),
owner: UserResponseDto.fromJson(json[r'owner'])!,
ownerId: mapValueOfType<String>(json, r'ownerId')!,
@ -239,6 +246,7 @@ class AlbumResponseDto {
'description',
'hasSharedLink',
'id',
'isActivityEnabled',
'owner',
'ownerId',
'shared',

View file

@ -21,7 +21,7 @@ class ImportAssetDto {
required this.fileModifiedAt,
this.isArchived,
this.isExternal,
required this.isFavorite,
this.isFavorite,
this.isOffline,
this.isReadOnly = true,
this.isVisible,
@ -63,7 +63,13 @@ class ImportAssetDto {
///
bool? isExternal;
bool isFavorite;
///
/// 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.
///
bool? isFavorite;
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -127,7 +133,7 @@ class ImportAssetDto {
(fileModifiedAt.hashCode) +
(isArchived == null ? 0 : isArchived!.hashCode) +
(isExternal == null ? 0 : isExternal!.hashCode) +
(isFavorite.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isOffline == null ? 0 : isOffline!.hashCode) +
(isReadOnly.hashCode) +
(isVisible == null ? 0 : isVisible!.hashCode) +
@ -159,7 +165,11 @@ class ImportAssetDto {
} else {
// json[r'isExternal'] = null;
}
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.isOffline != null) {
json[r'isOffline'] = this.isOffline;
} else {
@ -200,7 +210,7 @@ class ImportAssetDto {
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!,
isArchived: mapValueOfType<bool>(json, r'isArchived'),
isExternal: mapValueOfType<bool>(json, r'isExternal'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isOffline: mapValueOfType<bool>(json, r'isOffline'),
isReadOnly: mapValueOfType<bool>(json, r'isReadOnly') ?? true,
isVisible: mapValueOfType<bool>(json, r'isVisible'),
@ -258,7 +268,6 @@ class ImportAssetDto {
'deviceId',
'fileCreatedAt',
'fileModifiedAt',
'isFavorite',
};
}

View file

@ -16,6 +16,7 @@ class UpdateAlbumDto {
this.albumName,
this.albumThumbnailAssetId,
this.description,
this.isActivityEnabled,
});
///
@ -42,21 +43,31 @@ class UpdateAlbumDto {
///
String? description;
///
/// 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.
///
bool? isActivityEnabled;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumDto &&
other.albumName == albumName &&
other.albumThumbnailAssetId == albumThumbnailAssetId &&
other.description == description;
other.description == description &&
other.isActivityEnabled == isActivityEnabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumName == null ? 0 : albumName!.hashCode) +
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
(description == null ? 0 : description!.hashCode);
(description == null ? 0 : description!.hashCode) +
(isActivityEnabled == null ? 0 : isActivityEnabled!.hashCode);
@override
String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description]';
String toString() => 'UpdateAlbumDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, description=$description, isActivityEnabled=$isActivityEnabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -75,6 +86,11 @@ class UpdateAlbumDto {
} else {
// json[r'description'] = null;
}
if (this.isActivityEnabled != null) {
json[r'isActivityEnabled'] = this.isActivityEnabled;
} else {
// json[r'isActivityEnabled'] = null;
}
return json;
}
@ -89,6 +105,7 @@ class UpdateAlbumDto {
albumName: mapValueOfType<String>(json, r'albumName'),
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
description: mapValueOfType<String>(json, r'description'),
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled'),
);
}
return null;

View file

@ -61,6 +61,11 @@ void main() {
// TODO
});
// bool isActivityEnabled
test('to test the property `isActivityEnabled`', () async {
// TODO
});
// DateTime lastModifiedAssetTimestamp
test('to test the property `lastModifiedAssetTimestamp`', () async {
// TODO

View file

@ -53,7 +53,7 @@ void main() {
// Get all AssetEntity belong to the user
//
//Future<List<AssetResponseDto>> getAllAssets({ String userId, bool isFavorite, bool isArchived, num skip, DateTime updatedAfter, String ifNoneMatch }) async
//Future<List<AssetResponseDto>> getAllAssets({ int skip, int take, String userId, bool isFavorite, bool isArchived, DateTime updatedAfter, DateTime updatedBefore, String ifNoneMatch }) async
test('test getAllAssets', () async {
// TODO
});
@ -172,7 +172,7 @@ void main() {
// TODO
});
//Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isExternal, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async
//Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String key, String duration, bool isArchived, bool isExternal, bool isFavorite, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async
test('test uploadFile', () async {
// TODO
});

View file

@ -31,6 +31,11 @@ void main() {
// TODO
});
// bool isActivityEnabled
test('to test the property `isActivityEnabled`', () async {
// TODO
});
});

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.84.0+108
version: 1.85.0+109
isar_version: &isar_version 3.1.0+1
environment:

View file

@ -914,6 +914,22 @@
"description": "Get all AssetEntity belong to the user",
"operationId": "getAllAssets",
"parameters": [
{
"name": "skip",
"required": false,
"in": "query",
"schema": {
"type": "integer"
}
},
{
"name": "take",
"required": false,
"in": "query",
"schema": {
"type": "integer"
}
},
{
"name": "userId",
"required": false,
@ -940,15 +956,16 @@
}
},
{
"name": "skip",
"name": "updatedAfter",
"required": false,
"in": "query",
"schema": {
"type": "number"
"format": "date-time",
"type": "string"
}
},
{
"name": "updatedAfter",
"name": "updatedBefore",
"required": false,
"in": "query",
"schema": {
@ -5644,7 +5661,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.84.0",
"version": "1.85.0",
"contact": {}
},
"tags": [],
@ -5900,6 +5917,9 @@
"id": {
"type": "string"
},
"isActivityEnabled": {
"type": "boolean"
},
"lastModifiedAssetTimestamp": {
"format": "date-time",
"type": "string"
@ -5941,7 +5961,8 @@
"sharedUsers",
"hasSharedLink",
"assets",
"owner"
"owner",
"isActivityEnabled"
],
"type": "object"
},
@ -6678,8 +6699,7 @@
"deviceAssetId",
"deviceId",
"fileCreatedAt",
"fileModifiedAt",
"isFavorite"
"fileModifiedAt"
],
"type": "object"
},
@ -7166,8 +7186,7 @@
"deviceAssetId",
"deviceId",
"fileCreatedAt",
"fileModifiedAt",
"isFavorite"
"fileModifiedAt"
],
"type": "object"
},
@ -8918,6 +8937,9 @@
},
"description": {
"type": "string"
},
"isActivityEnabled": {
"type": "boolean"
}
},
"type": "object"

View file

@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.84.0",
"version": "1.85.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.84.0",
"version": "1.85.0",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.22.11",

View file

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.84.0",
"version": "1.85.0",
"description": "",
"author": "",
"private": true,

View file

@ -138,10 +138,7 @@ export class AccessCore {
switch (permission) {
// uses album id
case Permission.ACTIVITY_CREATE:
return (
(await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
);
return await this.repository.activity.hasCreateAccess(authUser.id, id);
// uses activity id
case Permission.ACTIVITY_DELETE:

View file

@ -58,6 +58,7 @@ export class ActivityService {
delete dto.comment;
[activity] = await this.repository.search({
...common,
isGlobal: !dto.assetId,
isLiked: true,
});
duplicate = !!activity;

View file

@ -94,7 +94,7 @@ describe(ActivityService.name, () => {
});
it('should create a comment', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
activityMock.create.mockResolvedValue(activityStub.oneComment);
await sut.create(authStub.admin, {
@ -113,8 +113,23 @@ describe(ActivityService.name, () => {
});
});
it('should create a like', async () => {
it('should fail because activity is disabled for the album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.activity.hasCreateAccess.mockResolvedValue(false);
activityMock.create.mockResolvedValue(activityStub.oneComment);
await expect(
sut.create(authStub.admin, {
albumId: 'album-id',
assetId: 'asset-id',
type: ReactionType.COMMENT,
comment: 'comment',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create a like', async () => {
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
activityMock.create.mockResolvedValue(activityStub.liked);
activityMock.search.mockResolvedValue([]);
@ -134,6 +149,7 @@ describe(ActivityService.name, () => {
it('should skip if like exists', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
activityMock.search.mockResolvedValue([activityStub.liked]);
await sut.create(authStub.admin, {

View file

@ -21,6 +21,7 @@ export class AlbumResponseDto {
lastModifiedAssetTimestamp?: Date;
startDate?: Date;
endDate?: Date;
isActivityEnabled!: boolean;
}
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumResponseDto => {
@ -61,6 +62,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean): AlbumRespons
endDate,
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset)),
assetCount: entity.assets?.length || 0,
isActivityEnabled: entity.isActivityEnabled,
};
};

View file

@ -125,12 +125,12 @@ export class AlbumService {
throw new BadRequestException('Invalid album thumbnail');
}
}
const updatedAlbum = await this.albumRepository.update({
id: album.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
});
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } });

View file

@ -1,4 +1,4 @@
import { IsString } from 'class-validator';
import { IsBoolean, IsString } from 'class-validator';
import { Optional, ValidateUUID } from '../../domain.util';
export class UpdateAlbumDto {
@ -12,4 +12,8 @@ export class UpdateAlbumDto {
@ValidateUUID({ optional: true })
albumThumbnailAssetId?: string;
@Optional()
@IsBoolean()
isActivityEnabled?: boolean;
}

View file

@ -216,7 +216,13 @@ export class PersonService {
return true;
}
const [asset] = await this.assetRepository.getByIds([id]);
const relations = {
exifInfo: true,
faces: {
person: true,
},
};
const [asset] = await this.assetRepository.getByIds([id], relations);
if (!asset || !asset.resizePath || asset.faces?.length > 0) {
return false;
}

View file

@ -2,8 +2,9 @@ export const IAccessRepository = 'IAccessRepository';
export interface IAccessRepository {
activity: {
hasOwnerAccess(userId: string, albumId: string): Promise<boolean>;
hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean>;
hasOwnerAccess(userId: string, activityId: string): Promise<boolean>;
hasAlbumOwnerAccess(userId: string, activityId: string): Promise<boolean>;
hasCreateAccess(userId: string, albumId: string): Promise<boolean>;
};
asset: {
hasOwnerAccess(userId: string, assetId: string): Promise<boolean>;

View file

@ -1,4 +1,5 @@
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { FindOptionsRelations } from 'typeorm';
import { Paginated, PaginationOptions } from '../domain.util';
export type AssetStats = Record<AssetType, number>;
@ -99,7 +100,7 @@ export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
create(asset: AssetCreate): Promise<AssetEntity>;
getByDate(ownerId: string, date: Date): Promise<AssetEntity[]>;
getByIds(ids: string[]): Promise<AssetEntity[]>;
getByIds(ids: string[], relations?: FindOptionsRelations<AssetEntity>): Promise<AssetEntity[]>;
getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise<AssetEntity[]>;
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;

View file

@ -1,8 +1,8 @@
import { AssetCreate } from '@app/domain';
import { AssetEntity } from '@app/infra/entities';
import OptionalBetween from '@app/infra/utils/optional-between.util';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan } from 'typeorm';
import { In } from 'typeorm/find-options/operator/In';
import { Repository } from 'typeorm/repository/Repository';
import { AssetSearchDto } from './dto/asset-search.dto';
@ -129,7 +129,7 @@ export class AssetRepository implements IAssetRepository {
isVisible: true,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived,
updatedAt: dto.updatedAfter ? MoreThan(dto.updatedAfter) : undefined,
updatedAt: OptionalBetween(dto.updatedAfter, dto.updatedBefore),
},
relations: {
exifInfo: true,
@ -137,6 +137,7 @@ export class AssetRepository implements IAssetRepository {
stack: true,
},
skip: dto.skip || 0,
take: dto.take,
order: {
fileCreatedAt: 'DESC',
},

View file

@ -1,7 +1,7 @@
import { Optional, toBoolean } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsDate, IsNotEmpty, IsNumber, IsUUID } from 'class-validator';
import { IsBoolean, IsDate, IsInt, IsNotEmpty, IsUUID } from 'class-validator';
export class AssetSearchDto {
@Optional()
@ -17,9 +17,17 @@ export class AssetSearchDto {
isArchived?: boolean;
@Optional()
@IsNumber()
@IsInt()
@Type(() => Number)
@ApiProperty({ type: 'integer' })
skip?: number;
@Optional()
@IsInt()
@Type(() => Number)
@ApiProperty({ type: 'integer' })
take?: number;
@Optional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
@ -29,4 +37,9 @@ export class AssetSearchDto {
@IsDate()
@Type(() => Date)
updatedAfter?: Date;
@Optional()
@IsDate()
@Type(() => Date)
updatedBefore?: Date;
}

View file

@ -22,9 +22,10 @@ export class CreateAssetBase {
@Type(() => Date)
fileModifiedAt!: Date;
@Optional()
@IsBoolean()
@Transform(toBoolean)
isFavorite!: boolean;
isFavorite?: boolean;
@Optional()
@IsBoolean()

View file

@ -56,4 +56,7 @@ export class AlbumEntity {
@OneToMany(() => SharedLinkEntity, (link) => link.album)
sharedLinks!: SharedLinkEntity[];
@Column({ default: true })
isActivityEnabled!: boolean;
}

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class DisableActivity1699268680508 implements MigrationInterface {
name = 'DisableActivity1699268680508'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" ADD "isActivityEnabled" boolean NOT NULL DEFAULT true`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "isActivityEnabled"`);
}
}

View file

@ -43,6 +43,24 @@ export class AccessRepository implements IAccessRepository {
},
});
},
hasCreateAccess: (userId: string, albumId: string): Promise<boolean> => {
return this.albumRepository.exist({
where: [
{
id: albumId,
isActivityEnabled: true,
sharedUsers: {
id: userId,
},
},
{
id: albumId,
isActivityEnabled: true,
ownerId: userId,
},
],
});
},
};
library = {
hasOwnerAccess: (userId: string, libraryId: string): Promise<boolean> => {

View file

@ -1,7 +1,7 @@
import { IActivityRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IsNull, Repository } from 'typeorm';
import { ActivityEntity } from '../entities/activity.entity';
export interface ActivitySearch {
@ -9,6 +9,7 @@ export interface ActivitySearch {
assetId?: string;
userId?: string;
isLiked?: boolean;
isGlobal?: boolean;
}
@Injectable()
@ -16,11 +17,11 @@ export class ActivityRepository implements IActivityRepository {
constructor(@InjectRepository(ActivityEntity) private repository: Repository<ActivityEntity>) {}
search(options: ActivitySearch): Promise<ActivityEntity[]> {
const { userId, assetId, albumId, isLiked } = options;
const { userId, assetId, albumId, isLiked, isGlobal } = options;
return this.repository.find({
where: {
userId,
assetId,
assetId: isGlobal ? IsNull() : assetId,
albumId,
isLiked,
},

View file

@ -104,10 +104,9 @@ export class AssetRepository implements IAssetRepository {
.getMany();
}
getByIds(ids: string[]): Promise<AssetEntity[]> {
return this.repository.find({
where: { id: In(ids) },
relations: {
getByIds(ids: string[], relations?: FindOptionsRelations<AssetEntity>): Promise<AssetEntity[]> {
if (!relations) {
relations = {
exifInfo: true,
smartInfo: true,
tags: true,
@ -115,7 +114,11 @@ export class AssetRepository implements IAssetRepository {
person: true,
},
stack: true,
},
};
}
return this.repository.find({
where: { id: In(ids) },
relations,
withDeleted: true,
});
}

View file

@ -226,6 +226,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
assets: [],
assetCount: 0,
owner: expect.objectContaining({ email: user1.userEmail }),
isActivityEnabled: true,
});
});
});

View file

@ -146,7 +146,6 @@ describe(`${AssetController.name} (e2e)`, () => {
{ should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } },
{ should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } },
{ should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } },
{ should: 'require `isFavorite`', dto: { ...makeUploadDto({ omit: 'isFavorite' }) } },
{ should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } },
{ should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } },
{ should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } },

View file

@ -18,6 +18,7 @@ export const albumStub = {
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
isActivityEnabled: true,
}),
sharedWithUser: Object.freeze<AlbumEntity>({
id: 'album-2',
@ -33,6 +34,7 @@ export const albumStub = {
deletedAt: null,
sharedLinks: [],
sharedUsers: [userStub.user1],
isActivityEnabled: true,
}),
sharedWithMultiple: Object.freeze<AlbumEntity>({
id: 'album-3',
@ -48,6 +50,7 @@ export const albumStub = {
deletedAt: null,
sharedLinks: [],
sharedUsers: [userStub.user1, userStub.user2],
isActivityEnabled: true,
}),
sharedWithAdmin: Object.freeze<AlbumEntity>({
id: 'album-3',
@ -63,6 +66,7 @@ export const albumStub = {
deletedAt: null,
sharedLinks: [],
sharedUsers: [userStub.admin],
isActivityEnabled: true,
}),
oneAsset: Object.freeze<AlbumEntity>({
id: 'album-4',
@ -78,6 +82,7 @@ export const albumStub = {
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
isActivityEnabled: true,
}),
twoAssets: Object.freeze<AlbumEntity>({
id: 'album-4a',
@ -93,6 +98,7 @@ export const albumStub = {
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
isActivityEnabled: true,
}),
emptyWithInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5',
@ -108,6 +114,7 @@ export const albumStub = {
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
isActivityEnabled: true,
}),
emptyWithValidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-5',
@ -123,6 +130,7 @@ export const albumStub = {
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
isActivityEnabled: true,
}),
oneAssetInvalidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-6',
@ -138,6 +146,7 @@ export const albumStub = {
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
isActivityEnabled: true,
}),
oneAssetValidThumbnail: Object.freeze<AlbumEntity>({
id: 'album-6',
@ -153,5 +162,6 @@ export const albumStub = {
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
isActivityEnabled: true,
}),
};

View file

@ -100,6 +100,7 @@ const albumResponse: AlbumResponseDto = {
hasSharedLink: false,
assets: [],
assetCount: 1,
isActivityEnabled: true,
};
export const sharedLinkStub = {
@ -179,6 +180,7 @@ export const sharedLinkStub = {
albumThumbnailAssetId: null,
sharedUsers: [],
sharedLinks: [],
isActivityEnabled: true,
assets: [
{
id: 'id_1',

View file

@ -19,6 +19,7 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
activity: {
hasOwnerAccess: jest.fn(),
hasAlbumOwnerAccess: jest.fn(),
hasCreateAccess: jest.fn(),
},
asset: {
hasOwnerAccess: jest.fn(),

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.84.0
* The version of the OpenAPI document: 1.85.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.84.0
* The version of the OpenAPI document: 1.85.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.84.0
* The version of the OpenAPI document: 1.85.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.84.0
* The version of the OpenAPI document: 1.85.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { mdiClose, mdiPlus } from '@mdi/js';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import { createEventDispatcher } from 'svelte';
import type { AlbumResponseDto, UserResponseDto } from '../../../api/open-api';
import Icon from '$lib/components/elements/icon.svelte';
export let album: AlbumResponseDto;
export let user: UserResponseDto;
const dispatch = createEventDispatcher<{
close: void;
toggleEnableActivity: void;
showSelectSharedUser: void;
}>();
</script>
<FullScreenModal on:clickOutside={() => dispatch('close')}>
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden p-2 md:p-0">
<div
class="w-[550px] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div class="px-2 pt-2">
<div class="flex items-center">
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary">Options</h1>
<div>
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
</div>
</div>
<div class=" items-center justify-center p-4">
<div class="py-2">
<h2 class="text-gray text-sm mb-3">SHARING</h2>
<div class="p-2">
<SettingSwitch
title="Comments & likes"
subtitle="Let others respond"
checked={album.isActivityEnabled}
on:toggle={() => dispatch('toggleEnableActivity')}
/>
</div>
</div>
<div class="py-2">
<div class="text-gray text-sm mb-3">PEOPLE</div>
<div class="p-2">
<button class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
<div><Icon path={mdiPlus} size="25" /></div>
</div>
<div>Invite People</div>
</button>
<div class="flex items-center gap-2 py-2 mt-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{`${user.firstName} ${user.lastName}`}</div>
<div>Owner</div>
</div>
{#each album.sharedUsers as user (user.id)}
<div class="flex items-center gap-2 py-2">
<div>
<UserAvatar {user} size="md" />
</div>
<div class="w-full">{`${user.firstName} ${user.lastName}`}</div>
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
</div>
</FullScreenModal>

View file

@ -7,6 +7,7 @@
export let isLiked: ActivityResponseDto | null;
export let numberOfComments: number | undefined;
export let isShowActivity: boolean | undefined;
export let disabled: boolean;
const dispatch = createEventDispatcher();
</script>
@ -14,7 +15,7 @@
<div
class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60"
>
<button on:click={() => dispatch('favorite')}>
<button class={disabled ? 'cursor-not-allowed' : ''} on:click={() => dispatch('favorite')} {disabled}>
<!-- svelte-ignore missing-declaration -->
<div class="items-center justify-center">
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />

View file

@ -38,6 +38,7 @@
export let albumId: string;
export let assetType: AssetTypeEnum | undefined = undefined;
export let albumOwnerId: string;
export let disabled: boolean;
let textArea: HTMLTextAreaElement;
let innerHeight: number;
@ -280,12 +281,15 @@
<form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}>
<div class="flex w-full items-center gap-4">
<textarea
{disabled}
bind:this={textArea}
bind:value={message}
placeholder="Say something"
placeholder={disabled ? 'Comments are disabled' : 'Say something'}
on:input={autoGrow}
on:keypress={handleEnter}
class="h-[18px] w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
class="h-[18px] {disabled
? 'cursor-not-allowed'
: ''} w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
/>
</div>
{#if isSendingMessage}

View file

@ -104,6 +104,12 @@
}
}
$: {
if (album && !album.isActivityEnabled && numberOfComments === 0) {
isShowActivity = false;
}
}
const handleAddComment = () => {
numberOfComments++;
updateNumberOfComments(1);
@ -115,7 +121,7 @@
};
const handleFavorite = async () => {
if (album) {
if (album && album.isActivityEnabled) {
try {
if (isLiked) {
const activityId = isLiked.id;
@ -661,9 +667,10 @@
on:onVideoStarted={handleVideoStarted}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
<div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
<ActivityStatus
disabled={!album?.isActivityEnabled}
{isLiked}
{numberOfComments}
{isShowActivity}
@ -744,6 +751,7 @@
>
<ActivityViewer
{user}
disabled={!album.isActivityEnabled}
assetType={asset.type}
albumOwnerId={album.ownerId}
albumId={album.id}

View file

@ -1,11 +1,11 @@
import { writable } from 'svelte/store';
export const numberOfComments = writable<number | undefined>(undefined);
export const numberOfComments = writable<number>(0);
export const setNumberOfComments = (number: number) => {
numberOfComments.set(number);
};
export const updateNumberOfComments = (addOrRemove: 1 | -1) => {
numberOfComments.update((n) => (n ? n + addOrRemove : undefined));
numberOfComments.update((n) => n + addOrRemove);
};

View file

@ -55,6 +55,7 @@
import ActivityViewer from '$lib/components/asset-viewer/activity-viewer.svelte';
import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte';
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
export let data: PageData;
@ -64,6 +65,12 @@
let album = data.album;
$: album = data.album;
$: {
if (!album.isActivityEnabled && $numberOfComments === 0) {
isShowActivity = false;
}
}
enum ViewMode {
CONFIRM_DELETE = 'confirm-delete',
LINK_SHARING = 'link-sharing',
@ -73,6 +80,7 @@
ALBUM_OPTIONS = 'album-options',
VIEW_USERS = 'view-users',
VIEW = 'view',
OPTIONS = 'options',
}
let backUrl: string = AppRoute.ALBUMS;
@ -107,6 +115,8 @@
assetGridWidth = globalWidth;
}
}
$: showActivityStatus =
album.sharedUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0);
afterNavigate(({ from }) => {
assetViewingStore.showAssetViewer(false);
@ -128,6 +138,24 @@
}
});
const handleToggleEnableActivity = async () => {
try {
const { data } = await api.albumApi.updateAlbumInfo({
id: album.id,
updateAlbumDto: {
isActivityEnabled: !album.isActivityEnabled,
},
});
album = data;
notificationController.show({
type: NotificationType.Info,
message: `Activity is ${album.isActivityEnabled ? 'enabled' : 'disabled'}`,
});
} catch (error) {
handleError(error, `Can't ${!album.isActivityEnabled ? 'enable' : 'disable'} activity`);
}
};
const handleFavorite = async () => {
try {
if (isLiked) {
@ -374,6 +402,7 @@
},
});
currentAlbumName = album.albumName;
notificationController.show({ type: NotificationType.Info, message: 'New album name has been saved' });
} catch (error) {
handleError(error, 'Unable to update album name');
}
@ -455,6 +484,7 @@
<MenuOption on:click={handleStartSlideshow} text="Slideshow" />
{/if}
<MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
<MenuOption on:click={() => (viewMode = ViewMode.OPTIONS)} text="Options" />
</ContextMenu>
{/if}
</CircleIconButton>
@ -630,9 +660,10 @@
</AssetGrid>
{/if}
{#if album.sharedUsers.length > 0 && !$showAssetViewer}
{#if showActivityStatus}
<div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end">
<ActivityStatus
disabled={!album.isActivityEnabled}
{isLiked}
numberOfComments={$numberOfComments}
{isShowActivity}
@ -648,11 +679,12 @@
<div
transition:fly={{ duration: 150 }}
id="activity-panel"
class="z-[1002] w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg pl-4"
class="z-[2] w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg pl-4"
translate="yes"
>
<ActivityViewer
{user}
disabled={!album.isActivityEnabled}
albumOwnerId={album.ownerId}
albumId={album.id}
bind:reactions
@ -700,6 +732,16 @@
</ConfirmDialogue>
{/if}
{#if viewMode === ViewMode.OPTIONS}
<AlbumOptions
{album}
{user}
on:close={() => (viewMode = ViewMode.VIEW)}
on:toggleEnableActivity={handleToggleEnableActivity}
on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)}
/>
{/if}
{#if isEditingDescription}
<EditDescriptionModal
{album}

View file

@ -17,4 +17,5 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
shared: false,
sharedUsers: [],
hasSharedLink: false,
isActivityEnabled: true,
});

View file

@ -24,7 +24,7 @@ const config = {
},
plugins: [sveltekit()],
optimizeDeps: {
entries: ['src/**/*.{svelte, ts, html}'],
entries: ['src/**/*.{svelte,ts,html}'],
},
};