1
0
Fork 0

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-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>

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
*ngIf="!isCreation() && videoChannelToUpdate"
[actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)"
[actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-info>
<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
onAvatarChange (formData: FormData) { /* empty */ }
onAvatarDelete () { /* empty */ }
// Should be implemented by the child
isBulkUpdateVideosDisplayed () {

View File

@ -11,6 +11,8 @@ import { FormValidatorService } from '@app/shared/shared-forms'
import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
import { ServerConfig, VideoChannelUpdate } from '@shared/models'
import { MyVideoChannelEdit } from './my-video-channel-edit'
import { HttpErrorResponse } from '@angular/common/http'
import { uploadErrorHandler } from '@app/helpers'
@Component({
selector: 'my-video-channel-update',
@ -107,10 +109,27 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements
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 () {
return this.serverConfig.avatar.file.size.max
}

View File

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

View File

@ -123,6 +123,16 @@ export class UserService {
.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) {
return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
.pipe(

View File

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

View File

@ -4,12 +4,18 @@
<img [src]="actor.avatarUrl" alt="Avatar" />
<div class="actor-img-edit-container">
<div class="actor-img-edit-button" [ngbTooltip]="avatarFormat"
placement="right" container="body">
<my-global-icon iconName="edit"></my-global-icon>
<label for="avatarfile" i18n>Change your avatar</label>
<input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/>
<div *ngIf="!hasAvatar" class="actor-img-edit-button" [ngbTooltip]="avatarFormat" placement="right" container="body">
<my-global-icon iconName="upload"></my-global-icon>
<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(avatarfileInput)"/>
</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>
@ -22,4 +28,16 @@
<div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</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 { getBytes } from '@root-helpers/bytes'
import { ServerConfig } from '@shared/models'
import { VideoChannel } from '../video-channel/video-channel.model'
import { Account } from '../account/account.model'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { Actor } from './actor.model'
@Component({
selector: 'my-actor-avatar-info',
templateUrl: './actor-avatar-info.component.html',
styleUrls: [ './actor-avatar-info.component.scss' ]
})
export class ActorAvatarInfoComponent implements OnInit {
export class ActorAvatarInfoComponent implements OnInit, OnChanges {
@ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement>
@ViewChild('avatarPopover') avatarPopover: NgbPopover
@Input() actor: VideoChannel | Account
@Output() avatarChange = new EventEmitter<FormData>()
@Output() avatarDelete = new EventEmitter<void>()
private avatarUrl: string
private serverConfig: ServerConfig
constructor (
@ -30,19 +35,31 @@ export class ActorAvatarInfoComponent implements OnInit {
.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 ]
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
}
const formData = new FormData()
formData.append('avatarfile', avatarfile)
this.avatarPopover?.close()
this.avatarChange.emit(formData)
}
deleteAvatar () {
this.avatarDelete.emit()
}
get maxAvatarSize () {
return this.serverConfig.avatar.file.size.max
}
@ -58,4 +75,8 @@ export class ActorAvatarInfoComponent implements OnInit {
get avatarFormat () {
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()
}
resetAvatar () {
this.avatar = null
this.avatarUrl = VideoChannel.GET_DEFAULT_AVATAR_URL()
}
private updateComputedAttributes () {
this.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(this)
}

View File

@ -89,6 +89,16 @@ export class VideoChannelService {
.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) {
return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost)
.pipe(

View File

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

View File

@ -10,7 +10,7 @@ import { CONFIG } from '../../../initializers/config'
import { MIMETYPES } from '../../../initializers/constants'
import { sequelizeTypescript } from '../../../initializers/database'
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 {
asyncMiddleware,
@ -89,6 +89,11 @@ meRouter.post('/me/avatar/pick',
asyncRetryTransactionMiddleware(updateMyAvatar)
)
meRouter.delete('/me/avatar',
authenticate,
asyncRetryTransactionMiddleware(deleteMyAvatar)
)
// ---------------------------------------------------------------------------
export {
@ -225,7 +230,16 @@ async function updateMyAvatar (req: express.Request, res: express.Response) {
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() })
}
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 { setAsyncActorKeys } from '../../lib/activitypub/actor'
import { sendUpdateActor } from '../../lib/activitypub/send'
import { updateActorAvatarFile } from '../../lib/avatar'
import { deleteActorAvatarFile, updateActorAvatarFile } from '../../lib/avatar'
import { JobQueue } from '../../lib/job-queue'
import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel'
import {
@ -70,6 +70,13 @@ videoChannelRouter.post('/:nameWithHost/avatar/pick',
asyncMiddleware(updateVideoChannelAvatar)
)
videoChannelRouter.delete('/:nameWithHost/avatar',
authenticate,
// Check the rights
asyncMiddleware(videoChannelsUpdateValidator),
asyncMiddleware(deleteVideoChannelAvatar)
)
videoChannelRouter.put('/:nameWithHost',
authenticate,
asyncMiddleware(videoChannelsUpdateValidator),
@ -133,7 +140,7 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp
const videoChannel = res.locals.videoChannel
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)
@ -144,6 +151,14 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp
.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) {
const videoChannelInfo: VideoChannelCreate = req.body

View File

@ -199,6 +199,19 @@ async function updateActorAvatarInstance (actor: MActorDefault, info: AvatarInfo
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) {
const options = {
uri: url,
@ -337,6 +350,7 @@ export {
fetchActorTotalItems,
getAvatarInfoIfExists,
updateActorInstance,
deleteActorAvatarInstance,
refreshActorIfNeeded,
updateActorAvatarInstance,
addFetchOutboxJob

View File

@ -1,7 +1,7 @@
import 'multer'
import { sendUpdateActor } from './activitypub/send'
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 { extname, join } from 'path'
import { retryTransactionWrapper } from '../helpers/database-utils'
@ -14,8 +14,8 @@ import { downloadImage } from '../helpers/requests'
import { MAccountDefault, MChannelDefault } from '../types/models'
async function updateActorAvatarFile (
avatarPhysicalFile: Express.Multer.File,
accountOrChannel: MAccountDefault | MChannelDefault
accountOrChannel: MAccountDefault | MChannelDefault,
avatarPhysicalFile: Express.Multer.File
) {
const extension = extname(avatarPhysicalFile.filename)
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 }
const downloadImageQueue = queue<DownloadImageQueueTask, Error>((task, cb) => {
@ -64,5 +79,6 @@ const avatarPathUnsafeCache = new LRUCache<string, string>({ max: LRU_CACHE.AVAT
export {
avatarPathUnsafeCache,
updateActorAvatarFile,
deleteActorAvatarFile,
pushAvatarProcessInQueue
}