add ability to remove one's avatar for account and channels (#3467)

* add ability to remove one's avatar for account and channels

* add ability to remove one's avatar for account and channels

* only display avatar edition options after input change
This commit is contained in:
Rigel Kent 2021-01-13 09:12:55 +01:00 committed by GitHub
parent 75dd1b641f
commit 1ea7da819e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 207 additions and 27 deletions

View File

@ -3,7 +3,7 @@
<div class="form-group col-12 col-lg-4 col-xl-3"></div> <div class="form-group col-12 col-lg-4 col-xl-3"></div>
<div class="form-group col-12 col-lg-8 col-xl-9"> <div class="form-group col-12 col-lg-8 col-xl-9">
<my-actor-avatar-info [actor]="user.account" (avatarChange)="onAvatarChange($event)"></my-actor-avatar-info> <my-actor-avatar-info [actor]="user.account" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"></my-actor-avatar-info>
</div> </div>
</div> </div>

View File

@ -53,4 +53,17 @@ export class MyAccountSettingsComponent implements OnInit, AfterViewChecked {
}) })
) )
} }
onAvatarDelete () {
this.userService.deleteAvatar()
.subscribe(
data => {
this.notifier.success($localize`Avatar deleted.`)
this.user.updateAccountAvatar()
},
(err: HttpErrorResponse) => this.notifier.error(err.message)
)
}
} }

View File

@ -46,7 +46,7 @@
<my-actor-avatar-info <my-actor-avatar-info
*ngIf="!isCreation() && videoChannelToUpdate" *ngIf="!isCreation() && videoChannelToUpdate"
[actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)" [actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-info> ></my-actor-avatar-info>
<div class="form-group"> <div class="form-group">

View File

@ -14,6 +14,7 @@ export abstract class MyVideoChannelEdit extends FormReactive {
// We need this method so angular does not complain in child template that doesn't need this // We need this method so angular does not complain in child template that doesn't need this
onAvatarChange (formData: FormData) { /* empty */ } onAvatarChange (formData: FormData) { /* empty */ }
onAvatarDelete () { /* empty */ }
// Should be implemented by the child // Should be implemented by the child
isBulkUpdateVideosDisplayed () { isBulkUpdateVideosDisplayed () {

View File

@ -11,6 +11,8 @@ import { FormValidatorService } from '@app/shared/shared-forms'
import { VideoChannel, VideoChannelService } from '@app/shared/shared-main' import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
import { ServerConfig, VideoChannelUpdate } from '@shared/models' import { ServerConfig, VideoChannelUpdate } from '@shared/models'
import { MyVideoChannelEdit } from './my-video-channel-edit' import { MyVideoChannelEdit } from './my-video-channel-edit'
import { HttpErrorResponse } from '@angular/common/http'
import { uploadErrorHandler } from '@app/helpers'
@Component({ @Component({
selector: 'my-video-channel-update', selector: 'my-video-channel-update',
@ -107,10 +109,27 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements
this.videoChannelToUpdate.updateAvatar(data.avatar) this.videoChannelToUpdate.updateAvatar(data.avatar)
}, },
err => this.notifier.error(err.message) (err: HttpErrorResponse) => uploadErrorHandler({
err,
name: $localize`avatar`,
notifier: this.notifier
})
) )
} }
onAvatarDelete () {
this.videoChannelService.deleteVideoChannelAvatar(this.videoChannelToUpdate.name)
.subscribe(
data => {
this.notifier.success($localize`Avatar deleted.`)
this.videoChannelToUpdate.resetAvatar()
},
err => this.notifier.error(err.message)
)
}
get maxAvatarSize () { get maxAvatarSize () {
return this.serverConfig.avatar.file.size.max return this.serverConfig.avatar.file.size.max
} }

View File

@ -131,8 +131,9 @@ export class User implements UserServerModel {
} }
} }
updateAccountAvatar (newAccountAvatar: Avatar) { updateAccountAvatar (newAccountAvatar?: Avatar) {
this.account.updateAvatar(newAccountAvatar) if (newAccountAvatar) this.account.updateAvatar(newAccountAvatar)
else this.account.resetAvatar()
} }
isUploadDisabled () { isUploadDisabled () {

View File

@ -123,6 +123,16 @@ export class UserService {
.pipe(catchError(err => this.restExtractor.handleError(err))) .pipe(catchError(err => this.restExtractor.handleError(err)))
} }
deleteAvatar () {
const url = UserService.BASE_USERS_URL + 'me/avatar'
return this.authHttp.delete(url)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(err => this.restExtractor.handleError(err))
)
}
signup (userCreate: UserRegister) { signup (userCreate: UserRegister) {
return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate) return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
.pipe( .pipe(

View File

@ -44,6 +44,11 @@ export class Account extends Actor implements ServerAccount {
this.updateComputedAttributes() this.updateComputedAttributes()
} }
resetAvatar () {
this.avatar = null
this.avatarUrl = Account.GET_DEFAULT_AVATAR_URL()
}
private updateComputedAttributes () { private updateComputedAttributes () {
this.avatarUrl = Account.GET_ACTOR_AVATAR_URL(this) this.avatarUrl = Account.GET_ACTOR_AVATAR_URL(this)
} }

View File

@ -4,12 +4,18 @@
<img [src]="actor.avatarUrl" alt="Avatar" /> <img [src]="actor.avatarUrl" alt="Avatar" />
<div class="actor-img-edit-container"> <div class="actor-img-edit-container">
<div class="actor-img-edit-button" [ngbTooltip]="avatarFormat"
placement="right" container="body"> <div *ngIf="!hasAvatar" class="actor-img-edit-button" [ngbTooltip]="avatarFormat" placement="right" container="body">
<my-global-icon iconName="edit"></my-global-icon> <my-global-icon iconName="upload"></my-global-icon>
<label for="avatarfile" i18n>Change your avatar</label> <label class="sr-only" for="avatarfile" i18n>Upload a new avatar</label>
<input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/> <input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange(avatarfileInput)"/>
</div> </div>
<div *ngIf="hasAvatar" class="actor-img-edit-button" #avatarPopover="ngbPopover" [ngbPopover]="avatarEditContent" popoverClass="popover-avatar-info" autoClose="outside" placement="right">
<my-global-icon iconName="edit"></my-global-icon>
<label class="sr-only" for="avatarMenu" i18n>Change your avatar</label>
</div>
</div> </div>
</div> </div>
@ -22,4 +28,16 @@
<div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div> <div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div>
</div> </div>
</div> </div>
</ng-container> </ng-container>
<ng-template #avatarEditContent>
<div class="dropdown-item c-hand" [ngbTooltip]="avatarFormat" placement="right" container="body">
<my-global-icon iconName="upload"></my-global-icon>
<span for="avatarfile" i18n>Upload a new avatar</span>
<input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange(avatarfileInput)"/>
</div>
<div class="dropdown-item c-hand" (click)="deleteAvatar()" (key.enter)="deleteAvatar()">
<my-global-icon iconName="delete"></my-global-icon>
<span i18n>Remove avatar</span>
</div>
</ng-template>

View File

@ -70,3 +70,17 @@
} }
} }
} }
.actor-img-edit-container ::ng-deep .popover-avatar-info .popover-body {
padding: 0;
.dropdown-item {
padding: 6px 10px;
border-radius: 4px;
&:first-child {
@include peertube-file;
display: block;
}
}
}

View File

@ -1,22 +1,27 @@
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { getBytes } from '@root-helpers/bytes' import { getBytes } from '@root-helpers/bytes'
import { ServerConfig } from '@shared/models' import { ServerConfig } from '@shared/models'
import { VideoChannel } from '../video-channel/video-channel.model' import { VideoChannel } from '../video-channel/video-channel.model'
import { Account } from '../account/account.model' import { Account } from '../account/account.model'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { Actor } from './actor.model'
@Component({ @Component({
selector: 'my-actor-avatar-info', selector: 'my-actor-avatar-info',
templateUrl: './actor-avatar-info.component.html', templateUrl: './actor-avatar-info.component.html',
styleUrls: [ './actor-avatar-info.component.scss' ] styleUrls: [ './actor-avatar-info.component.scss' ]
}) })
export class ActorAvatarInfoComponent implements OnInit { export class ActorAvatarInfoComponent implements OnInit, OnChanges {
@ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement> @ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement>
@ViewChild('avatarPopover') avatarPopover: NgbPopover
@Input() actor: VideoChannel | Account @Input() actor: VideoChannel | Account
@Output() avatarChange = new EventEmitter<FormData>() @Output() avatarChange = new EventEmitter<FormData>()
@Output() avatarDelete = new EventEmitter<void>()
private avatarUrl: string
private serverConfig: ServerConfig private serverConfig: ServerConfig
constructor ( constructor (
@ -30,19 +35,31 @@ export class ActorAvatarInfoComponent implements OnInit {
.subscribe(config => this.serverConfig = config) .subscribe(config => this.serverConfig = config)
} }
onAvatarChange () { ngOnChanges (changes: SimpleChanges) {
if (changes['actor']) {
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.actor)
}
}
onAvatarChange (input: HTMLInputElement) {
this.avatarfileInput = new ElementRef(input)
const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ] const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ]
if (avatarfile.size > this.maxAvatarSize) { if (avatarfile.size > this.maxAvatarSize) {
this.notifier.error('Error', 'This image is too large.') this.notifier.error('Error', $localize`This image is too large.`)
return return
} }
const formData = new FormData() const formData = new FormData()
formData.append('avatarfile', avatarfile) formData.append('avatarfile', avatarfile)
this.avatarPopover?.close()
this.avatarChange.emit(formData) this.avatarChange.emit(formData)
} }
deleteAvatar () {
this.avatarDelete.emit()
}
get maxAvatarSize () { get maxAvatarSize () {
return this.serverConfig.avatar.file.size.max return this.serverConfig.avatar.file.size.max
} }
@ -58,4 +75,8 @@ export class ActorAvatarInfoComponent implements OnInit {
get avatarFormat () { get avatarFormat () {
return `${$localize`max size`}: 192*192px, ${this.maxAvatarSizeInBytes} ${$localize`extensions`}: ${this.avatarExtensions}` return `${$localize`max size`}: 192*192px, ${this.maxAvatarSizeInBytes} ${$localize`extensions`}: ${this.avatarExtensions}`
} }
get hasAvatar () {
return !!this.avatarUrl
}
} }

View File

@ -56,6 +56,11 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
this.updateComputedAttributes() this.updateComputedAttributes()
} }
resetAvatar () {
this.avatar = null
this.avatarUrl = VideoChannel.GET_DEFAULT_AVATAR_URL()
}
private updateComputedAttributes () { private updateComputedAttributes () {
this.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(this) this.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(this)
} }

View File

@ -89,6 +89,16 @@ export class VideoChannelService {
.pipe(catchError(err => this.restExtractor.handleError(err))) .pipe(catchError(err => this.restExtractor.handleError(err)))
} }
deleteVideoChannelAvatar (videoChannelName: string) {
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/avatar'
return this.authHttp.delete(url)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(err => this.restExtractor.handleError(err))
)
}
removeVideoChannel (videoChannel: VideoChannel) { removeVideoChannel (videoChannel: VideoChannel) {
return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost) return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost)
.pipe( .pipe(

View File

@ -260,15 +260,12 @@
} }
} }
@mixin peertube-button-file ($width) { @mixin peertube-file {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
display: inline-block; display: inline-block;
width: $width;
min-height: 30px; min-height: 30px;
@include peertube-button;
input[type=file] { input[type=file] {
position: absolute; position: absolute;
top: 0; top: 0;
@ -286,6 +283,13 @@
} }
} }
@mixin peertube-button-file ($width) {
width: $width;
@include peertube-file;
@include peertube-button;
}
@mixin icon ($size) { @mixin icon ($size) {
display: inline-block; display: inline-block;
background-repeat: no-repeat; background-repeat: no-repeat;

View File

@ -10,7 +10,7 @@ import { CONFIG } from '../../../initializers/config'
import { MIMETYPES } from '../../../initializers/constants' import { MIMETYPES } from '../../../initializers/constants'
import { sequelizeTypescript } from '../../../initializers/database' import { sequelizeTypescript } from '../../../initializers/database'
import { sendUpdateActor } from '../../../lib/activitypub/send' import { sendUpdateActor } from '../../../lib/activitypub/send'
import { updateActorAvatarFile } from '../../../lib/avatar' import { deleteActorAvatarFile, updateActorAvatarFile } from '../../../lib/avatar'
import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user' import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
import { import {
asyncMiddleware, asyncMiddleware,
@ -89,6 +89,11 @@ meRouter.post('/me/avatar/pick',
asyncRetryTransactionMiddleware(updateMyAvatar) asyncRetryTransactionMiddleware(updateMyAvatar)
) )
meRouter.delete('/me/avatar',
authenticate,
asyncRetryTransactionMiddleware(deleteMyAvatar)
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -225,7 +230,16 @@ async function updateMyAvatar (req: express.Request, res: express.Response) {
const userAccount = await AccountModel.load(user.Account.id) const userAccount = await AccountModel.load(user.Account.id)
const avatar = await updateActorAvatarFile(avatarPhysicalFile, userAccount) const avatar = await updateActorAvatarFile(userAccount, avatarPhysicalFile)
return res.json({ avatar: avatar.toFormattedJSON() }) return res.json({ avatar: avatar.toFormattedJSON() })
} }
async function deleteMyAvatar (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.user
const userAccount = await AccountModel.load(user.Account.id)
await deleteActorAvatarFile(userAccount)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@ -13,7 +13,7 @@ import { MIMETYPES } from '../../initializers/constants'
import { sequelizeTypescript } from '../../initializers/database' import { sequelizeTypescript } from '../../initializers/database'
import { setAsyncActorKeys } from '../../lib/activitypub/actor' import { setAsyncActorKeys } from '../../lib/activitypub/actor'
import { sendUpdateActor } from '../../lib/activitypub/send' import { sendUpdateActor } from '../../lib/activitypub/send'
import { updateActorAvatarFile } from '../../lib/avatar' import { deleteActorAvatarFile, updateActorAvatarFile } from '../../lib/avatar'
import { JobQueue } from '../../lib/job-queue' import { JobQueue } from '../../lib/job-queue'
import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel' import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel'
import { import {
@ -70,6 +70,13 @@ videoChannelRouter.post('/:nameWithHost/avatar/pick',
asyncMiddleware(updateVideoChannelAvatar) asyncMiddleware(updateVideoChannelAvatar)
) )
videoChannelRouter.delete('/:nameWithHost/avatar',
authenticate,
// Check the rights
asyncMiddleware(videoChannelsUpdateValidator),
asyncMiddleware(deleteVideoChannelAvatar)
)
videoChannelRouter.put('/:nameWithHost', videoChannelRouter.put('/:nameWithHost',
authenticate, authenticate,
asyncMiddleware(videoChannelsUpdateValidator), asyncMiddleware(videoChannelsUpdateValidator),
@ -133,7 +140,7 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp
const videoChannel = res.locals.videoChannel const videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON()) const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
const avatar = await updateActorAvatarFile(avatarPhysicalFile, videoChannel) const avatar = await updateActorAvatarFile(videoChannel, avatarPhysicalFile)
auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys) auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
@ -144,6 +151,14 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp
.end() .end()
} }
async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
await deleteActorAvatarFile(videoChannel)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function addVideoChannel (req: express.Request, res: express.Response) { async function addVideoChannel (req: express.Request, res: express.Response) {
const videoChannelInfo: VideoChannelCreate = req.body const videoChannelInfo: VideoChannelCreate = req.body

View File

@ -199,6 +199,19 @@ async function updateActorAvatarInstance (actor: MActorDefault, info: AvatarInfo
return actor return actor
} }
async function deleteActorAvatarInstance (actor: MActorDefault, t: Transaction) {
try {
await actor.Avatar.destroy({ transaction: t })
} catch (err) {
logger.error('Cannot remove old avatar of actor %s.', actor.url, { err })
}
actor.avatarId = null
actor.Avatar = null
return actor
}
async function fetchActorTotalItems (url: string) { async function fetchActorTotalItems (url: string) {
const options = { const options = {
uri: url, uri: url,
@ -337,6 +350,7 @@ export {
fetchActorTotalItems, fetchActorTotalItems,
getAvatarInfoIfExists, getAvatarInfoIfExists,
updateActorInstance, updateActorInstance,
deleteActorAvatarInstance,
refreshActorIfNeeded, refreshActorIfNeeded,
updateActorAvatarInstance, updateActorAvatarInstance,
addFetchOutboxJob addFetchOutboxJob

View File

@ -1,7 +1,7 @@
import 'multer' import 'multer'
import { sendUpdateActor } from './activitypub/send' import { sendUpdateActor } from './activitypub/send'
import { AVATARS_SIZE, LRU_CACHE, QUEUE_CONCURRENCY } from '../initializers/constants' import { AVATARS_SIZE, LRU_CACHE, QUEUE_CONCURRENCY } from '../initializers/constants'
import { updateActorAvatarInstance } from './activitypub/actor' import { updateActorAvatarInstance, deleteActorAvatarInstance } from './activitypub/actor'
import { processImage } from '../helpers/image-utils' import { processImage } from '../helpers/image-utils'
import { extname, join } from 'path' import { extname, join } from 'path'
import { retryTransactionWrapper } from '../helpers/database-utils' import { retryTransactionWrapper } from '../helpers/database-utils'
@ -14,8 +14,8 @@ import { downloadImage } from '../helpers/requests'
import { MAccountDefault, MChannelDefault } from '../types/models' import { MAccountDefault, MChannelDefault } from '../types/models'
async function updateActorAvatarFile ( async function updateActorAvatarFile (
avatarPhysicalFile: Express.Multer.File, accountOrChannel: MAccountDefault | MChannelDefault,
accountOrChannel: MAccountDefault | MChannelDefault avatarPhysicalFile: Express.Multer.File
) { ) {
const extension = extname(avatarPhysicalFile.filename) const extension = extname(avatarPhysicalFile.filename)
const avatarName = uuidv4() + extension const avatarName = uuidv4() + extension
@ -40,6 +40,21 @@ async function updateActorAvatarFile (
}) })
} }
async function deleteActorAvatarFile (
accountOrChannel: MAccountDefault | MChannelDefault
) {
return retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
const updatedActor = await deleteActorAvatarInstance(accountOrChannel.Actor, t)
await updatedActor.save({ transaction: t })
await sendUpdateActor(accountOrChannel, t)
return updatedActor.Avatar
})
})
}
type DownloadImageQueueTask = { fileUrl: string, filename: string } type DownloadImageQueueTask = { fileUrl: string, filename: string }
const downloadImageQueue = queue<DownloadImageQueueTask, Error>((task, cb) => { const downloadImageQueue = queue<DownloadImageQueueTask, Error>((task, cb) => {
@ -64,5 +79,6 @@ const avatarPathUnsafeCache = new LRUCache<string, string>({ max: LRU_CACHE.AVAT
export { export {
avatarPathUnsafeCache, avatarPathUnsafeCache,
updateActorAvatarFile, updateActorAvatarFile,
deleteActorAvatarFile,
pushAvatarProcessInQueue pushAvatarProcessInQueue
} }