Cache some volatile conversation properties
This commit is contained in:
parent
ba79595563
commit
f92f81dfd6
6 changed files with 157 additions and 92 deletions
|
@ -18,12 +18,13 @@ import type {
|
||||||
ConversationAttributesType,
|
ConversationAttributesType,
|
||||||
ConversationLastProfileType,
|
ConversationLastProfileType,
|
||||||
ConversationRenderInfoType,
|
ConversationRenderInfoType,
|
||||||
LastMessageStatus,
|
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
QuotedMessageType,
|
QuotedMessageType,
|
||||||
SenderKeyInfoType,
|
SenderKeyInfoType,
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
import { drop } from '../util/drop';
|
import { drop } from '../util/drop';
|
||||||
|
import { isShallowEqual } from '../util/isShallowEqual';
|
||||||
|
import { memoizeByThis } from '../util/memoizeByThis';
|
||||||
import { getInitials } from '../util/getInitials';
|
import { getInitials } from '../util/getInitials';
|
||||||
import { normalizeUuid } from '../util/normalizeUuid';
|
import { normalizeUuid } from '../util/normalizeUuid';
|
||||||
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
||||||
|
@ -48,7 +49,10 @@ import type {
|
||||||
CallbackResultType,
|
CallbackResultType,
|
||||||
PniSignatureMessageType,
|
PniSignatureMessageType,
|
||||||
} from '../textsecure/Types.d';
|
} from '../textsecure/Types.d';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type {
|
||||||
|
ConversationType,
|
||||||
|
LastMessageType,
|
||||||
|
} from '../state/ducks/conversations';
|
||||||
import type {
|
import type {
|
||||||
AvatarColorType,
|
AvatarColorType,
|
||||||
ConversationColorType,
|
ConversationColorType,
|
||||||
|
@ -78,7 +82,7 @@ import {
|
||||||
deriveAccessKey,
|
deriveAccessKey,
|
||||||
} from '../Crypto';
|
} from '../Crypto';
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import type { BodyRangesType } from '../types/Util';
|
import type { BodyRangesType, DraftBodyRangesType } from '../types/Util';
|
||||||
import { getTextWithMentions } from '../util/getTextWithMentions';
|
import { getTextWithMentions } from '../util/getTextWithMentions';
|
||||||
import { migrateColor } from '../util/migrateColor';
|
import { migrateColor } from '../util/migrateColor';
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
|
@ -155,6 +159,9 @@ import { isMemberRequestingToJoin } from '../util/isMemberRequestingToJoin';
|
||||||
import { removePendingMember } from '../util/removePendingMember';
|
import { removePendingMember } from '../util/removePendingMember';
|
||||||
import { isMemberPending } from '../util/isMemberPending';
|
import { isMemberPending } from '../util/isMemberPending';
|
||||||
|
|
||||||
|
const EMPTY_ARRAY: Readonly<[]> = [];
|
||||||
|
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
window.Whisper = window.Whisper || {};
|
window.Whisper = window.Whisper || {};
|
||||||
|
|
||||||
|
@ -1722,7 +1729,15 @@ export class ConversationModel extends window.Backbone
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.cachedProps = this.getProps();
|
const { oldCachedProps } = this;
|
||||||
|
const newCachedProps = this.getProps();
|
||||||
|
|
||||||
|
if (oldCachedProps && isShallowEqual(oldCachedProps, newCachedProps)) {
|
||||||
|
this.cachedProps = oldCachedProps;
|
||||||
|
} else {
|
||||||
|
this.cachedProps = newCachedProps;
|
||||||
|
}
|
||||||
|
|
||||||
return this.cachedProps;
|
return this.cachedProps;
|
||||||
} finally {
|
} finally {
|
||||||
this.format = oldFormat;
|
this.format = oldFormat;
|
||||||
|
@ -1739,30 +1754,6 @@ export class ConversationModel extends window.Backbone
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const color = this.getColor()!;
|
const color = this.getColor()!;
|
||||||
|
|
||||||
let lastMessage:
|
|
||||||
| undefined
|
|
||||||
| {
|
|
||||||
status?: LastMessageStatus;
|
|
||||||
text: string;
|
|
||||||
author?: string;
|
|
||||||
deletedForEveryone: false;
|
|
||||||
}
|
|
||||||
| { deletedForEveryone: true };
|
|
||||||
|
|
||||||
if (this.get('lastMessageDeletedForEveryone')) {
|
|
||||||
lastMessage = { deletedForEveryone: true };
|
|
||||||
} else {
|
|
||||||
const lastMessageText = this.get('lastMessage');
|
|
||||||
if (lastMessageText) {
|
|
||||||
lastMessage = {
|
|
||||||
status: dropNull(this.get('lastMessageStatus')),
|
|
||||||
text: lastMessageText,
|
|
||||||
author: dropNull(this.get('lastMessageAuthor')),
|
|
||||||
deletedForEveryone: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const typingValues = Object.values(this.contactTypingTimers || {});
|
const typingValues = Object.values(this.contactTypingTimers || {});
|
||||||
const typingMostRecent = head(sortBy(typingValues, 'timestamp'));
|
const typingMostRecent = head(sortBy(typingValues, 'timestamp'));
|
||||||
|
|
||||||
|
@ -1770,11 +1761,10 @@ export class ConversationModel extends window.Backbone
|
||||||
const timestamp = this.get('timestamp')!;
|
const timestamp = this.get('timestamp')!;
|
||||||
const draftTimestamp = this.get('draftTimestamp');
|
const draftTimestamp = this.get('draftTimestamp');
|
||||||
const draftPreview = this.getDraftPreview();
|
const draftPreview = this.getDraftPreview();
|
||||||
const draftText = this.get('draft');
|
const draftText = dropNull(this.get('draft'));
|
||||||
const draftBodyRanges = this.get('draftBodyRanges');
|
const shouldShowDraft = Boolean(
|
||||||
const shouldShowDraft = (this.hasDraft() &&
|
this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp
|
||||||
draftTimestamp &&
|
);
|
||||||
draftTimestamp >= timestamp) as boolean;
|
|
||||||
const inboxPosition = this.get('inbox_position');
|
const inboxPosition = this.get('inbox_position');
|
||||||
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
|
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
|
||||||
'desktop.messageRequests'
|
'desktop.messageRequests'
|
||||||
|
@ -1831,7 +1821,7 @@ export class ConversationModel extends window.Backbone
|
||||||
),
|
),
|
||||||
areWeAdmin: this.areWeAdmin(),
|
areWeAdmin: this.areWeAdmin(),
|
||||||
avatars: getAvatarData(this.attributes),
|
avatars: getAvatarData(this.attributes),
|
||||||
badges: this.get('badges') || [],
|
badges: this.get('badges') ?? EMPTY_ARRAY,
|
||||||
canChangeTimer: this.canChangeTimer(),
|
canChangeTimer: this.canChangeTimer(),
|
||||||
canEditGroupInfo: this.canEditGroupInfo(),
|
canEditGroupInfo: this.canEditGroupInfo(),
|
||||||
canAddNewMembers: this.canAddNewMembers(),
|
canAddNewMembers: this.canAddNewMembers(),
|
||||||
|
@ -1844,7 +1834,7 @@ export class ConversationModel extends window.Backbone
|
||||||
customColor,
|
customColor,
|
||||||
customColorId,
|
customColorId,
|
||||||
discoveredUnregisteredAt: this.get('discoveredUnregisteredAt'),
|
discoveredUnregisteredAt: this.get('discoveredUnregisteredAt'),
|
||||||
draftBodyRanges,
|
draftBodyRanges: this.getDraftBodyRanges(),
|
||||||
draftPreview,
|
draftPreview,
|
||||||
draftText,
|
draftText,
|
||||||
familyName: this.get('profileFamilyName'),
|
familyName: this.get('profileFamilyName'),
|
||||||
|
@ -1863,14 +1853,14 @@ export class ConversationModel extends window.Backbone
|
||||||
isUntrusted: this.isUntrusted(),
|
isUntrusted: this.isUntrusted(),
|
||||||
isVerified: this.isVerified(),
|
isVerified: this.isVerified(),
|
||||||
isFetchingUUID: this.isFetchingUUID,
|
isFetchingUUID: this.isFetchingUUID,
|
||||||
lastMessage,
|
lastMessage: this.getLastMessage(),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
lastUpdated: this.get('timestamp')!,
|
lastUpdated: this.get('timestamp')!,
|
||||||
left: Boolean(this.get('left')),
|
left: Boolean(this.get('left')),
|
||||||
markedUnread: this.get('markedUnread'),
|
markedUnread: this.get('markedUnread'),
|
||||||
membersCount: this.getMembersCount(),
|
membersCount: this.getMembersCount(),
|
||||||
memberships: this.getMemberships(),
|
memberships: this.getMemberships(),
|
||||||
messageCount: this.get('messageCount') || 0,
|
hasMessages: (this.get('messageCount') ?? 0) > 0,
|
||||||
pendingMemberships: this.getPendingMemberships(),
|
pendingMemberships: this.getPendingMemberships(),
|
||||||
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
|
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
|
||||||
bannedMemberships: this.getBannedMemberships(),
|
bannedMemberships: this.getBannedMemberships(),
|
||||||
|
@ -1906,13 +1896,14 @@ export class ConversationModel extends window.Backbone
|
||||||
...(isDirectConversation(this.attributes)
|
...(isDirectConversation(this.attributes)
|
||||||
? {
|
? {
|
||||||
type: 'direct' as const,
|
type: 'direct' as const,
|
||||||
sharedGroupNames: this.get('sharedGroupNames') || [],
|
sharedGroupNames: this.get('sharedGroupNames') || EMPTY_ARRAY,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
type: 'group' as const,
|
type: 'group' as const,
|
||||||
acknowledgedGroupNameCollisions:
|
acknowledgedGroupNameCollisions:
|
||||||
this.get('acknowledgedGroupNameCollisions') || {},
|
this.get('acknowledgedGroupNameCollisions') ||
|
||||||
sharedGroupNames: [],
|
EMPTY_GROUP_COLLISIONS,
|
||||||
|
sharedGroupNames: EMPTY_ARRAY,
|
||||||
storySendMode: this.getGroupStorySendMode(),
|
storySendMode: this.getGroupStorySendMode(),
|
||||||
}),
|
}),
|
||||||
voiceNotePlaybackRate: this.get('voiceNotePlaybackRate'),
|
voiceNotePlaybackRate: this.get('voiceNotePlaybackRate'),
|
||||||
|
@ -3611,20 +3602,44 @@ export class ConversationModel extends window.Backbone
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMemberships(): Array<{
|
private getDraftBodyRanges = memoizeByThis(
|
||||||
uuid: UUIDStringType;
|
(): DraftBodyRangesType | undefined => {
|
||||||
isAdmin: boolean;
|
return this.get('draftBodyRanges');
|
||||||
}> {
|
|
||||||
if (!isGroupV2(this.attributes)) {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const members = this.get('membersV2') || [];
|
private getLastMessage = memoizeByThis((): LastMessageType | undefined => {
|
||||||
return members.map(member => ({
|
if (this.get('lastMessageDeletedForEveryone')) {
|
||||||
isAdmin: member.role === Proto.Member.Role.ADMINISTRATOR,
|
return { deletedForEveryone: true };
|
||||||
uuid: member.uuid,
|
}
|
||||||
}));
|
const lastMessageText = this.get('lastMessage');
|
||||||
}
|
if (!lastMessageText) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: dropNull(this.get('lastMessageStatus')),
|
||||||
|
text: lastMessageText,
|
||||||
|
author: dropNull(this.get('lastMessageAuthor')),
|
||||||
|
deletedForEveryone: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
private getMemberships = memoizeByThis(
|
||||||
|
(): ReadonlyArray<{
|
||||||
|
uuid: UUIDStringType;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}> => {
|
||||||
|
if (!isGroupV2(this.attributes)) {
|
||||||
|
return EMPTY_ARRAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = this.get('membersV2') || [];
|
||||||
|
return members.map(member => ({
|
||||||
|
isAdmin: member.role === Proto.Member.Role.ADMINISTRATOR,
|
||||||
|
uuid: member.uuid,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
getGroupLink(): string | undefined {
|
getGroupLink(): string | undefined {
|
||||||
if (!isGroupV2(this.attributes)) {
|
if (!isGroupV2(this.attributes)) {
|
||||||
|
@ -3638,39 +3653,45 @@ export class ConversationModel extends window.Backbone
|
||||||
return window.Signal.Groups.buildGroupLink(this);
|
return window.Signal.Groups.buildGroupLink(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPendingMemberships(): Array<{
|
private getPendingMemberships = memoizeByThis(
|
||||||
addedByUserId?: UUIDStringType;
|
(): ReadonlyArray<{
|
||||||
uuid: UUIDStringType;
|
addedByUserId?: UUIDStringType;
|
||||||
}> {
|
uuid: UUIDStringType;
|
||||||
if (!isGroupV2(this.attributes)) {
|
}> => {
|
||||||
return [];
|
if (!isGroupV2(this.attributes)) {
|
||||||
|
return EMPTY_ARRAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = this.get('pendingMembersV2') || [];
|
||||||
|
return members.map(member => ({
|
||||||
|
addedByUserId: member.addedByUserId,
|
||||||
|
uuid: member.uuid,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const members = this.get('pendingMembersV2') || [];
|
private getPendingApprovalMemberships = memoizeByThis(
|
||||||
return members.map(member => ({
|
(): ReadonlyArray<{ uuid: UUIDStringType }> => {
|
||||||
addedByUserId: member.addedByUserId,
|
if (!isGroupV2(this.attributes)) {
|
||||||
uuid: member.uuid,
|
return EMPTY_ARRAY;
|
||||||
}));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private getPendingApprovalMemberships(): Array<{ uuid: UUIDStringType }> {
|
const members = this.get('pendingAdminApprovalV2') || [];
|
||||||
if (!isGroupV2(this.attributes)) {
|
return members.map(member => ({
|
||||||
return [];
|
uuid: member.uuid,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const members = this.get('pendingAdminApprovalV2') || [];
|
private getBannedMemberships = memoizeByThis(
|
||||||
return members.map(member => ({
|
(): ReadonlyArray<UUIDStringType> => {
|
||||||
uuid: member.uuid,
|
if (!isGroupV2(this.attributes)) {
|
||||||
}));
|
return EMPTY_ARRAY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBannedMemberships(): Array<UUIDStringType> {
|
return (this.get('bannedMembersV2') || []).map(member => member.uuid);
|
||||||
if (!isGroupV2(this.attributes)) {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return (this.get('bannedMembersV2') || []).map(member => member.uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
getMembers(
|
getMembers(
|
||||||
options: { includePendingMembers?: boolean } = {}
|
options: { includePendingMembers?: boolean } = {}
|
||||||
|
@ -4132,7 +4153,7 @@ export class ConversationModel extends window.Backbone
|
||||||
const draftProperties = dontClearDraft
|
const draftProperties = dontClearDraft
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
draft: null,
|
draft: '',
|
||||||
draftTimestamp: null,
|
draftTimestamp: null,
|
||||||
lastMessage: model.getNotificationText(),
|
lastMessage: model.getNotificationText(),
|
||||||
lastMessageAuthor: model.getAuthorText(),
|
lastMessageAuthor: model.getAuthorText(),
|
||||||
|
|
|
@ -155,6 +155,16 @@ export type MessageWithUIFieldsType = MessageAttributesType & {
|
||||||
export const ConversationTypes = ['direct', 'group'] as const;
|
export const ConversationTypes = ['direct', 'group'] as const;
|
||||||
export type ConversationTypeType = typeof ConversationTypes[number];
|
export type ConversationTypeType = typeof ConversationTypes[number];
|
||||||
|
|
||||||
|
export type LastMessageType = Readonly<
|
||||||
|
| {
|
||||||
|
status?: LastMessageStatus;
|
||||||
|
text: string;
|
||||||
|
author?: string;
|
||||||
|
deletedForEveryone: false;
|
||||||
|
}
|
||||||
|
| { deletedForEveryone: true }
|
||||||
|
>;
|
||||||
|
|
||||||
export type ConversationType = {
|
export type ConversationType = {
|
||||||
id: string;
|
id: string;
|
||||||
uuid?: UUIDStringType;
|
uuid?: UUIDStringType;
|
||||||
|
@ -197,18 +207,11 @@ export type ConversationType = {
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
inboxPosition?: number;
|
inboxPosition?: number;
|
||||||
left?: boolean;
|
left?: boolean;
|
||||||
lastMessage?:
|
lastMessage?: LastMessageType;
|
||||||
| {
|
|
||||||
status?: LastMessageStatus;
|
|
||||||
text: string;
|
|
||||||
author?: string;
|
|
||||||
deletedForEveryone: false;
|
|
||||||
}
|
|
||||||
| { deletedForEveryone: true };
|
|
||||||
markedUnread?: boolean;
|
markedUnread?: boolean;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
membersCount?: number;
|
membersCount?: number;
|
||||||
messageCount?: number;
|
hasMessages?: boolean;
|
||||||
accessControlAddFromInviteLink?: number;
|
accessControlAddFromInviteLink?: number;
|
||||||
accessControlAttributes?: number;
|
accessControlAttributes?: number;
|
||||||
accessControlMembers?: number;
|
accessControlMembers?: number;
|
||||||
|
@ -244,7 +247,7 @@ export type ConversationType = {
|
||||||
profileSharing?: boolean;
|
profileSharing?: boolean;
|
||||||
|
|
||||||
shouldShowDraft?: boolean;
|
shouldShowDraft?: boolean;
|
||||||
draftText?: string | null;
|
draftText?: string;
|
||||||
draftBodyRanges?: DraftBodyRangesType;
|
draftBodyRanges?: DraftBodyRangesType;
|
||||||
draftPreview?: string;
|
draftPreview?: string;
|
||||||
|
|
||||||
|
|
|
@ -1033,8 +1033,7 @@ export function isMissingRequiredProfileSharing(
|
||||||
doesConversationRequireIt &&
|
doesConversationRequireIt &&
|
||||||
!conversation.profileSharing &&
|
!conversation.profileSharing &&
|
||||||
window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing') &&
|
window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing') &&
|
||||||
conversation.messageCount &&
|
conversation.hasMessages
|
||||||
conversation.messageCount > 0
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,9 @@ import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import { isConversationNameKnown } from './isConversationNameKnown';
|
import { isConversationNameKnown } from './isConversationNameKnown';
|
||||||
import { isInSystemContacts } from './isInSystemContacts';
|
import { isInSystemContacts } from './isInSystemContacts';
|
||||||
|
|
||||||
export type GroupNameCollisionsWithIdsByTitle = Record<string, Array<string>>;
|
export type GroupNameCollisionsWithIdsByTitle = Readonly<
|
||||||
|
Record<string, Array<string>>
|
||||||
|
>;
|
||||||
export type GroupNameCollisionsWithConversationsByTitle = Record<
|
export type GroupNameCollisionsWithConversationsByTitle = Record<
|
||||||
string,
|
string,
|
||||||
Array<ConversationType>
|
Array<ConversationType>
|
||||||
|
|
20
ts/util/isShallowEqual.ts
Normal file
20
ts/util/isShallowEqual.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export function isShallowEqual<Obj extends Record<string, unknown>>(
|
||||||
|
a: Obj,
|
||||||
|
b: Obj
|
||||||
|
): boolean {
|
||||||
|
const keys = Object.keys(a);
|
||||||
|
if (keys.length !== Object.keys(b).length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (a[key] !== b[key]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
20
ts/util/memoizeByThis.ts
Normal file
20
ts/util/memoizeByThis.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
|
export function memoizeByThis<Owner extends Record<string, unknown>, Result>(
|
||||||
|
fn: () => Result
|
||||||
|
): () => Result {
|
||||||
|
const lastValueMap = new WeakMap<Owner, Result>();
|
||||||
|
return function memoizedFn(this: Owner): Result {
|
||||||
|
const lastValue = lastValueMap.get(this);
|
||||||
|
const newValue = fn();
|
||||||
|
if (lastValue !== undefined && isEqual(lastValue, newValue)) {
|
||||||
|
return lastValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastValueMap.set(this, newValue);
|
||||||
|
return newValue;
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in a new issue