1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(web,server): user memory settings (#3628)

* feat(web,server): user preference for time-based memories

* chore: open api

* dev: mobile

* fix: update

* mobile work

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
This commit is contained in:
Jason Rasmussen 2023-08-09 22:01:16 -04:00 committed by GitHub
parent 343087e2b4
commit a6eb227330
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 220 additions and 19 deletions

View File

@ -954,6 +954,12 @@ export interface CreateUserDto {
* @memberof CreateUserDto
*/
'lastName': string;
/**
*
* @type {boolean}
* @memberof CreateUserDto
*/
'memoriesEnabled'?: boolean;
/**
*
* @type {string}
@ -2995,6 +3001,12 @@ export interface UpdateUserDto {
* @memberof UpdateUserDto
*/
'lastName'?: string;
/**
*
* @type {boolean}
* @memberof UpdateUserDto
*/
'memoriesEnabled'?: boolean;
/**
*
* @type {string}
@ -3124,6 +3136,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto
*/
'lastName': string;
/**
*
* @type {boolean}
* @memberof UserResponseDto
*/
'memoriesEnabled': boolean;
/**
*
* @type {string}

View File

@ -342,7 +342,10 @@ class HomePage extends HookConsumerWidget {
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
onRefresh: refreshAssets,
topWidget: const MemoryLane(),
topWidget:
(currentUser != null && currentUser.memoryEnabled)
? const MemoryLane()
: const SizedBox(),
),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator,

View File

@ -97,12 +97,11 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<void> logout() async {
var log = Logger('AuthenticationNotifier');
try {
String? userEmail = Store.tryGet(StoreKey.currentUser)?.email;
_apiService.authenticationApi
.logout()
.then((_) => log.info("Logout was successfull for $userEmail"))
.then((_) => log.info("Logout was successful for $userEmail"))
.onError(
(error, stackTrace) =>
log.severe("Error logging out $userEmail", error, stackTrace),
@ -186,8 +185,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
user = User.fromDto(userResponseDto);
retResult = true;
}
else {
} else {
_log.severe("Unable to get user information from the server.");
return false;
}

View File

@ -1,4 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
@ -6,6 +7,9 @@ import 'package:immich_mobile/modules/search/providers/people.provider.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
class TabNavigationObserver extends AutoRouterObserver {
@ -46,6 +50,20 @@ class TabNavigationObserver extends AutoRouterObserver {
if (route.name == 'HomeRoute') {
ref.invalidate(memoryFutureProvider);
// Update user info
try {
final userResponseDto =
await ref.read(apiServiceProvider).userApi.getMyUserInfo();
if (userResponseDto == null) {
return;
}
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
} catch (e) {
debugPrint("Error refreshing user info $e");
}
}
ref.watch(serverInfoProvider.notifier).getServerVersion();
}

View File

@ -17,6 +17,7 @@ class User {
this.isPartnerSharedBy = false,
this.isPartnerSharedWith = false,
this.profileImagePath = '',
this.memoryEnabled = true,
});
Id get isarId => fastHash(id);
@ -30,7 +31,8 @@ class User {
isPartnerSharedBy = false,
isPartnerSharedWith = false,
profileImagePath = dto.profileImagePath,
isAdmin = dto.isAdmin;
isAdmin = dto.isAdmin,
memoryEnabled = dto.memoriesEnabled;
@Index(unique: true, replace: false, type: IndexType.hash)
String id;
@ -42,6 +44,7 @@ class User {
bool isPartnerSharedWith;
bool isAdmin;
String profileImagePath;
bool memoryEnabled;
@Backlink(to: 'owner')
final IsarLinks<Album> albums = IsarLinks<Album>();
@Backlink(to: 'sharedUsers')
@ -58,7 +61,8 @@ class User {
isPartnerSharedBy == other.isPartnerSharedBy &&
isPartnerSharedWith == other.isPartnerSharedWith &&
profileImagePath == other.profileImagePath &&
isAdmin == other.isAdmin;
isAdmin == other.isAdmin &&
memoryEnabled == other.memoryEnabled;
}
@override
@ -72,5 +76,6 @@ class User {
isPartnerSharedBy.hashCode ^
isPartnerSharedWith.hashCode ^
profileImagePath.hashCode ^
isAdmin.hashCode;
isAdmin.hashCode ^
memoryEnabled.hashCode;
}

Binary file not shown.

View File

@ -1,8 +1,8 @@
build:
flutter packages pub run build_runner build --delete-conflicting-outputs
dart run build_runner build --delete-conflicting-outputs
watch:
flutter packages pub run build_runner watch --delete-conflicting-outputs
dart run build_runner watch --delete-conflicting-outputs
create_app_icon:
flutter pub run flutter_launcher_icons:main

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -5396,6 +5396,9 @@
"lastName": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"password": {
"type": "string"
},
@ -7004,6 +7007,9 @@
"lastName": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"password": {
"type": "string"
},
@ -7092,6 +7098,9 @@
"lastName": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"oauthId": {
"type": "string"
},
@ -7123,7 +7132,8 @@
"createdAt",
"deletedAt",
"updatedAt",
"oauthId"
"oauthId",
"memoriesEnabled"
],
"type": "object"
},

View File

@ -176,6 +176,7 @@ describe(AlbumService.name, () => {
deletedAt: null,
updatedAt: new Date('2021-01-01'),
externalPath: null,
memoriesEnabled: true,
},
ownerId: 'admin_id',
shared: false,

View File

@ -1,10 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { authStub, newPartnerRepositoryMock, partnerStub } from '@test';
import { UserResponseDto } from '../index';
import { IPartnerRepository, PartnerDirection } from './partner.repository';
import { PartnerService } from './partner.service';
const responseDto = {
admin: {
admin: <UserResponseDto>{
email: 'admin@test.com',
firstName: 'admin_first_name',
id: 'admin_id',
@ -18,8 +19,9 @@ const responseDto = {
deletedAt: null,
updatedAt: new Date('2021-01-01'),
externalPath: null,
memoriesEnabled: true,
},
user1: {
user1: <UserResponseDto>{
email: 'immich@test.com',
firstName: 'immich_first_name',
id: 'user-id',
@ -33,6 +35,7 @@ const responseDto = {
deletedAt: null,
updatedAt: new Date('2021-01-01'),
externalPath: null,
memoriesEnabled: true,
},
};

View File

@ -1,5 +1,5 @@
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { toEmail, toSanitized } from '../../domain.util';
export class CreateUserDto {
@ -27,6 +27,10 @@ export class CreateUserDto {
@IsOptional()
@IsString()
externalPath?: string | null;
@IsOptional()
@IsBoolean()
memoriesEnabled?: boolean;
}
export class CreateAdminDto {

View File

@ -45,4 +45,8 @@ export class UpdateUserDto {
@IsOptional()
@IsBoolean()
shouldChangePassword?: boolean;
@IsOptional()
@IsBoolean()
memoriesEnabled?: boolean;
}

View File

@ -14,6 +14,7 @@ export class UserResponseDto {
deletedAt!: Date | null;
updatedAt!: Date;
oauthId!: string;
memoriesEnabled!: boolean;
}
export function mapUser(entity: UserEntity): UserResponseDto {
@ -31,5 +32,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
deletedAt: entity.deletedAt,
updatedAt: entity.updatedAt,
oauthId: entity.oauthId,
memoriesEnabled: entity.memoriesEnabled,
};
}

View File

@ -60,6 +60,7 @@ export class UserCore {
dto.externalPath = null;
}
console.log(dto.memoriesEnabled);
return this.userRepository.update(id, dto);
} catch (e) {
Logger.error(e, 'Failed to update user info');

View File

@ -16,6 +16,7 @@ import { ICryptoRepository } from '../crypto';
import { IJobRepository, JobName } from '../job';
import { IStorageRepository } from '../storage';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserResponseDto } from './response-dto';
import { IUserRepository } from './user.repository';
import { UserService } from './user.service';
@ -54,6 +55,7 @@ const adminUser: UserEntity = Object.freeze({
assets: [],
storageLabel: 'admin',
externalPath: null,
memoriesEnabled: true,
});
const immichUser: UserEntity = Object.freeze({
@ -73,9 +75,10 @@ const immichUser: UserEntity = Object.freeze({
assets: [],
storageLabel: null,
externalPath: null,
memoriesEnabled: true,
});
const updatedImmichUser: UserEntity = Object.freeze({
const updatedImmichUser = Object.freeze<UserEntity>({
id: immichUserAuth.id,
email: 'immich@test.com',
password: 'immich_password',
@ -92,9 +95,10 @@ const updatedImmichUser: UserEntity = Object.freeze({
assets: [],
storageLabel: null,
externalPath: null,
memoriesEnabled: true,
});
const adminUserResponse = Object.freeze({
const adminUserResponse = Object.freeze<UserResponseDto>({
id: adminUserAuth.id,
email: 'admin@test.com',
firstName: 'admin_first_name',
@ -108,6 +112,7 @@ const adminUserResponse = Object.freeze({
updatedAt: new Date('2021-01-01'),
storageLabel: 'admin',
externalPath: null,
memoriesEnabled: true,
});
describe(UserService.name, () => {
@ -158,6 +163,7 @@ describe(UserService.name, () => {
updatedAt: new Date('2021-01-01'),
storageLabel: 'admin',
externalPath: null,
memoriesEnabled: true,
},
]);
});

View File

@ -54,6 +54,9 @@ export class UserEntity {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Column({ default: true })
memoriesEnabled!: boolean;
@OneToMany(() => TagEntity, (tag) => tag.user)
tags!: TagEntity[];

View File

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

View File

@ -143,6 +143,24 @@ describe(`${UserController.name}`, () => {
});
expect(status).toBe(201);
});
it('should create a user without memories enabled', async () => {
const { status, body } = await request(server)
.post(`/user`)
.send({
email: 'no-memories@immich.app',
password: 'Password123',
firstName: 'No Memories',
lastName: 'User',
memoriesEnabled: false,
})
.set('Authorization', `Bearer ${accessToken}`);
expect(body).toMatchObject({
email: 'no-memories@immich.app',
memoriesEnabled: false,
});
expect(status).toBe(201);
});
});
describe('PUT /user', () => {
@ -206,6 +224,21 @@ describe(`${UserController.name}`, () => {
});
expect(before.updatedAt).not.toEqual(after.updatedAt);
});
it('should update memories enabled', async () => {
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
const after = await api.userApi.update(server, accessToken, {
id: before.id,
memoriesEnabled: false,
});
expect(after).toMatchObject({
...before,
updatedAt: expect.anything(),
memoriesEnabled: false,
});
expect(before.updatedAt).not.toEqual(after.updatedAt);
});
});
describe('GET /user/count', () => {

View File

@ -17,6 +17,7 @@ export const userStub = {
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
}),
user1: Object.freeze<UserEntity>({
...authStub.user1,
@ -33,6 +34,7 @@ export const userStub = {
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
}),
user2: Object.freeze<UserEntity>({
...authStub.user2,
@ -49,6 +51,7 @@ export const userStub = {
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
}),
storageLabel: Object.freeze<UserEntity>({
...authStub.user1,
@ -65,5 +68,6 @@ export const userStub = {
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
}),
};

View File

@ -954,6 +954,12 @@ export interface CreateUserDto {
* @memberof CreateUserDto
*/
'lastName': string;
/**
*
* @type {boolean}
* @memberof CreateUserDto
*/
'memoriesEnabled'?: boolean;
/**
*
* @type {string}
@ -2995,6 +3001,12 @@ export interface UpdateUserDto {
* @memberof UpdateUserDto
*/
'lastName'?: string;
/**
*
* @type {boolean}
* @memberof UpdateUserDto
*/
'memoriesEnabled'?: boolean;
/**
*
* @type {string}
@ -3124,6 +3136,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto
*/
'lastName': string;
/**
*
* @type {boolean}
* @memberof UserResponseDto
*/
'memoriesEnabled': boolean;
/**
*
* @type {string}

View File

@ -0,0 +1,49 @@
<script lang="ts">
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { api, UserResponseDto } from '@api';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import SettingSwitch from '../admin-page/settings/setting-switch.svelte';
import Button from '../elements/buttons/button.svelte';
export let user: UserResponseDto;
const handleSave = async () => {
try {
const { data } = await api.userApi.updateUser({
updateUserDto: {
id: user.id,
memoriesEnabled: user.memoriesEnabled,
},
});
Object.assign(user, data);
notificationController.show({ message: 'Saved settings', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to update settings');
}
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4">
<SettingSwitch
title="Time-based memories"
subtitle="Photos from previous years"
bind:checked={user.memoriesEnabled}
/>
</div>
<div class="flex justify-end">
<Button type="submit" size="sm" on:click={() => handleSave()}>Save</Button>
</div>
</div>
</form>
</div>
</section>

View File

@ -4,10 +4,11 @@
import { onMount } from 'svelte';
import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
import ChangePasswordSettings from './change-password-settings.svelte';
import OAuthSettings from './oauth-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import DeviceList from './device-list.svelte';
import MemoriesSettings from './memories-settings.svelte';
import OAuthSettings from './oauth-settings.svelte';
import PartnerSettings from './partner-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
export let user: UserResponseDto;
@ -39,6 +40,10 @@
<DeviceList />
</SettingAccordion>
<SettingAccordion title="Memories" subtitle="Manage what you see in your memories.">
<MemoriesSettings {user} />
</SettingAccordion>
{#if oauthEnabled}
<SettingAccordion
title="OAuth"

View File

@ -59,7 +59,9 @@
<svelte:fragment slot="content">
{#if assetCount}
<AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.ARCHIVE}>
<MemoryLane />
{#if data.user.memoriesEnabled}
<MemoryLane />
{/if}
</AssetGrid>
{:else}
<EmptyPlaceholder text="CLICK TO UPLOAD YOUR FIRST PHOTO" actionHandler={() => openFileUploadDialog()} />

View File

@ -15,5 +15,6 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
createdAt: Sync.each(() => faker.date.past().toISOString()),
deletedAt: null,
updatedAt: Sync.each(() => faker.date.past().toISOString()),
memoriesEnabled: true,
oauthId: '',
});