Use blurred canvas for thumbnails
This commit is contained in:
parent
038a12931c
commit
408db34135
@ -1292,7 +1292,7 @@ export default class AppMediaViewerBase<
|
|||||||
const size = appPhotosManager.setAttachmentSize(media, container, maxWidth, maxHeight, mediaSizes.isMobile ? false : true, undefined, !!(isDocument && media.w && media.h)).photoSize;
|
const size = appPhotosManager.setAttachmentSize(media, container, maxWidth, maxHeight, mediaSizes.isMobile ? false : true, undefined, !!(isDocument && media.w && media.h)).photoSize;
|
||||||
if(useContainerAsTarget) {
|
if(useContainerAsTarget) {
|
||||||
const cacheContext = appDownloadManager.getCacheContext(media, size.type);
|
const cacheContext = appDownloadManager.getCacheContext(media, size.type);
|
||||||
let img: HTMLImageElement;
|
let img: HTMLImageElement | HTMLCanvasElement;
|
||||||
if(cacheContext.downloaded) {
|
if(cacheContext.downloaded) {
|
||||||
img = new Image();
|
img = new Image();
|
||||||
img.src = cacheContext.url;
|
img.src = cacheContext.url;
|
||||||
|
@ -532,7 +532,7 @@ export default class AudioElement extends HTMLElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if(doc.thumbs?.length) {
|
if(doc.thumbs?.length) {
|
||||||
const imgs: HTMLImageElement[] = [];
|
const imgs: HTMLElement[] = [];
|
||||||
const wrapped = wrapPhoto({
|
const wrapped = wrapPhoto({
|
||||||
photo: doc,
|
photo: doc,
|
||||||
message: null,
|
message: null,
|
||||||
|
@ -425,14 +425,14 @@ export default class AppBackgroundTab extends SliderSuperTab {
|
|||||||
const cacheContext = appDownloadManager.getCacheContext(doc);
|
const cacheContext = appDownloadManager.getCacheContext(doc);
|
||||||
if(background.blur) {
|
if(background.blur) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
blur(cacheContext.url, 12, 4)
|
const {canvas, promise} = blur(cacheContext.url, 12, 4)
|
||||||
.then(url => {
|
promise.then(() => {
|
||||||
if(!middleware()) {
|
if(!middleware()) {
|
||||||
deferred.resolve();
|
deferred.resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onReady(url);
|
onReady(canvas.toDataURL());
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 200);
|
||||||
} else {
|
} else {
|
||||||
|
@ -625,7 +625,7 @@ export function wrapDocument({message, withTime, fontWeight, voiceAsMusic, showS
|
|||||||
if((doc.thumbs?.length || (message.pFlags.is_outgoing && cacheContext.url && doc.type === 'photo'))/* && doc.mime_type !== 'image/gif' */) {
|
if((doc.thumbs?.length || (message.pFlags.is_outgoing && cacheContext.url && doc.type === 'photo'))/* && doc.mime_type !== 'image/gif' */) {
|
||||||
docDiv.classList.add('document-with-thumb');
|
docDiv.classList.add('document-with-thumb');
|
||||||
|
|
||||||
let imgs: HTMLImageElement[] = [];
|
let imgs: (HTMLImageElement | HTMLCanvasElement)[] = [];
|
||||||
// ! WARNING, use thumbs for check when thumb will be generated for media
|
// ! WARNING, use thumbs for check when thumb will be generated for media
|
||||||
if(message.pFlags.is_outgoing && ['photo', 'video'].includes(doc.type)) {
|
if(message.pFlags.is_outgoing && ['photo', 'video'].includes(doc.type)) {
|
||||||
icoDiv.innerHTML = `<img src="${cacheContext.url}">`;
|
icoDiv.innerHTML = `<img src="${cacheContext.url}">`;
|
||||||
@ -887,7 +887,7 @@ export function wrapPhoto({photo, message, container, boxWidth, boxHeight, withT
|
|||||||
|
|
||||||
let isFit = true;
|
let isFit = true;
|
||||||
let loadThumbPromise: Promise<any> = Promise.resolve();
|
let loadThumbPromise: Promise<any> = Promise.resolve();
|
||||||
let thumbImage: HTMLImageElement;
|
let thumbImage: HTMLImageElement | HTMLCanvasElement;
|
||||||
let image: HTMLImageElement;
|
let image: HTMLImageElement;
|
||||||
let cacheContext: ThumbCache;
|
let cacheContext: ThumbCache;
|
||||||
const isGif = photo._ === 'document' && photo.mime_type === 'image/gif' && !size;
|
const isGif = photo._ === 'document' && photo.mime_type === 'image/gif' && !size;
|
||||||
@ -1000,8 +1000,10 @@ export function wrapPhoto({photo, message, container, boxWidth, boxHeight, withT
|
|||||||
if(middleware && !middleware()) return Promise.resolve();
|
if(middleware && !middleware()) return Promise.resolve();
|
||||||
|
|
||||||
if(blurAfter) {
|
if(blurAfter) {
|
||||||
return blur(cacheContext.url, 12).then(url => {
|
const result = blur(cacheContext.url, 12);
|
||||||
return renderOnLoad(url);
|
return result.promise.then(() => {
|
||||||
|
// image = result.canvas;
|
||||||
|
return renderOnLoad(result.canvas.toDataURL());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1069,12 +1071,13 @@ export function wrapPhoto({photo, message, container, boxWidth, boxHeight, withT
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderImageWithFadeIn(container: HTMLElement,
|
export function renderImageWithFadeIn(
|
||||||
|
container: HTMLElement,
|
||||||
image: HTMLImageElement,
|
image: HTMLImageElement,
|
||||||
url: string,
|
url: string,
|
||||||
needFadeIn: boolean,
|
needFadeIn: boolean,
|
||||||
aspecter = container,
|
aspecter = container,
|
||||||
thumbImage?: HTMLImageElement
|
thumbImage?: HTMLElement
|
||||||
) {
|
) {
|
||||||
if(needFadeIn) {
|
if(needFadeIn) {
|
||||||
image.classList.add('fade-in');
|
image.classList.add('fade-in');
|
||||||
|
3
src/environment/canvasFilterSupport.ts
Normal file
3
src/environment/canvasFilterSupport.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const IS_CANVAS_FILTER_SUPPORTED = 'filter' in (document.createElement('canvas').getContext('2d') || {});
|
||||||
|
|
||||||
|
export default IS_CANVAS_FILTER_SUPPORTED;
|
@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
import type fastBlur from '../vendor/fastBlur';
|
import type fastBlur from '../vendor/fastBlur';
|
||||||
import addHeavyTask from './heavyQueue';
|
import addHeavyTask from './heavyQueue';
|
||||||
|
import IS_CANVAS_FILTER_SUPPORTED from '../environment/canvasFilterSupport';
|
||||||
|
|
||||||
const RADIUS = 2;
|
const RADIUS = 2;
|
||||||
const ITERATIONS = 2;
|
const ITERATIONS = 2;
|
||||||
|
|
||||||
const isFilterAvailable = 'filter' in (document.createElement('canvas').getContext('2d') || {});
|
|
||||||
let requireBlurPromise: Promise<any>;
|
let requireBlurPromise: Promise<any>;
|
||||||
let fastBlurFunc: typeof fastBlur;
|
let fastBlurFunc: typeof fastBlur;
|
||||||
if(!isFilterAvailable) {
|
if(!IS_CANVAS_FILTER_SUPPORTED) {
|
||||||
requireBlurPromise = import('../vendor/fastBlur').then(m => {
|
requireBlurPromise = import('../vendor/fastBlur').then(m => {
|
||||||
fastBlurFunc = m.default;
|
fastBlurFunc = m.default;
|
||||||
});
|
});
|
||||||
@ -21,80 +21,80 @@ if(!isFilterAvailable) {
|
|||||||
requireBlurPromise = Promise.resolve();
|
requireBlurPromise = Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
function processBlurNext(img: HTMLImageElement, radius: number, iterations: number) {
|
function processBlurNext(
|
||||||
return new Promise<string>((resolve) => {
|
img: HTMLImageElement,
|
||||||
const canvas = document.createElement('canvas');
|
radius: number,
|
||||||
canvas.width = img.width;
|
iterations: number,
|
||||||
canvas.height = img.height;
|
canvas: HTMLCanvasElement = document.createElement('canvas')
|
||||||
|
) {
|
||||||
const ctx = canvas.getContext('2d', {alpha: false});
|
canvas.width = img.width;
|
||||||
if(isFilterAvailable) {
|
canvas.height = img.height;
|
||||||
ctx.filter = `blur(${radius}px)`;
|
|
||||||
ctx.drawImage(img, -radius * 2, -radius * 2, canvas.width + radius * 4, canvas.height + radius * 4);
|
|
||||||
} else {
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
fastBlurFunc(ctx, 0, 0, canvas.width, canvas.height, radius, iterations);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(canvas.toDataURL());
|
|
||||||
/* if(DEBUG) {
|
|
||||||
console.log(`[blur] end, radius: ${radius}, iterations: ${iterations}, time: ${performance.now() - perf}`);
|
|
||||||
} */
|
|
||||||
|
|
||||||
/* canvas.toBlob(blob => {
|
const ctx = canvas.getContext('2d', {alpha: false});
|
||||||
resolve(URL.createObjectURL(blob));
|
if(IS_CANVAS_FILTER_SUPPORTED) {
|
||||||
|
ctx.filter = `blur(${radius}px)`;
|
||||||
if(DEBUG) {
|
ctx.drawImage(img, -radius * 2, -radius * 2, canvas.width + radius * 4, canvas.height + radius * 4);
|
||||||
console.log(`[blur] end, radius: ${radius}, iterations: ${iterations}, time: ${performance.now() - perf}`);
|
} else {
|
||||||
}
|
ctx.drawImage(img, 0, 0);
|
||||||
}); */
|
fastBlurFunc(ctx, 0, 0, canvas.width, canvas.height, radius, iterations);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blurPromises: Map<string, Promise<string>> = new Map();
|
type CacheValue = {canvas: HTMLCanvasElement, promise: Promise<void>};
|
||||||
const CACHE_SIZE = 1000;
|
const cache: Map<string, CacheValue> = new Map();
|
||||||
|
const CACHE_SIZE = 150;
|
||||||
|
|
||||||
export default function blur(dataUri: string, radius: number = RADIUS, iterations: number = ITERATIONS) {
|
export default function blur(dataUri: string, radius: number = RADIUS, iterations: number = ITERATIONS) {
|
||||||
if(!dataUri) {
|
if(!dataUri) {
|
||||||
console.error('no dataUri for blur', dataUri);
|
throw 'no dataUri for blur: ' + dataUri;
|
||||||
return Promise.resolve(dataUri);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(blurPromises.size > CACHE_SIZE) {
|
if(cache.size > CACHE_SIZE) {
|
||||||
blurPromises.clear();
|
cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'canvas-thumbnail';
|
||||||
|
|
||||||
if(blurPromises.has(dataUri)) return blurPromises.get(dataUri);
|
let cached = cache.get(dataUri);
|
||||||
const promise = new Promise<string>((resolve) => {
|
if(!cached) {
|
||||||
//return resolve(dataUri);
|
const promise: CacheValue['promise'] = new Promise((resolve) => {
|
||||||
requireBlurPromise.then(() => {
|
//return resolve(dataUri);
|
||||||
const img = new Image();
|
requireBlurPromise.then(() => {
|
||||||
img.onload = () => {
|
const img = new Image();
|
||||||
if(isFilterAvailable) {
|
img.onload = () => {
|
||||||
processBlurNext(img, radius, iterations).then(resolve);
|
// if(IS_CANVAS_FILTER_SUPPORTED) {
|
||||||
} else {
|
// resolve(processBlurNext(img, radius, iterations));
|
||||||
addHeavyTask({
|
// } else {
|
||||||
items: [[img, radius, iterations]],
|
const promise = addHeavyTask({
|
||||||
context: null,
|
items: [[img, radius, iterations, canvas]],
|
||||||
process: processBlurNext
|
context: null,
|
||||||
}, 'unshift').then(results => {
|
process: processBlurNext
|
||||||
resolve(results[0]);
|
}, 'unshift');
|
||||||
});
|
|
||||||
}
|
promise.then(() => {
|
||||||
};
|
resolve();
|
||||||
img.src = dataUri;
|
});
|
||||||
|
// }
|
||||||
/* addHeavyTask({
|
};
|
||||||
items: [[dataUri, radius, iterations]],
|
img.src = dataUri;
|
||||||
context: null,
|
});
|
||||||
process: processBlur
|
|
||||||
}, 'unshift').then(results => {
|
|
||||||
resolve(results[0]);
|
|
||||||
}); */
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
cache.set(dataUri, cached = {
|
||||||
|
canvas,
|
||||||
|
promise
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
canvas.width = cached.canvas.width;
|
||||||
|
canvas.height = cached.canvas.height;
|
||||||
|
canvas.getContext('2d').drawImage(cached.canvas, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
blurPromises.set(dataUri, promise);
|
return {
|
||||||
|
...cached,
|
||||||
return promise;
|
canvas
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
3
src/helpers/bytes/bytesToDataURL.ts
Normal file
3
src/helpers/bytes/bytesToDataURL.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default function bytesToDataURL(bytes: Uint8Array, mimeType: string = 'image/jpeg') {
|
||||||
|
return `data:${mimeType};base64,${btoa(String.fromCharCode(...bytes))}`;
|
||||||
|
}
|
@ -17,7 +17,7 @@ const set = (elem: HTMLElement | HTMLImageElement | SVGImageElement | HTMLVideoE
|
|||||||
export default function renderImageFromUrl(
|
export default function renderImageFromUrl(
|
||||||
elem: HTMLElement | HTMLImageElement | SVGImageElement | HTMLVideoElement,
|
elem: HTMLElement | HTMLImageElement | SVGImageElement | HTMLVideoElement,
|
||||||
url: string,
|
url: string,
|
||||||
callback?: (err?: Event) => void,
|
callback?: () => void,
|
||||||
useCache = true
|
useCache = true
|
||||||
) {
|
) {
|
||||||
if(!url) {
|
if(!url) {
|
||||||
@ -61,7 +61,7 @@ export default function renderImageFromUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderImageFromUrlPromise(elem: Parameters<typeof renderImageFromUrl>[0], url: string, useCache?: boolean) {
|
export function renderImageFromUrlPromise(elem: Parameters<typeof renderImageFromUrl>[0], url: string, useCache?: boolean) {
|
||||||
return new Promise<Event>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
renderImageFromUrl(elem, url, resolve, useCache);
|
renderImageFromUrl(elem, url, resolve, useCache);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -7,26 +7,27 @@
|
|||||||
import deferredPromise, { CancellablePromise } from "./cancellablePromise";
|
import deferredPromise, { CancellablePromise } from "./cancellablePromise";
|
||||||
import { getHeavyAnimationPromise } from "../hooks/useHeavyAnimationCheck";
|
import { getHeavyAnimationPromise } from "../hooks/useHeavyAnimationCheck";
|
||||||
import { fastRaf } from "./schedulers";
|
import { fastRaf } from "./schedulers";
|
||||||
|
import { ArgumentTypes } from "../types";
|
||||||
|
|
||||||
type HeavyQueue<T> = {
|
type HeavyQueue<T extends HeavyQueue<any>> = {
|
||||||
items: any[],
|
items: ArgumentTypes<T['process']>[],
|
||||||
process: (...args: any[]) => T,
|
process: (...args: any[]) => ReturnType<T['process']>,
|
||||||
context: any,
|
context: any,
|
||||||
promise?: CancellablePromise<ReturnType<HeavyQueue<T>['process']>[]>
|
promise?: CancellablePromise<ReturnType<T['process']>[]>
|
||||||
};
|
};
|
||||||
const heavyQueue: HeavyQueue<any>[] = [];
|
const heavyQueue: HeavyQueue<any>[] = [];
|
||||||
let processingQueue = false;
|
let processingQueue = false;
|
||||||
|
|
||||||
export default function addHeavyTask<T>(queue: HeavyQueue<T>, method: 'push' | 'unshift' = 'push') {
|
export default function addHeavyTask<T extends HeavyQueue<T>>(queue: T, method: 'push' | 'unshift' = 'push') {
|
||||||
if(!queue.items.length) {
|
if(!queue.items.length) {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]) as typeof promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
queue.promise = deferredPromise<T[]>();
|
const promise = queue.promise = deferredPromise();
|
||||||
heavyQueue[method](queue);
|
heavyQueue[method](queue);
|
||||||
processHeavyQueue();
|
processHeavyQueue();
|
||||||
|
|
||||||
return queue.promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function processHeavyQueue() {
|
function processHeavyQueue() {
|
||||||
@ -41,23 +42,24 @@ function processHeavyQueue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function timedChunk<T>(queue: HeavyQueue<T>) {
|
function timedChunk<T extends HeavyQueue<T>>(queue: HeavyQueue<T>) {
|
||||||
if(!queue.items.length) {
|
if(!queue.items.length) {
|
||||||
queue.promise.resolve([]);
|
queue.promise.resolve([] as any);
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const todo = queue.items.slice();
|
const todo = queue.items.slice();
|
||||||
const results: T[] = [];
|
const results: ReturnType<T['process']>[] = [];
|
||||||
|
|
||||||
return new Promise<T[]>((resolve, reject) => {
|
return new Promise<typeof results>((resolve, reject) => {
|
||||||
const f = async() => {
|
const f = async() => {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
|
|
||||||
do {
|
do {
|
||||||
await getHeavyAnimationPromise();
|
await getHeavyAnimationPromise();
|
||||||
const possiblePromise = queue.process.apply(queue.context, todo.shift());
|
const possiblePromise = queue.process.apply(queue.context, todo.shift());
|
||||||
let realResult: T;
|
let realResult: typeof results[0];
|
||||||
|
// @ts-ignore
|
||||||
if(possiblePromise instanceof Promise) {
|
if(possiblePromise instanceof Promise) {
|
||||||
try {
|
try {
|
||||||
realResult = await possiblePromise;
|
realResult = await possiblePromise;
|
||||||
|
@ -314,8 +314,9 @@ export class AppDocsManager {
|
|||||||
const cacheContext = appDownloadManager.getCacheContext(doc, thumb.type);
|
const cacheContext = appDownloadManager.getCacheContext(doc, thumb.type);
|
||||||
if(!cacheContext.url) {
|
if(!cacheContext.url) {
|
||||||
if('bytes' in thumb) {
|
if('bytes' in thumb) {
|
||||||
promise = blur(appPhotosManager.getPreviewURLFromBytes(thumb.bytes, !!doc.sticker)).then(url => {
|
const result = blur(appPhotosManager.getPreviewURLFromBytes(thumb.bytes, !!doc.sticker));
|
||||||
cacheContext.url = url;
|
promise = result.promise.then(() => {
|
||||||
|
cacheContext.url = result.canvas.toDataURL();
|
||||||
}) as any;
|
}) as any;
|
||||||
} else {
|
} else {
|
||||||
//return this.getFileURL(doc, false, thumb);
|
//return this.getFileURL(doc, false, thumb);
|
||||||
|
@ -28,6 +28,7 @@ import windowSize from "../../helpers/windowSize";
|
|||||||
import bytesFromHex from "../../helpers/bytes/bytesFromHex";
|
import bytesFromHex from "../../helpers/bytes/bytesFromHex";
|
||||||
import isObject from "../../helpers/object/isObject";
|
import isObject from "../../helpers/object/isObject";
|
||||||
import safeReplaceArrayInObject from "../../helpers/object/safeReplaceArrayInObject";
|
import safeReplaceArrayInObject from "../../helpers/object/safeReplaceArrayInObject";
|
||||||
|
import bytesToDataURL from "../../helpers/bytes/bytesToDataURL";
|
||||||
|
|
||||||
export type MyPhoto = Photo.photo;
|
export type MyPhoto = Photo.photo;
|
||||||
|
|
||||||
@ -171,8 +172,7 @@ export class AppPhotosManager {
|
|||||||
mimeType = 'image/jpeg';
|
mimeType = 'image/jpeg';
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob([arr], {type: mimeType});
|
return bytesToDataURL(arr, mimeType);
|
||||||
return URL.createObjectURL(blob);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -210,14 +210,19 @@ export class AppPhotosManager {
|
|||||||
public getImageFromStrippedThumb(photo: MyPhoto | MyDocument, thumb: PhotoSize.photoCachedSize | PhotoSize.photoStrippedSize, useBlur: boolean) {
|
public getImageFromStrippedThumb(photo: MyPhoto | MyDocument, thumb: PhotoSize.photoCachedSize | PhotoSize.photoStrippedSize, useBlur: boolean) {
|
||||||
const url = this.getPreviewURLFromThumb(photo, thumb, false);
|
const url = this.getPreviewURLFromThumb(photo, thumb, false);
|
||||||
|
|
||||||
const image = new Image();
|
let element: HTMLImageElement | HTMLCanvasElement, loadPromise: Promise<void>;
|
||||||
image.classList.add('thumbnail');
|
if(!useBlur) {
|
||||||
|
element = new Image();
|
||||||
|
loadPromise = renderImageFromUrlPromise(element, url);
|
||||||
|
} else {
|
||||||
|
const result = blur(url);
|
||||||
|
element = result.canvas;
|
||||||
|
loadPromise = result.promise;
|
||||||
|
}
|
||||||
|
|
||||||
const loadPromise = (useBlur ? blur(url) : Promise.resolve(url)).then(url => {
|
element.classList.add('thumbnail');
|
||||||
return renderImageFromUrlPromise(image, url);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {image, loadPromise};
|
return {image: element, loadPromise};
|
||||||
}
|
}
|
||||||
|
|
||||||
public setAttachmentSize(
|
public setAttachmentSize(
|
||||||
|
@ -1213,6 +1213,12 @@ middle-ellipsis-element {
|
|||||||
fill: rgba(0, 0, 0, .08);
|
fill: rgba(0, 0, 0, .08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canvas-thumbnail {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.media-photo,
|
.media-photo,
|
||||||
.media-video,
|
.media-video,
|
||||||
.media-sticker,
|
.media-sticker,
|
||||||
|
Loading…
Reference in New Issue
Block a user