feat: move avatar downloading to JobRunner
parent
9cf1419ca5
commit
08a15b210a
@ -1,164 +0,0 @@
|
||||
import Queue from 'queue-promise';
|
||||
import ByteBuffer from 'bytebuffer';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { downloadAttachment } from './attachments';
|
||||
|
||||
import { allowOnlyOneAtATime, hasAlreadyOneAtaTimeMatching } from '../session/utils/Promise';
|
||||
import { toHex } from '../session/utils/String';
|
||||
import { processNewAttachment } from '../types/MessageAttachment';
|
||||
import { MIME } from '../types';
|
||||
import { autoScaleForIncomingAvatar } from '../util/attachmentsUtil';
|
||||
import { decryptProfile } from '../util/crypto/profileEncrypter';
|
||||
import { SignalService } from '../protobuf';
|
||||
import { getConversationController } from '../session/conversations';
|
||||
import { UserUtils } from '../session/utils';
|
||||
|
||||
const queue = new Queue({
|
||||
concurrent: 1,
|
||||
interval: 500,
|
||||
});
|
||||
|
||||
queue.on('reject', error => {
|
||||
window.log.warn('[profileupdate] task profile image update failed with', error);
|
||||
});
|
||||
|
||||
export async function appendFetchAvatarAndProfileJob(
|
||||
conversationId: string,
|
||||
profileInDataMessage: SignalService.DataMessage.ILokiProfile,
|
||||
profileKey?: Uint8Array | null
|
||||
) {
|
||||
if (!conversationId) {
|
||||
window?.log?.warn('[profileupdate] Cannot update profile with empty convoid');
|
||||
return;
|
||||
}
|
||||
const oneAtaTimeStr = `appendFetchAvatarAndProfileJob:${conversationId}`;
|
||||
|
||||
if (hasAlreadyOneAtaTimeMatching(oneAtaTimeStr)) {
|
||||
return;
|
||||
}
|
||||
const task = allowOnlyOneAtATime(oneAtaTimeStr, async () => {
|
||||
return createOrUpdateProfile(conversationId, profileInDataMessage, profileKey);
|
||||
});
|
||||
|
||||
queue.enqueue(async () => task);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function should be used only when we have to do a sync update to our conversation with a new profile/avatar image or display name
|
||||
* It tries to fetch the profile image, scale it, save it, and update the conversationModel
|
||||
*/
|
||||
export async function updateOurProfileSync(
|
||||
profileInDataMessage: SignalService.DataMessage.ILokiProfile,
|
||||
profileKey?: Uint8Array | null
|
||||
) {
|
||||
const ourConvo = getConversationController().get(UserUtils.getOurPubKeyStrFromCache());
|
||||
if (!ourConvo?.id) {
|
||||
window?.log?.warn('[profileupdate] Cannot update our profile with empty convoid');
|
||||
return;
|
||||
}
|
||||
|
||||
const oneAtaTimeStr = `appendFetchAvatarAndProfileJob:${ourConvo.id}`;
|
||||
return allowOnlyOneAtATime(oneAtaTimeStr, async () => {
|
||||
return createOrUpdateProfile(ourConvo.id, profileInDataMessage, profileKey);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new profile from the profile provided. Creates the profile if it doesn't exist.
|
||||
*/
|
||||
async function createOrUpdateProfile(
|
||||
conversationId: string,
|
||||
profileInDataMessage: SignalService.DataMessage.ILokiProfile,
|
||||
profileKey?: Uint8Array | null
|
||||
) {
|
||||
const conversation = getConversationController().get(conversationId);
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
if (!conversation.isPrivate()) {
|
||||
window.log.warn('createOrUpdateProfile can only be used for private convos');
|
||||
return;
|
||||
}
|
||||
|
||||
const existingDisplayName = conversation.get('displayNameInProfile');
|
||||
const newDisplayName = profileInDataMessage.displayName;
|
||||
|
||||
let changes = false;
|
||||
if (existingDisplayName !== newDisplayName) {
|
||||
changes = true;
|
||||
conversation.set('displayNameInProfile', newDisplayName || undefined);
|
||||
}
|
||||
|
||||
if (profileInDataMessage.profilePicture && profileKey) {
|
||||
const prevPointer = conversation.get('avatarPointer');
|
||||
const needsUpdate =
|
||||
!prevPointer || !_.isEqual(prevPointer, profileInDataMessage.profilePicture);
|
||||
|
||||
if (needsUpdate) {
|
||||
try {
|
||||
window.log.debug(`[profileupdate] starting downloading task for ${conversation.id}`);
|
||||
const downloaded = await downloadAttachment({
|
||||
url: profileInDataMessage.profilePicture,
|
||||
isRaw: true,
|
||||
});
|
||||
|
||||
// null => use placeholder with color and first letter
|
||||
let path = null;
|
||||
if (profileKey) {
|
||||
// Convert profileKey to ArrayBuffer, if needed
|
||||
const encoding = typeof profileKey === 'string' ? 'base64' : undefined;
|
||||
try {
|
||||
const profileKeyArrayBuffer = ByteBuffer.wrap(profileKey, encoding).toArrayBuffer();
|
||||
const decryptedData = await decryptProfile(downloaded.data, profileKeyArrayBuffer);
|
||||
window.log.info(
|
||||
`[profileupdate] about to auto scale avatar for convo ${conversation.id}`
|
||||
);
|
||||
|
||||
const scaledData = await autoScaleForIncomingAvatar(decryptedData);
|
||||
const upgraded = await processNewAttachment({
|
||||
data: await scaledData.blob.arrayBuffer(),
|
||||
contentType: MIME.IMAGE_UNKNOWN, // contentType is mostly used to generate previews and screenshot. We do not care for those in this case.
|
||||
});
|
||||
// Only update the convo if the download and decrypt is a success
|
||||
conversation.set('avatarPointer', profileInDataMessage.profilePicture);
|
||||
conversation.set('profileKey', toHex(profileKey));
|
||||
({ path } = upgraded);
|
||||
} catch (e) {
|
||||
window?.log?.error(`[profileupdate] Could not decrypt profile image: ${e}`);
|
||||
}
|
||||
}
|
||||
conversation.set({ avatarInProfile: path || undefined });
|
||||
|
||||
changes = true;
|
||||
} catch (e) {
|
||||
window.log.warn(
|
||||
`[profileupdate] Failed to download attachment at ${profileInDataMessage.profilePicture}. Maybe it expired? ${e.message}`
|
||||
);
|
||||
// do not return here, we still want to update the display name even if the avatar failed to download
|
||||
}
|
||||
}
|
||||
} else if (profileKey) {
|
||||
conversation.set({ avatarInProfile: undefined });
|
||||
}
|
||||
|
||||
if (conversation.id === UserUtils.getOurPubKeyStrFromCache()) {
|
||||
// make sure the settings which should already set to `true` are
|
||||
if (
|
||||
!conversation.get('isTrustedForAttachmentDownload') ||
|
||||
!conversation.get('isApproved') ||
|
||||
!conversation.get('didApproveMe')
|
||||
) {
|
||||
conversation.set({
|
||||
isTrustedForAttachmentDownload: true,
|
||||
isApproved: true,
|
||||
didApproveMe: true,
|
||||
});
|
||||
changes = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changes) {
|
||||
await conversation.commit();
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
import { to_hex } from 'libsodium-wrappers-sumo';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { getConversationController } from '../conversations';
|
||||
import { UserUtils } from '../utils';
|
||||
import { runners } from '../utils/job_runners/JobRunner';
|
||||
import {
|
||||
AvatarDownloadJob,
|
||||
shouldAddAvatarDownloadJob,
|
||||
} from '../utils/job_runners/jobs/AvatarDownloadJob';
|
||||
|
||||
/**
|
||||
* This can be used to update our conversation display name with the given name right away, and plan an AvatarDownloadJob to retrieve the new avatar if needed to download it
|
||||
*/
|
||||
async function updateOurProfileSync(
|
||||
displayName: string | undefined,
|
||||
profileUrl: string | null,
|
||||
profileKey: Uint8Array | null
|
||||
) {
|
||||
const ourConvo = getConversationController().get(UserUtils.getOurPubKeyStrFromCache());
|
||||
if (!ourConvo?.id) {
|
||||
window?.log?.warn('[profileupdate] Cannot update our profile with empty convoid');
|
||||
return;
|
||||
}
|
||||
|
||||
return updateProfileOfContact(
|
||||
UserUtils.getOurPubKeyStrFromCache(),
|
||||
displayName,
|
||||
profileUrl,
|
||||
profileKey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This can be used to update the display name of the given pubkey right away, and plan an AvatarDownloadJob to retrieve the new avatar if needed to download it.
|
||||
*/
|
||||
async function updateProfileOfContact(
|
||||
pubkey: string,
|
||||
displayName: string | null | undefined,
|
||||
profileUrl: string | null | undefined,
|
||||
profileKey: Uint8Array | null | undefined
|
||||
) {
|
||||
const conversation = getConversationController().get(pubkey);
|
||||
|
||||
if (!conversation || !conversation.isPrivate()) {
|
||||
window.log.warn('updateProfileOfContact can only be used for existing and private convos');
|
||||
return;
|
||||
}
|
||||
|
||||
const existingDisplayName = conversation.get('displayNameInProfile');
|
||||
|
||||
// avoid setting the display name to an invalid value
|
||||
if (existingDisplayName !== displayName && !isEmpty(displayName)) {
|
||||
conversation.set('displayNameInProfile', displayName || undefined);
|
||||
await conversation.commit();
|
||||
}
|
||||
// add an avatar download job only if needed
|
||||
|
||||
const profileKeyHex = !profileKey || isEmpty(profileKey) ? null : to_hex(profileKey);
|
||||
if (shouldAddAvatarDownloadJob({ pubkey, profileUrl, profileKeyHex })) {
|
||||
const avatarDownloadJob = new AvatarDownloadJob({
|
||||
conversationId: pubkey,
|
||||
profileKeyHex,
|
||||
profilePictureUrl: profileUrl || null,
|
||||
});
|
||||
|
||||
await runners.avatarDownloadRunner.addJob(avatarDownloadJob);
|
||||
}
|
||||
}
|
||||
|
||||
export const ProfileManager = {
|
||||
updateOurProfileSync,
|
||||
updateProfileOfContact,
|
||||
};
|
@ -0,0 +1,233 @@
|
||||
import { isEmpty, isEqual, isNumber } from 'lodash';
|
||||
import { v4 } from 'uuid';
|
||||
import { UserUtils } from '../..';
|
||||
import { downloadAttachment } from '../../../../receiver/attachments';
|
||||
import { MIME } from '../../../../types';
|
||||
import { processNewAttachment } from '../../../../types/MessageAttachment';
|
||||
import { autoScaleForIncomingAvatar } from '../../../../util/attachmentsUtil';
|
||||
import { decryptProfile } from '../../../../util/crypto/profileEncrypter';
|
||||
import { getConversationController } from '../../../conversations';
|
||||
import { fromHexToArray } from '../../String';
|
||||
import { AvatarDownloadPersistedData, PersistedJob } from '../PersistedJob';
|
||||
|
||||
const defaultMsBetweenRetries = 10000;
|
||||
const defaultMaxAttemps = 3;
|
||||
|
||||
/**
|
||||
* Returns true if given those details we should add an Avatar Download Job to the list of jobs to run
|
||||
*/
|
||||
export function shouldAddAvatarDownloadJob({
|
||||
profileKeyHex,
|
||||
profileUrl,
|
||||
pubkey,
|
||||
}: {
|
||||
pubkey: string;
|
||||
profileUrl: string | null | undefined;
|
||||
profileKeyHex: string | null | undefined;
|
||||
}) {
|
||||
const conversation = getConversationController().get(pubkey);
|
||||
if (!conversation) {
|
||||
// return true so we do not retry this task.
|
||||
window.log.warn('shouldAddAvatarDownloadJob did not corresponding conversation');
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!conversation.isPrivate()) {
|
||||
window.log.warn('shouldAddAvatarDownloadJob can only be used for private convos currently');
|
||||
return false;
|
||||
}
|
||||
if (profileUrl && !isEmpty(profileKeyHex)) {
|
||||
const prevPointer = conversation.get('avatarPointer');
|
||||
const needsUpdate = !prevPointer || !isEqual(prevPointer, profileUrl);
|
||||
|
||||
return needsUpdate;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* This job can be used to add the downloading of the avatar of a conversation to the list of jobs to be run.
|
||||
* The conversationId is used as identifier so we can only have a single job per conversation.
|
||||
* When the jobRunners starts this job, the job first checks if a download is required or not (avatarPointer changed and wasn't already downloaded).
|
||||
* If yes, it downloads the new avatar, decrypt it and store it before updating the conversation with the new url,profilekey and local file storage.
|
||||
*/
|
||||
export class AvatarDownloadJob extends PersistedJob<AvatarDownloadPersistedData> {
|
||||
constructor({
|
||||
conversationId,
|
||||
nextAttemptTimestamp,
|
||||
maxAttempts,
|
||||
currentRetry,
|
||||
profileKeyHex,
|
||||
profilePictureUrl,
|
||||
identifier,
|
||||
}: Pick<AvatarDownloadPersistedData, 'profileKeyHex' | 'profilePictureUrl'> & {
|
||||
conversationId: string;
|
||||
} & Partial<
|
||||
Pick<
|
||||
AvatarDownloadPersistedData,
|
||||
| 'nextAttemptTimestamp'
|
||||
| 'identifier'
|
||||
| 'maxAttempts'
|
||||
| 'delayBetweenRetries'
|
||||
| 'currentRetry'
|
||||
>
|
||||
>) {
|
||||
super({
|
||||
jobType: 'AvatarDownloadJobType',
|
||||
identifier: identifier || v4(),
|
||||
conversationId,
|
||||
delayBetweenRetries: defaultMsBetweenRetries,
|
||||
maxAttempts: isNumber(maxAttempts) ? maxAttempts : defaultMaxAttemps,
|
||||
nextAttemptTimestamp: nextAttemptTimestamp || Date.now() + defaultMsBetweenRetries,
|
||||
currentRetry: isNumber(currentRetry) ? currentRetry : 0,
|
||||
profileKeyHex,
|
||||
profilePictureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// tslint:disable-next-line: cyclomatic-complexity
|
||||
public async run(): Promise<boolean> {
|
||||
const convoId = this.persistedData.conversationId;
|
||||
|
||||
window.log.warn(
|
||||
`running job ${this.persistedData.jobType} with conversationId:"${convoId}" id:"${this.persistedData.identifier}" `
|
||||
);
|
||||
|
||||
if (!this.persistedData.identifier || !convoId) {
|
||||
// return true so we do not retry this task.
|
||||
return true;
|
||||
}
|
||||
|
||||
let conversation = getConversationController().get(convoId);
|
||||
if (!conversation) {
|
||||
// return true so we do not retry this task.
|
||||
window.log.warn('AvatarDownloadJob did not corresponding conversation');
|
||||
|
||||
return true;
|
||||
}
|
||||
if (!conversation.isPrivate()) {
|
||||
window.log.warn('AvatarDownloadJob can only be used for private convos currently');
|
||||
return true;
|
||||
}
|
||||
let changes = false;
|
||||
|
||||
const shouldRunJob = shouldAddAvatarDownloadJob({
|
||||
pubkey: convoId,
|
||||
profileKeyHex: this.persistedData.profileKeyHex,
|
||||
profileUrl: this.persistedData.profilePictureUrl,
|
||||
});
|
||||
if (!shouldRunJob) {
|
||||
// return true so we do not retry this task.
|
||||
window.log.warn('AvatarDownloadJob shouldAddAvatarDownloadJob said no');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.persistedData.profilePictureUrl && this.persistedData.profileKeyHex) {
|
||||
const prevPointer = conversation.get('avatarPointer');
|
||||
const needsUpdate =
|
||||
!prevPointer || !isEqual(prevPointer, this.persistedData.profilePictureUrl);
|
||||
|
||||
if (needsUpdate) {
|
||||
try {
|
||||
window.log.debug(`[profileupdate] starting downloading task for ${conversation.id}`);
|
||||
const downloaded = await downloadAttachment({
|
||||
url: this.persistedData.profilePictureUrl,
|
||||
isRaw: true,
|
||||
});
|
||||
conversation = getConversationController().getOrThrow(convoId);
|
||||
|
||||
// null => use placeholder with color and first letter
|
||||
let path = null;
|
||||
|
||||
try {
|
||||
const profileKeyArrayBuffer = fromHexToArray(this.persistedData.profileKeyHex);
|
||||
const decryptedData = await decryptProfile(downloaded.data, profileKeyArrayBuffer);
|
||||
|
||||
window.log.info(
|
||||
`[profileupdate] about to auto scale avatar for convo ${conversation.id}`
|
||||
);
|
||||
|
||||
const scaledData = await autoScaleForIncomingAvatar(decryptedData);
|
||||
|
||||
const upgraded = await processNewAttachment({
|
||||
data: await scaledData.blob.arrayBuffer(),
|
||||
contentType: MIME.IMAGE_UNKNOWN, // contentType is mostly used to generate previews and screenshot. We do not care for those in this case.
|
||||
});
|
||||
conversation = getConversationController().getOrThrow(convoId);
|
||||
|
||||
// Only update the convo if the download and decrypt is a success
|
||||
conversation.set('avatarPointer', this.persistedData.profilePictureUrl);
|
||||
conversation.set('profileKey', this.persistedData.profileKeyHex || undefined);
|
||||
({ path } = upgraded);
|
||||
} catch (e) {
|
||||
window?.log?.error(`[profileupdate] Could not decrypt profile image: ${e}`);
|
||||
}
|
||||
|
||||
conversation.set({ avatarInProfile: path || undefined });
|
||||
|
||||
changes = true;
|
||||
} catch (e) {
|
||||
window.log.warn(
|
||||
`[profileupdate] Failed to download attachment at ${this.persistedData.profilePictureUrl}. Maybe it expired? ${e.message}`
|
||||
);
|
||||
// do not return here, we still want to update the display name even if the avatar failed to download
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
conversation.get('avatarInProfile') ||
|
||||
conversation.get('avatarPointer') ||
|
||||
conversation.get('profileKey')
|
||||
) {
|
||||
changes = true;
|
||||
conversation.set({
|
||||
avatarInProfile: undefined,
|
||||
avatarPointer: undefined,
|
||||
profileKey: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (conversation.id === UserUtils.getOurPubKeyStrFromCache()) {
|
||||
// make sure the settings which should already set to `true` are
|
||||
if (
|
||||
!conversation.get('isTrustedForAttachmentDownload') ||
|
||||
!conversation.get('isApproved') ||
|
||||
!conversation.get('didApproveMe')
|
||||
) {
|
||||
conversation.set({
|
||||
isTrustedForAttachmentDownload: true,
|
||||
isApproved: true,
|
||||
didApproveMe: true,
|
||||
});
|
||||
changes = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changes) {
|
||||
await conversation.commit();
|
||||
}
|
||||
|
||||
// return true so this job is marked as a success
|
||||
return true;
|
||||
}
|
||||
|
||||
public serializeJob(): AvatarDownloadPersistedData {
|
||||
return super.serializeBase();
|
||||
}
|
||||
|
||||
public nonRunningJobsToRemove(jobs: Array<AvatarDownloadPersistedData>) {
|
||||
// for an avatar download job, we want to remove any job matching the same conversationID.
|
||||
return jobs.filter(j => j.conversationId === this.persistedData.conversationId);
|
||||
}
|
||||
|
||||
public addJobCheck(
|
||||
jobs: Array<AvatarDownloadPersistedData>
|
||||
): 'skipAsJobTypeAlreadyPresent' | 'removeJobsFromQueue' | null {
|
||||
if (this.nonRunningJobsToRemove(jobs).length) {
|
||||
return 'removeJobsFromQueue';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@ -1,45 +1,58 @@
|
||||
import { isNumber } from 'lodash';
|
||||
import { v4 } from 'uuid';
|
||||
import { sleepFor } from '../../Promise';
|
||||
import { Persistedjob, SerializedPersistedJob } from '../PersistedJob';
|
||||
import { ConfigurationSyncPersistedData, PersistedJob } from '../PersistedJob';
|
||||
|
||||
export class ConfigurationSyncJob extends Persistedjob {
|
||||
const defaultMsBetweenRetries = 3000;
|
||||
|
||||
export class ConfigurationSyncJob extends PersistedJob<ConfigurationSyncPersistedData> {
|
||||
constructor({
|
||||
identifier,
|
||||
nextAttemptTimestamp,
|
||||
maxAttempts,
|
||||
currentRetry,
|
||||
}: {
|
||||
identifier: string | null;
|
||||
nextAttemptTimestamp: number | null;
|
||||
maxAttempts: number | null;
|
||||
currentRetry: number;
|
||||
}) {
|
||||
}: Pick<ConfigurationSyncPersistedData, 'identifier' | 'currentRetry' | 'maxAttempts'> &
|
||||
Partial<Pick<ConfigurationSyncPersistedData, 'nextAttemptTimestamp'>>) {
|
||||
super({
|
||||
jobType: 'ConfigurationSyncJobType',
|
||||
identifier: identifier || v4(),
|
||||
delayBetweenRetries: 3000,
|
||||
maxAttempts: isNumber(maxAttempts) ? maxAttempts : 3,
|
||||
nextAttemptTimestamp: nextAttemptTimestamp || Date.now() + 3000,
|
||||
singleJobInQueue: true,
|
||||
delayBetweenRetries: defaultMsBetweenRetries,
|
||||
maxAttempts: maxAttempts,
|
||||
nextAttemptTimestamp: nextAttemptTimestamp || Date.now() + defaultMsBetweenRetries,
|
||||
currentRetry,
|
||||
});
|
||||
}
|
||||
|
||||
public async run() {
|
||||
// blablha do everything from the notion page, and if success, return true.
|
||||
window.log.warn(`running job ${this.jobType} with id:"${this.identifier}" `);
|
||||
window.log.warn(
|
||||
`running job ${this.persistedData.jobType} with id:"${this.persistedData.identifier}" `
|
||||
);
|
||||
|
||||
await sleepFor(5000);
|
||||
window.log.warn(
|
||||
`running job ${this.jobType} with id:"${this.identifier}" done and returning failed `
|
||||
`running job ${this.persistedData.jobType} with id:"${this.persistedData.identifier}" done and returning failed `
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public serializeJob(): SerializedPersistedJob {
|
||||
public serializeJob(): ConfigurationSyncPersistedData {
|
||||
const fromParent = super.serializeBase();
|
||||
return fromParent;
|
||||
}
|
||||
|
||||
public addJobCheck(
|
||||
jobs: Array<ConfigurationSyncPersistedData>
|
||||
): 'skipAsJobTypeAlreadyPresent' | 'removeJobsFromQueue' | null {
|
||||
return this.addJobCheckSameTypePresent(jobs);
|
||||
}
|
||||
|
||||
/**
|
||||
* For the SharedConfig job, we do not care about the jobs already in the list.
|
||||
* We never want to add a new sync configuration job if there is already one in the queue.
|
||||
* This is done by the `addJobCheck` method above
|
||||
*/
|
||||
public nonRunningJobsToRemove(_jobs: Array<ConfigurationSyncPersistedData>) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
@ -1 +1,5 @@
|
||||
export type JobRunnerType = 'ConfigurationSyncJob' | 'FakeSleepForJob';
|
||||
export type JobRunnerType =
|
||||
| 'ConfigurationSyncJob'
|
||||
| 'FakeSleepForJob'
|
||||
| 'FakeSleepForMultiJob'
|
||||
| 'AvatarDownloadJob';
|
||||
|
Loading…
Reference in New Issue