mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
fix(downloads): show loading skeletons before empty states
Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
1 parent
f7c6cb3572
commit
20445beaf0
8 files changed
+466
-8
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
WorkspaceAccountInfoData,
|
||||
@@ -67,5 +68,9 @@ export function provideWorkspaceShellActions(): Provider[] {
|
||||
provide: WORKSPACE_SHELL_ACTIONS,
|
||||
useExisting: AppWorkspaceShellActionsService,
|
||||
},
|
||||
{
|
||||
provide: PORTAL_SHELL_ACTIONS,
|
||||
useExisting: AppWorkspaceShellActionsService,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -54,6 +54,45 @@
|
||||
<p>{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}</p>
|
||||
<span>{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}</span>
|
||||
</div>
|
||||
} @else if (!playlistsLoaded()) {
|
||||
<div
|
||||
class="downloads__list-wrapper downloads__list-wrapper--loading"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<p class="downloads__loading-label">
|
||||
{{ 'CHANNELS.LOADING' | translate }}
|
||||
</p>
|
||||
<div
|
||||
class="downloads__list downloads__list--skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@for (slot of skeletonItems; track slot) {
|
||||
<div class="downloads__skeleton-item">
|
||||
<div class="downloads__skeleton-poster"></div>
|
||||
<div class="downloads__skeleton-info">
|
||||
<div class="downloads__skeleton-title"></div>
|
||||
<div
|
||||
class="downloads__skeleton-title downloads__skeleton-title--short"
|
||||
></div>
|
||||
<div class="downloads__skeleton-meta">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="downloads__skeleton-progress"></div>
|
||||
<div class="downloads__skeleton-actions">
|
||||
@for (
|
||||
actionSlot of skeletonActionSlots;
|
||||
track actionSlot
|
||||
) {
|
||||
<span></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (hasNoPlaylists()) {
|
||||
<app-empty-state
|
||||
type="no-data"
|
||||
@@ -65,7 +104,46 @@
|
||||
(primaryActionClicked)="addPlaylist()"
|
||||
(secondaryActionClicked)="goToSources()"
|
||||
/>
|
||||
} @else if (!hasDownloads()) {
|
||||
} @else if (showDownloadSkeleton()) {
|
||||
<div
|
||||
class="downloads__list-wrapper downloads__list-wrapper--loading"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<p class="downloads__loading-label">
|
||||
{{ 'CHANNELS.LOADING' | translate }}
|
||||
</p>
|
||||
<div
|
||||
class="downloads__list downloads__list--skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@for (slot of skeletonItems; track slot) {
|
||||
<div class="downloads__skeleton-item">
|
||||
<div class="downloads__skeleton-poster"></div>
|
||||
<div class="downloads__skeleton-info">
|
||||
<div class="downloads__skeleton-title"></div>
|
||||
<div
|
||||
class="downloads__skeleton-title downloads__skeleton-title--short"
|
||||
></div>
|
||||
<div class="downloads__skeleton-meta">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="downloads__skeleton-progress"></div>
|
||||
<div class="downloads__skeleton-actions">
|
||||
@for (
|
||||
actionSlot of skeletonActionSlots;
|
||||
track actionSlot
|
||||
) {
|
||||
<span></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (!hasScopedDownloads()) {
|
||||
<app-empty-state
|
||||
type="no-data"
|
||||
icon="download_for_offline"
|
||||
|
||||
@@ -5,6 +5,35 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
%downloads-skeleton-shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 56%,
|
||||
var(--app-body-color, var(--mat-sys-on-surface-variant)) 44%
|
||||
);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 8%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-heading-color, var(--mat-sys-on-surface)) 18%,
|
||||
transparent
|
||||
)
|
||||
44%,
|
||||
transparent 74%
|
||||
);
|
||||
transform: translateX(-120%);
|
||||
animation: downloads-skeleton-shimmer 1.15s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.downloads-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -143,6 +172,12 @@
|
||||
overflow-x: hidden;
|
||||
padding: 0 16px;
|
||||
container-type: inline-size;
|
||||
|
||||
&--loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
@@ -151,6 +186,111 @@
|
||||
gap: 12px;
|
||||
padding-bottom: 24px;
|
||||
align-content: start;
|
||||
|
||||
&--skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading-label {
|
||||
margin: 0;
|
||||
padding: 2px 4px 0;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
&__skeleton-item {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
min-height: 124px;
|
||||
padding: 1rem;
|
||||
border-radius: 14px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-bg, var(--mat-sys-surface-container-high)) 88%,
|
||||
var(--mat-sys-surface-container) 12%
|
||||
);
|
||||
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
|
||||
box-shadow: var(--app-widget-shadow, none);
|
||||
}
|
||||
|
||||
&__skeleton-poster {
|
||||
@extend %downloads-skeleton-shimmer;
|
||||
width: 64px;
|
||||
height: 90px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
|
||||
}
|
||||
|
||||
&__skeleton-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
&__skeleton-title {
|
||||
@extend %downloads-skeleton-shimmer;
|
||||
width: min(70%, 280px);
|
||||
height: 13px;
|
||||
border-radius: 999px;
|
||||
|
||||
&--short {
|
||||
width: min(48%, 190px);
|
||||
}
|
||||
}
|
||||
|
||||
&__skeleton-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
span {
|
||||
@extend %downloads-skeleton-shimmer;
|
||||
width: 88px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
|
||||
&:last-child {
|
||||
width: 132px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__skeleton-progress {
|
||||
@extend %downloads-skeleton-shimmer;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
&__skeleton-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-separator, var(--mat-sys-outline-variant)) 72%,
|
||||
transparent
|
||||
);
|
||||
|
||||
span {
|
||||
@extend %downloads-skeleton-shimmer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
@@ -415,4 +555,27 @@
|
||||
.downloads__item {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.downloads__skeleton-item {
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.downloads__skeleton-poster,
|
||||
.downloads__skeleton-title,
|
||||
.downloads__skeleton-meta span,
|
||||
.downloads__skeleton-progress,
|
||||
.downloads__skeleton-actions span {
|
||||
&::after {
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes downloads-skeleton-shimmer {
|
||||
100% {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,10 @@ import {
|
||||
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { queryParamSignal } from '@iptvnator/portal/shared/util';
|
||||
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
buildStandardCollectionCategories,
|
||||
filterCollectionBucket,
|
||||
PORTAL_SHELL_ACTIONS,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
@@ -67,13 +67,14 @@ export class DownloadsComponent {
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly shellActions = inject(PORTAL_SHELL_ACTIONS);
|
||||
readonly downloadsService = inject(DownloadsService);
|
||||
|
||||
readonly downloads = this.downloadsService.downloads;
|
||||
readonly downloadFolder = this.downloadsService.downloadFolder;
|
||||
readonly isAvailable = this.downloadsService.isAvailable;
|
||||
readonly hasDownloads = this.downloadsService.hasDownloads;
|
||||
readonly isLoadingDownloads = this.downloadsService.isLoadingDownloads;
|
||||
readonly hasLoadedDownloads = this.downloadsService.hasLoadedDownloads;
|
||||
readonly activeCount = this.downloadsService.activeCount;
|
||||
readonly playlistId = toSignal(
|
||||
this.route.paramMap.pipe(map((params) => params.get('id') ?? '')),
|
||||
@@ -83,9 +84,15 @@ export class DownloadsComponent {
|
||||
(value ?? '').trim().toLowerCase()
|
||||
);
|
||||
readonly playlists = toSignal(this.playlistsService.getAllPlaylists(), {
|
||||
initialValue: [] as Playlist[],
|
||||
initialValue: null as Playlist[] | null,
|
||||
});
|
||||
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
|
||||
readonly playlistsLoaded = computed(() => this.playlists() !== null);
|
||||
readonly playlistItems = computed(() => this.playlists() ?? []);
|
||||
readonly hasNoPlaylists = computed(
|
||||
() => this.playlistsLoaded() && this.playlistItems().length === 0
|
||||
);
|
||||
readonly skeletonItems = Array.from({ length: 6 }, (_, index) => index);
|
||||
readonly skeletonActionSlots = Array.from({ length: 4 }, (_, index) => index);
|
||||
|
||||
addPlaylist(): void {
|
||||
this.shellActions.openAddPlaylistDialog();
|
||||
@@ -106,6 +113,14 @@ export class DownloadsComponent {
|
||||
? downloads.filter((item) => item.playlistId === playlistId)
|
||||
: downloads;
|
||||
});
|
||||
readonly hasScopedDownloads = computed(
|
||||
() => this.scopedDownloads().length > 0
|
||||
);
|
||||
readonly showDownloadSkeleton = computed(
|
||||
() =>
|
||||
!this.hasScopedDownloads() &&
|
||||
(!this.hasLoadedDownloads() || this.isLoadingDownloads())
|
||||
);
|
||||
|
||||
readonly categories = computed(() => {
|
||||
const downloads = this.scopedDownloads();
|
||||
@@ -165,7 +180,7 @@ export class DownloadsComponent {
|
||||
)
|
||||
);
|
||||
readonly availablePlaylistIds = computed(
|
||||
() => new Set(this.playlists().map((playlist) => playlist._id))
|
||||
() => new Set(this.playlistItems().map((playlist) => playlist._id))
|
||||
);
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './lib/portal-external-playback';
|
||||
export * from './lib/portal-player';
|
||||
export * from './lib/portal-playback-positions';
|
||||
export * from './lib/portal-navigation-actions';
|
||||
export * from './lib/portal-shell-actions';
|
||||
export * from './lib/portal-collection-context';
|
||||
export * from './lib/portal-collection-context.service';
|
||||
export * from './lib/portal-collection-items';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export type PortalPlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
|
||||
|
||||
export interface PortalShellActions {
|
||||
openAddPlaylistDialog(type?: PortalPlaylistType): void;
|
||||
}
|
||||
|
||||
export const PORTAL_SHELL_ACTIONS = new InjectionToken<PortalShellActions>(
|
||||
'PORTAL_SHELL_ACTIONS'
|
||||
);
|
||||
@@ -0,0 +1,162 @@
|
||||
import { signal, Signal, WritableSignal } from '@angular/core';
|
||||
import {
|
||||
DownloadItem,
|
||||
DownloadsService,
|
||||
} from './downloads.service';
|
||||
|
||||
type TestDownloadsService = {
|
||||
downloads: WritableSignal<DownloadItem[]>;
|
||||
isAvailable: () => boolean;
|
||||
isLoadingDownloads: Signal<boolean>;
|
||||
hasLoadedDownloads: Signal<boolean>;
|
||||
loadDownloads: DownloadsService['loadDownloads'];
|
||||
_isLoadingDownloads: WritableSignal<boolean>;
|
||||
_hasLoadedDownloads: WritableSignal<boolean>;
|
||||
loadDownloadsRequestId: number;
|
||||
};
|
||||
|
||||
type DownloadsElectronStub = {
|
||||
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, [string?]>;
|
||||
};
|
||||
|
||||
describe('DownloadsService', () => {
|
||||
const testWindow = window as unknown as {
|
||||
electron?: DownloadsElectronStub;
|
||||
};
|
||||
const originalElectron = testWindow.electron;
|
||||
|
||||
afterEach(() => {
|
||||
testWindow.electron = originalElectron;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createDownload(id: number, playlistId = 'playlist-1'): DownloadItem {
|
||||
return {
|
||||
id,
|
||||
playlistId,
|
||||
xtreamId: id,
|
||||
contentType: 'vod',
|
||||
title: `Movie ${id}`,
|
||||
url: `https://example.com/${id}.mp4`,
|
||||
status: 'completed',
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createService(initialDownloads: DownloadItem[] = []) {
|
||||
const downloads = signal(initialDownloads);
|
||||
const isLoadingDownloads = signal(false);
|
||||
const hasLoadedDownloads = signal(false);
|
||||
const service = Object.create(
|
||||
DownloadsService.prototype
|
||||
) as TestDownloadsService;
|
||||
|
||||
Object.assign(service, {
|
||||
downloads,
|
||||
isAvailable: () => true,
|
||||
_isLoadingDownloads: isLoadingDownloads,
|
||||
isLoadingDownloads: isLoadingDownloads.asReadonly(),
|
||||
_hasLoadedDownloads: hasLoadedDownloads,
|
||||
hasLoadedDownloads: hasLoadedDownloads.asReadonly(),
|
||||
loadDownloadsRequestId: 0,
|
||||
});
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
it('tracks loading and loaded state around a successful download list request', async () => {
|
||||
const item = createDownload(1);
|
||||
const pending = createDeferred<DownloadItem[]>();
|
||||
const electron = {
|
||||
downloadsGetList: jest.fn(() => pending.promise),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
const request = service.loadDownloads('playlist-1');
|
||||
|
||||
expect(service.isLoadingDownloads()).toBe(true);
|
||||
expect(service.hasLoadedDownloads()).toBe(false);
|
||||
expect(electron.downloadsGetList).toHaveBeenCalledWith('playlist-1');
|
||||
|
||||
pending.resolve([item]);
|
||||
await request;
|
||||
|
||||
expect(service.downloads()).toEqual([item]);
|
||||
expect(service.isLoadingDownloads()).toBe(false);
|
||||
expect(service.hasLoadedDownloads()).toBe(true);
|
||||
});
|
||||
|
||||
it('marks downloads as loaded after a failed request while preserving existing data', async () => {
|
||||
const existing = createDownload(1);
|
||||
const error = new Error('download query failed');
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
testWindow.electron = {
|
||||
downloadsGetList: jest.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
};
|
||||
const service = createService([existing]);
|
||||
|
||||
await service.loadDownloads();
|
||||
|
||||
expect(service.downloads()).toEqual([existing]);
|
||||
expect(service.isLoadingDownloads()).toBe(false);
|
||||
expect(service.hasLoadedDownloads()).toBe(true);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[DownloadsService] Error loading downloads:',
|
||||
error
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps only the latest overlapping download list request result', async () => {
|
||||
const staleItem = createDownload(1, 'playlist-old');
|
||||
const latestItem = createDownload(2, 'playlist-new');
|
||||
const first = createDeferred<DownloadItem[]>();
|
||||
const second = createDeferred<DownloadItem[]>();
|
||||
const electron = {
|
||||
downloadsGetList: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise),
|
||||
};
|
||||
testWindow.electron = electron;
|
||||
const service = createService();
|
||||
|
||||
const firstRequest = service.loadDownloads('playlist-old');
|
||||
const secondRequest = service.loadDownloads('playlist-new');
|
||||
|
||||
expect(service.isLoadingDownloads()).toBe(true);
|
||||
|
||||
second.resolve([latestItem]);
|
||||
await secondRequest;
|
||||
|
||||
expect(service.downloads()).toEqual([latestItem]);
|
||||
expect(service.isLoadingDownloads()).toBe(false);
|
||||
expect(service.hasLoadedDownloads()).toBe(true);
|
||||
|
||||
first.resolve([staleItem]);
|
||||
await firstRequest;
|
||||
|
||||
expect(service.downloads()).toEqual([latestItem]);
|
||||
expect(service.isLoadingDownloads()).toBe(false);
|
||||
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'playlist-old'
|
||||
);
|
||||
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'playlist-new'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -33,10 +33,20 @@ export interface DownloadItem {
|
||||
export class DownloadsService implements OnDestroy {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private unsubscribe?: () => void;
|
||||
private loadDownloadsRequestId = 0;
|
||||
|
||||
private readonly _isLoadingDownloads = signal(false);
|
||||
private readonly _hasLoadedDownloads = signal(false);
|
||||
|
||||
/** Signal for the list of downloads */
|
||||
readonly downloads = signal<DownloadItem[]>([]);
|
||||
|
||||
/** Whether the download list is currently being loaded */
|
||||
readonly isLoadingDownloads = this._isLoadingDownloads.asReadonly();
|
||||
|
||||
/** Whether the first download list request has completed */
|
||||
readonly hasLoadedDownloads = this._hasLoadedDownloads.asReadonly();
|
||||
|
||||
/** Whether the download feature is available (Electron only) */
|
||||
readonly isAvailable = computed(() => !!window.electron?.downloadsGetList);
|
||||
|
||||
@@ -98,11 +108,24 @@ export class DownloadsService implements OnDestroy {
|
||||
async loadDownloads(playlistId?: string): Promise<void> {
|
||||
if (!this.isAvailable()) return;
|
||||
|
||||
const requestId = ++this.loadDownloadsRequestId;
|
||||
this._isLoadingDownloads.set(true);
|
||||
|
||||
try {
|
||||
const list = await window.electron.downloadsGetList(playlistId);
|
||||
this.downloads.set(list);
|
||||
if (requestId === this.loadDownloadsRequestId) {
|
||||
this.downloads.set(list);
|
||||
this._hasLoadedDownloads.set(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DownloadsService] Error loading downloads:', error);
|
||||
if (requestId === this.loadDownloadsRequestId) {
|
||||
this._hasLoadedDownloads.set(true);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === this.loadDownloadsRequestId) {
|
||||
this._isLoadingDownloads.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user