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:
parent
75dd1b641f
commit
1ea7da819e
|
@ -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>
|
||||
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue