@@ -4,6 +4,7 @@ | |||
### IMPORTANT NOTES | |||
* If your instance has signup enabled, user registration approval is automatically enabled by the default configuration of this release. You can change this setting in your `production.yaml` or in the configuration page in the web admin | |||
* Update [web browsers support list](https://joinpeertube.org/faq#what-web-browsers-are-supported-by-peertube): | |||
* Drop support of Safari 11 on iOS | |||
* Drop support of Safari 11 on desktop | |||
@@ -224,7 +224,7 @@ | |||
<div class="form-group"> | |||
<label i18n>Manually set the user password</label> | |||
<my-user-password [userId]="user.id"></my-user-password> | |||
<my-user-password [userId]="user.id" [username]="user.username"></my-user-password> | |||
</div> | |||
<div *ngIf="user.twoFactorEnabled" class="form-group"> | |||
@@ -11,12 +11,12 @@ import { UserUpdate } from '@shared/models' | |||
styleUrls: [ './user-password.component.scss' ] | |||
}) | |||
export class UserPasswordComponent extends FormReactive implements OnInit { | |||
@Input() userId: number | |||
@Input() username: string | |||
error: string | |||
username: string | |||
showPassword = false | |||
@Input() userId: number | |||
constructor ( | |||
protected formReactiveService: FormReactiveService, | |||
private notifier: Notifier, | |||
@@ -13,6 +13,7 @@ import { FormReactiveService } from '@app/shared/shared-forms' | |||
import { VideoChannel, VideoChannelService } from '@app/shared/shared-main' | |||
import { HTMLServerConfig, VideoChannelUpdate } from '@shared/models' | |||
import { VideoChannelEdit } from './video-channel-edit' | |||
import { shallowCopy } from '@shared/core-utils' | |||
@Component({ | |||
selector: 'my-video-channel-update', | |||
@@ -118,6 +119,9 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI | |||
this.notifier.success($localize`Avatar changed.`) | |||
this.videoChannel.updateAvatar(data.avatars) | |||
// So my-actor-avatar component detects changes | |||
this.videoChannel = shallowCopy(this.videoChannel) | |||
}, | |||
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ | |||
@@ -135,6 +139,9 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI | |||
this.notifier.success($localize`Avatar deleted.`) | |||
this.videoChannel.resetAvatar() | |||
// So my-actor-avatar component detects changes | |||
this.videoChannel = shallowCopy(this.videoChannel) | |||
}, | |||
error: err => this.notifier.error(err.message) | |||
@@ -3,6 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http' | |||
import { AfterViewChecked, Component, OnInit } from '@angular/core' | |||
import { AuthService, Notifier, User, UserService } from '@app/core' | |||
import { genericUploadErrorHandler } from '@app/helpers' | |||
import { shallowCopy } from '@shared/core-utils' | |||
@Component({ | |||
selector: 'my-account-settings', | |||
@@ -44,6 +45,9 @@ export class MyAccountSettingsComponent implements OnInit, AfterViewChecked { | |||
this.notifier.success($localize`Avatar changed.`) | |||
this.user.updateAccountAvatar(data.avatars) | |||
// So my-actor-avatar component detects changes | |||
this.user.account = shallowCopy(this.user.account) | |||
}, | |||
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ | |||
@@ -57,10 +61,13 @@ export class MyAccountSettingsComponent implements OnInit, AfterViewChecked { | |||
onAvatarDelete () { | |||
this.userService.deleteAvatar() | |||
.subscribe({ | |||
next: data => { | |||
next: () => { | |||
this.notifier.success($localize`Avatar deleted.`) | |||
this.user.updateAccountAvatar() | |||
// So my-actor-avatar component detects changes | |||
this.user.account = shallowCopy(this.user.account) | |||
}, | |||
error: (err: HttpErrorResponse) => this.notifier.error(err.message) | |||
@@ -21,9 +21,9 @@ | |||
} | |||
.section-title { | |||
font-size: 24px; | |||
padding-top: 20px; | |||
margin-bottom: 30px; | |||
@include font-size(1.5rem); | |||
@include padding-top(1.25rem); | |||
@include margin-bottom(2rem); | |||
&:not(h2) { | |||
border-top: 1px solid $separator-border-color; | |||
@@ -38,8 +38,8 @@ | |||
my-actor-avatar { | |||
@include margin-right(8px); | |||
position: relative; | |||
top: -2px; | |||
display: inline-block; | |||
vertical-align: text-top; | |||
} | |||
} | |||
@@ -49,8 +49,6 @@ | |||
.section-title { | |||
@include margin-left(10px); | |||
font-size: 17px; | |||
} | |||
} | |||
} |
@@ -1,23 +1,32 @@ | |||
<div class="actor" *ngIf="actor"> | |||
<div class="d-flex"> | |||
<div class="position-relative me-3"> | |||
<my-actor-avatar [actor]="actor" [actorType]="getActorType()" [previewImage]="preview" size="100"></my-actor-avatar> | |||
<div class="actor-img-edit-container"> | |||
<div *ngIf="editable && !hasAvatar()" class="actor-img-edit-button" [ngbTooltip]="avatarFormat" placement="right" container="body"> | |||
<my-global-icon iconName="upload"></my-global-icon> | |||
<label class="visually-hidden" for="avatarfile" i18n>Upload a new avatar</label> | |||
<input #avatarfileInput type="file" name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange(avatarfileInput)"/> | |||
</div> | |||
<div *ngIf="editable && !hasAvatar()" class="actor-img-edit-button" [ngbTooltip]="avatarFormat" placement="right" container="body"> | |||
<my-global-icon iconName="upload"></my-global-icon> | |||
<label class="visually-hidden" for="avatarfile" i18n>Upload a new avatar</label> | |||
<input #avatarfileInput type="file" name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange(avatarfileInput)"/> | |||
</div> | |||
<div | |||
*ngIf="editable && hasAvatar()" class="actor-img-edit-button" | |||
#avatarPopover="ngbPopover" [ngbPopover]="avatarEditContent" popoverClass="popover-image-info" autoClose="outside" placement="right" | |||
> | |||
<div *ngIf="editable && hasAvatar()" ngbDropdown placement="right"> | |||
<div class="actor-img-edit-button" ngbDropdownToggle> | |||
<my-global-icon iconName="edit"></my-global-icon> | |||
<label class="visually-hidden" for="avatarMenu" i18n>Change your avatar</label> | |||
</div> | |||
<div ngbDropdownMenu> | |||
<div class="dropdown-item c-hand dropdown-file" [ngbTooltip]="avatarFormat"> | |||
<my-global-icon iconName="upload"></my-global-icon> | |||
<span for="avatarfile" i18n>Upload a new avatar</span> | |||
<input #avatarfileInput type="file" 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> | |||
</div> | |||
</div> | |||
</div> | |||
@@ -27,16 +36,3 @@ | |||
<div *ngIf="displaySubscribers" i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div> | |||
</div> | |||
</div> | |||
<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" 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> |
@@ -5,10 +5,6 @@ | |||
display: flex; | |||
} | |||
my-actor-avatar { | |||
@include margin-right(15px); | |||
} | |||
.actor-info { | |||
display: inline-flex; | |||
flex-direction: column; | |||
@@ -16,12 +12,12 @@ my-actor-avatar { | |||
.actor-info-display-name { | |||
@include peertube-word-wrap; | |||
@include font-size(1.25rem); | |||
font-size: 20px; | |||
font-weight: $font-bold; | |||
@media screen and (max-width: $small-view) { | |||
font-size: 16px; | |||
@include font-size(18px); | |||
} | |||
} | |||
@@ -35,17 +31,18 @@ my-actor-avatar { | |||
padding-bottom: .5rem; | |||
} | |||
.actor-img-edit-container { | |||
position: relative; | |||
width: 0; | |||
} | |||
.actor-img-edit-button { | |||
top: 55px; | |||
right: 45px; | |||
border-radius: 50%; | |||
position: absolute; | |||
bottom: 5px; | |||
right: 5px; | |||
} | |||
.dropdown-item { | |||
@include dropdown-with-icon-item; | |||
} | |||
.dropdown-toggle::after { | |||
display: none; | |||
} |
@@ -1,7 +1,6 @@ | |||
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | |||
import { Notifier, ServerService } from '@app/core' | |||
import { Account, VideoChannel } from '@app/shared/shared-main' | |||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | |||
import { getBytes } from '@root-helpers/bytes' | |||
import { imageToDataURL } from '@root-helpers/images' | |||
@@ -15,7 +14,6 @@ import { imageToDataURL } from '@root-helpers/images' | |||
}) | |||
export class ActorAvatarEditComponent implements OnInit { | |||
@ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement> | |||
@ViewChild('avatarPopover') avatarPopover: NgbPopover | |||
@Input() actor: VideoChannel | Account | |||
@Input() editable = true | |||
@@ -58,7 +56,6 @@ export class ActorAvatarEditComponent implements OnInit { | |||
const formData = new FormData() | |||
formData.append('avatarfile', avatarfile) | |||
this.avatarPopover?.close() | |||
this.avatarChange.emit(formData) | |||
if (this.previewImage) { | |||
@@ -8,26 +8,25 @@ | |||
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container> | |||
</div> | |||
<div | |||
*ngIf="hasBanner()" class="actor-img-edit-button" | |||
#bannerPopover="ngbPopover" [ngbPopover]="bannerEditContent" popoverClass="popover-image-info" autoClose="outside" placement="right" | |||
> | |||
<my-global-icon iconName="edit"></my-global-icon> | |||
<label for="bannerMenu" i18n>Change your banner</label> | |||
</div> | |||
</div> | |||
</div> | |||
<div *ngIf="hasBanner()" ngbDropdown placement="right"> | |||
<div class="actor-img-edit-button" ngbDropdownToggle> | |||
<my-global-icon iconName="edit"></my-global-icon> | |||
<label for="bannerMenu" i18n>Change your banner</label> | |||
</div> | |||
<ng-template #bannerEditContent> | |||
<div class="dropdown-item c-hand" [ngbTooltip]="bannerFormat" placement="right" container="body"> | |||
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container> | |||
</div> | |||
<div ngbDropdownMenu> | |||
<div class="dropdown-item c-hand dropdown-file" [ngbTooltip]="bannerFormat"> | |||
<ng-container *ngTemplateOutlet="uploadNewBanner"></ng-container> | |||
</div> | |||
<div class="dropdown-item c-hand" (click)="deleteBanner()" (key.enter)="deleteBanner()"> | |||
<my-global-icon iconName="delete"></my-global-icon> | |||
<span i18n>Remove banner</span> | |||
<div class="dropdown-item c-hand" (click)="deleteBanner()" (key.enter)="deleteBanner()"> | |||
<my-global-icon iconName="delete"></my-global-icon> | |||
<span i18n>Remove banner</span> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</ng-template> | |||
</div> | |||
<ng-template #uploadNewBanner> | |||
<my-global-icon iconName="upload"></my-global-icon> | |||
@@ -16,12 +16,28 @@ | |||
align-items: center; | |||
} | |||
.actor-img-edit-button { | |||
.dropdown { | |||
position: absolute; | |||
> .actor-img-edit-button { | |||
position: relative; | |||
} | |||
} | |||
.actor-img-edit-button { | |||
width: auto; | |||
position: absolute; | |||
label { | |||
font-weight: $font-semibold; | |||
margin-bottom: 0; | |||
} | |||
} | |||
.dropdown-item { | |||
@include dropdown-with-icon-item; | |||
} | |||
.dropdown-toggle::after { | |||
display: none; | |||
} |
@@ -1,18 +1,8 @@ | |||
@use '_variables' as *; | |||
@use '_mixins' as *; | |||
.actor ::ng-deep .popover-image-info .popover-body { | |||
padding: 0; | |||
.dropdown-item { | |||
padding: 6px 10px; | |||
border-radius: 4px; | |||
&:first-child { | |||
@include peertube-file; | |||
display: block; | |||
} | |||
} | |||
.dropdown-file { | |||
@include peertube-file; | |||
} | |||
.actor-img-edit-button { | |||
@@ -22,8 +12,6 @@ | |||
display: flex; | |||
justify-content: center; | |||
margin-top: 10px; | |||
margin-bottom: 5px; | |||
cursor: pointer; | |||
input { | |||
@@ -1,5 +1,5 @@ | |||
<ng-template #img> | |||
<img *ngIf="displayImage()" [class]="classes" [src]="previewImage || avatarUrl || defaultAvatarUrl" [alt]="alt" /> | |||
<img *ngIf="displayImage()" [class]="classes" [src]="previewImage || avatarUrl || defaultAvatarUrl" alt="" /> | |||
<div *ngIf="displayActorInitial()" [ngClass]="classes"> | |||
<span>{{ getActorInitial() }}</span> | |||
@@ -43,22 +43,19 @@ export class ActorAvatarComponent implements OnInit, OnChanges { | |||
} | |||
classes: string[] = [] | |||
alt: string | |||
defaultAvatarUrl: string | |||
avatarUrl: string | |||
ngOnInit () { | |||
this.buildDefaultAvatarUrl() | |||
this.buildClasses() | |||
this.buildAlt() | |||
this.buildAvatarUrl() | |||
this.buildClasses() | |||
} | |||
ngOnChanges () { | |||
this.buildClasses() | |||
this.buildAlt() | |||
this.buildAvatarUrl() | |||
this.buildClasses() | |||
} | |||
private buildClasses () { | |||
@@ -81,12 +78,6 @@ export class ActorAvatarComponent implements OnInit, OnChanges { | |||
} | |||
} | |||
private buildAlt () { | |||
if (this.isAccount()) this.alt = $localize`Account avatar` | |||
else if (this.isChannel()) this.alt = $localize`Channel avatar` | |||
else this.alt = '' | |||
} | |||
private buildDefaultAvatarUrl () { | |||
this.defaultAvatarUrl = this.isChannel() | |||
? VideoChannel.GET_DEFAULT_AVATAR_URL(this.getSizeNumber()) | |||
@@ -114,12 +105,13 @@ export class ActorAvatarComponent implements OnInit, OnChanges { | |||
displayImage () { | |||
if (this.actorType === 'unlogged') return true | |||
if (this.previewImage) return true | |||
return !!(this.actor && this.avatarUrl) | |||
} | |||
displayActorInitial () { | |||
return this.actor && !this.avatarUrl | |||
return !this.displayImage() && this.actor && !this.avatarUrl | |||
} | |||
displayPlaceholder () { | |||
@@ -30,7 +30,7 @@ | |||
@import 'bootstrap/scss/helpers'; | |||
@import 'bootstrap/scss/utilities/api'; | |||
:root { | |||
body { | |||
--bs-border-color-translucent: #{pvar(--inputBorderColor)}; | |||
} | |||
@@ -155,6 +155,7 @@ | |||
@mixin orange-button-inverted { | |||
@include button-focus(pvar(--mainColorLightest)); | |||
padding: 2px 13px; | |||
border: 2px solid pvar(--mainColor); | |||
font-weight: $font-semibold; | |||
@@ -263,6 +264,7 @@ | |||
cursor: pointer; | |||
font-size: $button-font-size; | |||
line-height: $button-font-size + math.round(math.div($button-font-size, 2)); | |||
my-global-icon + * { | |||
@include margin-right(4px); | |||
@@ -312,6 +314,10 @@ | |||
width: $width; | |||
top: $top; | |||
} | |||
span { | |||
vertical-align: middle; | |||
} | |||
} | |||
@mixin peertube-file { | |||
@@ -781,12 +781,12 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> { | |||
`WHERE "account"."userId" = ${options.whereUserId} ${andWhere}` | |||
const webtorrentFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' + | |||
'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' + | |||
'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" AND "video"."isLive" IS FALSE ' + | |||
videoChannelJoin | |||
const hlsFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' + | |||
'INNER JOIN "videoStreamingPlaylist" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id ' + | |||
'INNER JOIN "video" ON "videoStreamingPlaylist"."videoId" = "video"."id" ' + | |||
'INNER JOIN "video" ON "videoStreamingPlaylist"."videoId" = "video"."id" AND "video"."isLive" IS FALSE ' + | |||
videoChannelJoin | |||
return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' + | |||
@@ -1,5 +1,6 @@ | |||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | |||
import { join } from 'path' | |||
import { areMockObjectStorageTestsDisabled } from '@shared/core-utils' | |||
import { HttpStatusCode, VideoDetails } from '@shared/models' | |||
import { | |||
@@ -12,7 +13,7 @@ import { | |||
setAccessTokensToServers, | |||
waitJobs | |||
} from '@shared/server-commands' | |||
import { expectStartWith } from '../shared' | |||
import { checkDirectoryIsEmpty, expectStartWith } from '../shared' | |||
async function checkFiles (origin: PeerTubeServer, video: VideoDetails, inObjectStorage: boolean) { | |||
for (const file of video.files) { | |||
@@ -106,6 +107,14 @@ describe('Test create move video storage job', function () { | |||
} | |||
}) | |||
it('Should not have files on disk anymore', async function () { | |||
await checkDirectoryIsEmpty(servers[0], 'videos', [ 'private' ]) | |||
await checkDirectoryIsEmpty(servers[0], join('videos', 'private')) | |||
await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls'), [ 'private' ]) | |||
await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls', 'private')) | |||
}) | |||
after(async function () { | |||
await cleanupTests(servers) | |||
}) | |||
@@ -41,9 +41,14 @@ function sortObjectComparator (key: string, order: 'asc' | 'desc') { | |||
} | |||
} | |||
function shallowCopy <T> (o: T): T { | |||
return Object.assign(Object.create(Object.getPrototypeOf(o)), o) | |||
} | |||
export { | |||
pick, | |||
omit, | |||
getKeys, | |||
shallowCopy, | |||
sortObjectComparator | |||
} |
@@ -57,6 +57,15 @@ object_storage: | |||
region: "PEERTUBE_OBJECT_STORAGE_REGION" | |||
upload_acl: | |||
public: "PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC" | |||
private: "PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE" | |||
proxy: | |||
proxify_private_files: | |||
__name: "PEERTUBE_OBJECT_STORAGE_PROXY_PROXIFY_PRIVATE_FILES" | |||
__format: "json" | |||
credentials: | |||
access_key_id: "PEERTUBE_OBJECT_STORAGE_CREDENTIALS_ACCESS_KEY_ID" | |||
secret_access_key: 'PEERTUBE_OBJECT_STORAGE_CREDENTIALS_SECRET_ACCESS_KEY' | |||