feat: add convo volatile info during migrations for each convo tracked

pull/2620/head
Audric Ackermann 2 years ago
parent 88b5446c6e
commit 8a0074d2bd

@ -1531,7 +1531,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
ReduxSogsRoomInfos.setCanWriteOutsideRedux(this.id, !!write);
}
const adminChanged = await this.handleModsOrAdminsChanges({
const adminChanged = await this.handleSogsModsOrAdminsChanges({
modsOrAdmins: details.admins,
hiddenModsOrAdmins: details.hidden_admins,
type: 'admins',
@ -1539,7 +1539,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
hasChange = hasChange || adminChanged;
const modsChanged = await this.handleModsOrAdminsChanges({
const modsChanged = await this.handleSogsModsOrAdminsChanges({
modsOrAdmins: details.moderators,
hiddenModsOrAdmins: details.hidden_moderators,
type: 'mods',
@ -2045,33 +2045,24 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
}
private sendTypingMessage(isTyping: boolean) {
if (!this.isPrivate()) {
// we can only send typing messages to approved contacts
if (!this.isPrivate() || this.isMe() || !this.isApproved()) {
return;
}
const recipientId = this.id;
const recipientId = this.id as string;
if (!recipientId) {
if (isEmpty(recipientId)) {
throw new Error('Need to provide either recipientId');
}
if (!this.isApproved()) {
return;
}
if (this.isMe()) {
// note to self
return;
}
const typingParams = {
timestamp: Date.now(),
timestamp: GetNetworkTime.getNowWithNetworkOffset(),
isTyping,
typingTimestamp: Date.now(),
typingTimestamp: GetNetworkTime.getNowWithNetworkOffset(),
};
const typingMessage = new TypingMessage(typingParams);
// send the message to a single recipient if this is a session chat
const device = new PubKey(recipientId);
getMessageQueue()
.sendToPubKey(device, typingMessage, SnodeNamespaces.UserMessages)
@ -2091,7 +2082,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
return replacedWithOurRealSessionId;
}
private async handleModsOrAdminsChanges({
private async handleSogsModsOrAdminsChanges({
modsOrAdmins,
hiddenModsOrAdmins,
type,
@ -2110,12 +2101,15 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
uniq(localModsOrAdmins)
);
if (type === 'admins') {
return await this.updateGroupAdmins(replacedWithOurRealSessionId, false);
switch (type) {
case 'admins':
return this.updateGroupAdmins(replacedWithOurRealSessionId, false);
case 'mods':
ReduxSogsRoomInfos.setModeratorsOutsideRedux(this.id, replacedWithOurRealSessionId);
return false;
default:
assertUnreachable(type, `handleSogsModsOrAdminsChanges: unhandled switch case: ${type}`);
}
ReduxSogsRoomInfos.setModeratorsOutsideRedux(this.id, replacedWithOurRealSessionId);
return false;
}
return false;
}

@ -1,7 +1,8 @@
import * as BetterSqlite3 from 'better-sqlite3';
import { compact, isArray, isEmpty, isString, map, pick } from 'lodash';
import { compact, isArray, isEmpty, isNumber, isString, map, pick } from 'lodash';
import {
ContactsConfigWrapperInsideWorker,
ConvoInfoVolatileWrapperInsideWorker,
UserConfigWrapperInsideWorker,
UserGroupsWrapperInsideWorker,
} from 'session_util_wrapper';
@ -19,7 +20,6 @@ import {
CONVERSATIONS_TABLE,
dropFtsAndTriggers,
GUARD_NODE_TABLE,
jsonToObject,
LAST_HASHES_TABLE,
MESSAGES_TABLE,
NODES_FOR_PUBKEY_TABLE,
@ -1205,66 +1205,96 @@ function updateToSessionSchemaVersion29(currentVersion: number, db: BetterSqlite
console.log(`updateToSessionSchemaVersion${targetVersion}: success!`);
}
function insertContactIntoWrapper(
function insertContactIntoContactWrapper(
contact: any,
blockedNumbers: Array<string>,
contactsConfigWrapper: ContactsConfigWrapperInsideWorker
contactsConfigWrapper: ContactsConfigWrapperInsideWorker | null, // set this to null to only insert into the convo volatile wrapper (i.e. for ourConvo case)
volatileConfigWrapper: ConvoInfoVolatileWrapperInsideWorker,
db: BetterSqlite3.Database
) {
const dbApproved = !!contact.isApproved || false;
const dbApprovedMe = !!contact.didApproveMe || false;
const dbBlocked = blockedNumbers.includes(contact.id);
const hidden = contact.hidden || false;
const isPinned = contact.isPinned;
const wrapperContact = getContactInfoFromDBValues({
id: contact.id,
dbApproved,
dbApprovedMe,
dbBlocked,
dbName: contact.displayNameInProfile || undefined,
dbNickname: contact.nickname || undefined,
dbProfileKey: contact.profileKey || undefined,
dbProfileUrl: contact.avatarPointer || undefined,
isPinned,
hidden,
});
if (contactsConfigWrapper !== null) {
const dbApproved = !!contact.isApproved || false;
const dbApprovedMe = !!contact.didApproveMe || false;
const dbBlocked = blockedNumbers.includes(contact.id);
const hidden = contact.hidden || false;
const isPinned = contact.isPinned;
const wrapperContact = getContactInfoFromDBValues({
id: contact.id,
dbApproved,
dbApprovedMe,
dbBlocked,
dbName: contact.displayNameInProfile || undefined,
dbNickname: contact.nickname || undefined,
dbProfileKey: contact.profileKey || undefined,
dbProfileUrl: contact.avatarPointer || undefined,
isPinned,
hidden,
});
try {
console.info('Inserting contact into wrapper: ', wrapperContact);
contactsConfigWrapper.set(wrapperContact);
} catch (e) {
console.error(
`contactsConfigWrapper.set during migration failed with ${e.message} for id: ${contact.id}`
);
// the wrapper did not like something. Try again with just the boolean fields as it's most likely the issue is with one of the strings (which could be recovered)
try {
console.info('Inserting edited contact into wrapper: ', contact.id);
contactsConfigWrapper.set(
getContactInfoFromDBValues({
id: contact.id,
dbApproved,
dbApprovedMe,
dbBlocked,
dbName: undefined,
dbNickname: undefined,
dbProfileKey: undefined,
dbProfileUrl: undefined,
isPinned: false,
hidden,
})
);
console.info('Inserting contact into wrapper: ', wrapperContact);
contactsConfigWrapper.set(wrapperContact);
} catch (e) {
// there is nothing else we can do here
console.error(
`contactsConfigWrapper.set during migration failed with ${e.message} for id: ${contact.id}. Skipping contact entirely`
`contactsConfigWrapper.set during migration failed with ${e.message} for id: ${contact.id}`
);
// the wrapper did not like something. Try again with just the boolean fields as it's most likely the issue is with one of the strings (which could be recovered)
try {
console.info('Inserting edited contact into wrapper: ', contact.id);
contactsConfigWrapper.set(
getContactInfoFromDBValues({
id: contact.id,
dbApproved,
dbApprovedMe,
dbBlocked,
dbName: undefined,
dbNickname: undefined,
dbProfileKey: undefined,
dbProfileUrl: undefined,
isPinned: false,
hidden,
})
);
} catch (e) {
// there is nothing else we can do here
console.error(
`contactsConfigWrapper.set during migration failed with ${e.message} for id: ${contact.id}. Skipping contact entirely`
);
}
}
}
try {
const rows = db
.prepare(
`
SELECT MAX(COALESCE(sent_at, 0)) AS max_sent_at
FROM ${MESSAGES_TABLE} WHERE
conversationId = $conversationId AND
unread = $unread;
`
)
.get({
conversationId: contact.id,
unread: toSqliteBoolean(false), // we want to find the message read with the higher sentAt timestamp
});
const maxRead = rows?.max_sent_at;
const lastRead = isNumber(maxRead) && isFinite(maxRead) ? maxRead : 0;
console.info(`Inserting contact into volatile wrapper maxread: ${contact.id} :${lastRead}`);
volatileConfigWrapper.set1o1(contact.id, lastRead, false);
} catch (e) {
console.error(
`volatileConfigWrapper.set1o1 during migration failed with ${e.message} for id: ${contact.id}. skipping`
);
}
}
function insertCommunityIntoWrapper(
community: { id: string; isPinned: boolean },
userGroupConfigWrapper: UserGroupsWrapperInsideWorker,
volatileConfigWrapper: ConvoInfoVolatileWrapperInsideWorker,
db: BetterSqlite3.Database
) {
const isPinned = community.isPinned;
@ -1301,8 +1331,26 @@ function insertCommunityIntoWrapper(
});
try {
console.info('Inserting community into wrapper: ', wrapperComm);
console.info('Inserting community into group wrapper: ', wrapperComm);
userGroupConfigWrapper.setCommunityByFullUrl(wrapperComm.fullUrl, wrapperComm.priority);
const rows = db
.prepare(
`
SELECT MAX(COALESCE(serverTimestamp, 0)) AS max_sent_at
FROM ${MESSAGES_TABLE} WHERE
conversationId = $conversationId AND
unread = $unread;
`
)
.get({
conversationId: convoId,
unread: toSqliteBoolean(false), // we want to find the message read with the higher serverTimestamp timestamp
});
const maxRead = rows?.max_sent_at;
const lastRead = isNumber(maxRead) && isFinite(maxRead) ? maxRead : 0;
console.info(`Inserting community into volatile wrapper: ${wrapperComm.fullUrl} :${lastRead}`);
volatileConfigWrapper.setCommunityByFullUrl(wrapperComm.fullUrl, lastRead, false);
} catch (e) {
console.error(
`userGroupConfigWrapper.set during migration failed with ${e.message} for fullUrl: "${wrapperComm.fullUrl}". Skipping community entirely`
@ -1316,6 +1364,7 @@ function insertLegacyGroupIntoWrapper(
'hidden' | 'id' | 'isPinned' | 'expireTimer' | 'displayNameInProfile'
> & { members: string; groupAdmins: string }, // members and groupAdmins are still stringified here
userGroupConfigWrapper: UserGroupsWrapperInsideWorker,
volatileInfoConfigWrapper: ConvoInfoVolatileWrapperInsideWorker,
db: BetterSqlite3.Database
) {
const {
@ -1348,6 +1397,25 @@ function insertLegacyGroupIntoWrapper(
try {
console.info('Inserting legacy group into wrapper: ', wrapperLegacyGroup);
userGroupConfigWrapper.setLegacyGroup(wrapperLegacyGroup);
const rows = db
.prepare(
`
SELECT MAX(COALESCE(sent_at, 0)) AS max_sent_at
FROM ${MESSAGES_TABLE} WHERE
conversationId = $conversationId AND
unread = $unread;
`
)
.get({
conversationId: id,
unread: toSqliteBoolean(false), // we want to find the message read with the higher sentAt timestamp
});
const maxRead = rows?.max_sent_at;
const lastRead = isNumber(maxRead) && isFinite(maxRead) ? maxRead : 0;
console.info(`Inserting legacy group into volatile wrapper maxread: ${id} :${lastRead}`);
volatileInfoConfigWrapper.setLegacyGroup(id, lastRead, false);
} catch (e) {
console.error(
`userGroupConfigWrapper.set during migration failed with ${e.message} for legacyGroup.id: "${legacyGroup.id}". Skipping that legacy group entirely`
@ -1394,13 +1462,18 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
);
// drop unused readCapability & uploadCapability columns. Also move `writeCapability` to memory only value.
db.exec(`
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN readCapability;
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN writeCapability;
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN uploadCapability;
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN subscriberCount;
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN is_medium_group;
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN groupModerators;
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN readCapability; -- stored in a redux slice now
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN writeCapability; -- stored in a redux slice now
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN uploadCapability; -- stored in a redux slice now
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN subscriberCount; -- stored in a redux slice now
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN groupModerators; -- stored in a redux slice now
ALTER TABLE ${CONVERSATIONS_TABLE} DROP COLUMN is_medium_group; -- a medium group starts with 05 and has a type of group. We cache everything renderer side so there is no need for that field
`);
// Didn't find any reference to this serverTimestamp in the unprocessed table needed, so let's clean it up
db.exec(`
ALTER TABLE unprocessed DROP COLUMN serverTimestamp;
`);
// mark every "active" private chats as not hidden
db.prepare(
@ -1478,26 +1551,32 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
if (!userAlreadyCreated) {
throw new Error('privateEd25519 was empty. Considering no users are logged in');
}
const blockedNumbers = getBlockedNumbersDuringMigration(db);
const { privateEd25519, publicKeyHex } = keys;
const userProfileWrapper = new UserConfigWrapperInsideWorker(privateEd25519, null);
const contactsConfigWrapper = new ContactsConfigWrapperInsideWorker(privateEd25519, null);
const userGroupsConfigWrapper = new UserGroupsWrapperInsideWorker(privateEd25519, null);
const volatileInfoConfigWrapper = new ConvoInfoVolatileWrapperInsideWorker(
privateEd25519,
null
);
/**
* Setup up the User profile wrapper with what is stored in our own conversation
*/
const ourConvoRow = db.prepare(`SELECT * FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`).get({
id: publicKeyHex,
});
const ourConversation = db
.prepare(`SELECT * FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`)
.get({
id: publicKeyHex,
}) as Record<string, any> | undefined;
if (!ourConvoRow) {
if (!ourConversation) {
throw new Error('Failed to find our logged in conversation while migrating');
}
const ourConversation = jsonToObject(ourConvoRow);
// Insert the user profile into the userWrappoer
// Insert the user profile into the userWrapper
const ourDbName = ourConversation.displayNameInProfile || '';
const ourDbProfileUrl = ourConversation.avatarPointer || '';
const ourDbProfileKey = fromHexToArray(ourConversation.profileKey || '');
@ -1508,6 +1587,13 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
} else {
userProfileWrapper.setProfilePicture('', new Uint8Array());
}
insertContactIntoContactWrapper(
ourConversation,
blockedNumbers,
null,
volatileInfoConfigWrapper,
db
);
// dump the user wrapper content and save it to the DB
const userDump = userProfileWrapper.dump();
@ -1531,7 +1617,6 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
/**
* Setup up the Contacts Wrapper with all the contact details which needs to be stored in it.
*/
const blockedNumbers = getBlockedNumbersDuringMigration(db);
// this filter is based on the `isContactToStoreInContactsWrapper` function.
const contactsToWriteInWrapper = db
@ -1553,7 +1638,13 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
);
contactsToWriteInWrapper.forEach(contact => {
insertContactIntoWrapper(contact, blockedNumbers, contactsConfigWrapper);
insertContactIntoContactWrapper(
contact,
blockedNumbers,
contactsConfigWrapper,
volatileInfoConfigWrapper,
db
);
});
console.info('===================== Done with contact inserting =======================');
@ -1595,7 +1686,12 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
communitiesToWriteInWrapper.forEach(community => {
try {
console.info('Writing community: ', JSON.stringify(community));
insertCommunityIntoWrapper(community, userGroupsConfigWrapper, db);
insertCommunityIntoWrapper(
community,
userGroupsConfigWrapper,
volatileInfoConfigWrapper,
db
);
} catch (e) {
console.info(`failed to insert community with ${e.message}`, community);
}
@ -1622,7 +1718,12 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
try {
console.info('Writing legacy group: ', JSON.stringify(legacyGroup));
insertLegacyGroupIntoWrapper(legacyGroup, userGroupsConfigWrapper, db);
insertLegacyGroupIntoWrapper(
legacyGroup,
userGroupsConfigWrapper,
volatileInfoConfigWrapper,
db
);
} catch (e) {
console.info(`failed to insert legacy group with ${e.message}`, legacyGroup);
}
@ -1632,7 +1733,6 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
'===================== Done with legacy group inserting ======================='
);
}
// TODO we need to do the same for new groups once they are available
const userGroupsDump = userGroupsConfigWrapper.dump();
@ -1652,25 +1752,35 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
data: userGroupsDump,
});
// TODO add the conversation volatile one with handling of contacts and note to self and reference to `isConvoToStoreInWrapper`
const convoVolatileDump = volatileInfoConfigWrapper.dump();
db.prepare(
`INSERT OR REPLACE INTO ${CONFIG_DUMP_TABLE} (
publicKey,
variant,
data
) values (
$publicKey,
$variant,
$data
);`
).run({
publicKey: publicKeyHex,
variant: 'ConvoInfoVolatileConfig',
data: convoVolatileDump,
});
// TODO we've just created the initial dumps. We have to add an initial SyncJob to the database so it is run on the next app start/
// or find another way of adding one on the next start (store an another item in the DB and check for it on app start?)
// or just start a conf sync job on app start
} catch (e) {
console.error(`failed to create initial wrapper: `, e.stack);
throw e;
}
// db.exec(`ALTER TABLE conversations
// ADD COLUMN lastReadTimestampMs INTEGER;
// ;
// `);
// for manually flagging conversations as :unread"
db.exec(`ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN markedAsUnread BOOLEAN;`);
// Didn't find any reference to this serverTimestamp in the unprocessed table needed, so let's clean it up
db.exec(`ALTER TABLE unprocessed DROP COLUMN serverTimestamp;`);
writeSessionSchemaVersion(targetVersion, db);
})();

@ -504,6 +504,11 @@ function fetchConvoMemoryDetails(convoId: string): SaveConversationReturn {
const unreadCount = getUnreadCountByConversation(convoId);
const lastReadTimestampMessageSentTimestamp = getLastMessageReadInConversation(convoId);
// TODO it would be nice to be able to remove the lastMessage and lastMessageStatus from the conversation table, and just return it when saving the conversation
// and saving it in memory only.
// But we'd need to update a bunch of things as we do some logic before setting the lastUpdate text and status mostly in `getMessagePropStatus` and `getNotificationText()`
// const lastMessages = getLastMessagesByConversation(convoId, 1) as Array:Record<string, any>>;
return {
mentionedUs: hasMentionedUsUnread,
unreadCount,

@ -14,6 +14,7 @@ import { ConfigurationSync } from '../utils/job_runners/jobs/ConfigurationSyncJo
import { SessionUtilContact } from '../utils/libsession/libsession_utils_contacts';
import { SessionUtilConvoInfoVolatile } from '../utils/libsession/libsession_utils_convo_info_volatile';
import { SessionUtilUserGroups } from '../utils/libsession/libsession_utils_user_groups';
import { ConfigurationDumpSync } from '../utils/job_runners/jobs/ConfigurationSyncDumpJob';
let instance: ConversationController | null;
@ -207,12 +208,12 @@ export class ConversationController {
await deleteAllMessagesByConvoIdNoConfirmation(id);
window.log.info(`deleteContact messages destroyed: ${id}`);
// Closed/Medium group leaving
// Legacy group leaving
if (conversation.isClosedGroup()) {
window.log.info(`deleteContact ClosedGroup case: ${id}`);
await leaveClosedGroup(conversation.id);
await SessionUtilConvoInfoVolatile.removeLegacyGroupFromWrapper(conversation.id);
// open group v2
await SessionUtilUserGroups.removeLegacyGroupFromWrapper(conversation.id);
} else if (conversation.isPublic()) {
window?.log?.info('leaving open group v2', conversation.id);
// remove from the wrapper the entries before we remove the roomInfos, as we won't have the required community pubkey afterwards
@ -248,7 +249,7 @@ export class ConversationController {
});
// we currently do not wish to reset the approved/approvedMe state when marking a private conversation as hidden
// await conversation.setIsApproved(false, false);
await conversation.commit();
await conversation.commit(); // this updates the wrappers content to reflect the hidden state
// The note to self cannot be removed from the wrapper I suppose, as it must always be there
// TODO I think we want to mark the contacts as hidden instead of removing them, so maybe keep the volatile info too?
@ -281,6 +282,7 @@ export class ConversationController {
if (!fromSyncMessage) {
await ConfigurationSync.queueNewJobIfNeeded();
await ConfigurationDumpSync.queueNewJobIfNeeded();
}
}
@ -294,10 +296,6 @@ export class ConversationController {
return this.conversations.models;
}
public unsafeDelete(convo: ConversationModel) {
this.conversations.remove(convo);
}
public async load() {
if (this.conversations.length) {
throw new Error('ConversationController: Already loaded!');
@ -311,7 +309,7 @@ export class ConversationController {
const start = Date.now();
// TODO make this a switch so we take care of all wrappers and have compilation errors if we forgot to add one.
// also keep in mind that the convo volatile one need to run for each convo.
// also keep in mind that the convo volatile one need to run for each convo so it must be outside of a `else`
for (let index = 0; index < convoModels.length; index++) {
const convo = convoModels[index];
if (SessionUtilContact.isContactToStoreInContactsWrapper(convo)) {
@ -330,7 +328,7 @@ export class ConversationController {
await convo.refreshInMemoryDetails();
}
}
console.info(`refreshAllWrappersMappedValues took ${Date.now() - start}ms`);
window.log.info(`refreshAllWrappersMappedValues took ${Date.now() - start}ms`);
this._initialFetchComplete = true;
// TODO do we really need to do this?

@ -62,7 +62,7 @@ function isConvoToStoreInWrapper(convo: ConversationModel): boolean {
return (
SessionUtilUserGroups.isUserGroupToStoreInWrapper(convo) || // this checks for community & legacy group
SessionUtilContact.isContactToStoreInContactsWrapper(convo) || // this checks for contacts
SessionUtilUserProfile.isUserProfileToStoreInContactsWrapper(convo.id) // this checks for out own pubkey, as we want to keep track of the read state for the Note To Self
SessionUtilUserProfile.isUserProfileToStoreInContactsWrapper(convo.id) // this checks for our own pubkey, as we want to keep track of the read state for the Note To Self
);
}
@ -91,7 +91,8 @@ async function insertConvoFromDBIntoWrapperAndRefresh(convoId: string): Promise<
const isForcedUnread = foundConvo.isMarkedUnread();
const timestampFromDbMs = (await Data.fetchConvoMemoryDetails(convoId))?.lastReadTimestampMessage;
// TODO not having a last read timestamp fallsback to 0, which keeps the existing value in the wrapper if it is already set (as done in src/convo_info_volatile_config.cpp)
// Note: not having a last read timestamp fallsback to 0, which keeps the existing value in the wrapper if it is already set (as done in src/convo_info_volatile_config.cpp)
// we actually do the max() of whatever is inside the wrapper and the value from the DB
const lastReadMessageTimestamp =
!!timestampFromDbMs && isFinite(timestampFromDbMs) && timestampFromDbMs > 0
? timestampFromDbMs

@ -95,7 +95,6 @@ export const forceSyncConfigurationNowIfNeeded = async (waitForMessageSent = fal
});
if (waitForMessageSent) {
window.Whisper.events.once(ConfigurationSyncJobDone, () => {
debugger;
resolve(true);
});
}

Loading…
Cancel
Save