Add support for m.recent_emoji account data event (#33172)
* Improve types in AccountSettingsHandler * Add support for `m.recent_emoji` account data event Maintains read-compatibility with `io.element.recent_emoji` * Iterate * Make ts happier * Improve coverage * Improve coverage * Improve coverage
This commit is contained in:
committed by
GitHub
parent
5cfb68962d
commit
f0342ddbd2
+1
-2
@@ -71,9 +71,8 @@ declare module "matrix-js-sdk/src/types" {
|
||||
[key: `io.element.matrix_client_information.${string}`]: DeviceClientInformation;
|
||||
// Element settings account data events
|
||||
"im.vector.setting.breadcrumbs": { recent_rooms: string[] };
|
||||
"io.element.recent_emoji": { recent_emoji: string[] };
|
||||
"im.vector.setting.integration_provisioning": { enabled: boolean };
|
||||
"im.vector.riot.breadcrumb_rooms": { recent_rooms: string[] };
|
||||
"im.vector.riot.breadcrumb_rooms": { rooms: string[] };
|
||||
"im.vector.web.settings": Record<string, any>;
|
||||
|
||||
// URL preview account data event
|
||||
|
||||
@@ -8,16 +8,20 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { orderBy } from "lodash";
|
||||
import { type AccountDataEvents } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { SettingLevel } from "../settings/SettingLevel";
|
||||
|
||||
interface ILegacyFormat {
|
||||
[emoji: string]: [number, number]; // [count, date]
|
||||
}
|
||||
export type RecentEmojiData = AccountDataEvents["m.recent_emoji"]["recent_emoji"];
|
||||
export type LegacyRecentEmojiData = [emoji: string, count: number][];
|
||||
|
||||
// New format tries to be more space efficient for synchronization. Ordered by Date descending.
|
||||
export type RecentEmojiData = [emoji: string, count: number][];
|
||||
// XXX: We can get rid of the legacy account data handling towards the end of 2026.
|
||||
declare module "matrix-js-sdk/src/types" {
|
||||
export interface AccountDataEvents {
|
||||
"io.element.recent_emoji": { recent_emoji: LegacyRecentEmojiData };
|
||||
}
|
||||
}
|
||||
|
||||
const SETTING_NAME = "recent_emoji";
|
||||
|
||||
@@ -25,43 +29,57 @@ const SETTING_NAME = "recent_emoji";
|
||||
// even if you haven't used your typically favourite emoji for a little while.
|
||||
const STORAGE_LIMIT = 100;
|
||||
|
||||
// TODO remove this after some time
|
||||
function migrate(): void {
|
||||
const data: ILegacyFormat = JSON.parse(window.localStorage.mx_reaction_count || "{}");
|
||||
const sorted = Object.entries(data).sort(([, [count1, date1]], [, [count2, date2]]) => date2 - date1);
|
||||
const newFormat = sorted.map<RecentEmojiData[number]>(([emoji, [count, date]]) => [emoji, count]);
|
||||
SettingsStore.setValue(SETTING_NAME, null, SettingLevel.ACCOUNT, newFormat.slice(0, STORAGE_LIMIT));
|
||||
}
|
||||
|
||||
function getRecentEmoji(): RecentEmojiData {
|
||||
return SettingsStore.getValue(SETTING_NAME) || [];
|
||||
}
|
||||
|
||||
export function translateLegacyEmojiData(legacyData: LegacyRecentEmojiData): RecentEmojiData {
|
||||
return legacyData.map(([emoji, total]) => ({
|
||||
emoji,
|
||||
total,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mergeEmojiData(data1: RecentEmojiData, data2?: RecentEmojiData): RecentEmojiData {
|
||||
if (!data2) return data1;
|
||||
|
||||
return Object.values(
|
||||
[...data1, ...data2].reduce(
|
||||
(acc, item) => {
|
||||
const existing = acc[item.emoji];
|
||||
|
||||
// If it doesn't exist or the current total is higher, update it
|
||||
if (!existing || item.total > existing.total) {
|
||||
acc[item.emoji] = item;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, RecentEmojiData[number]>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function add(emoji: string): void {
|
||||
const recents = getRecentEmoji();
|
||||
const i = recents.findIndex(([e]) => e === emoji);
|
||||
const i = recents.findIndex((entry) => entry.emoji === emoji);
|
||||
|
||||
let newEntry: RecentEmojiData[number];
|
||||
if (i >= 0) {
|
||||
// first remove the existing tuple so that we can increment it and push it to the front
|
||||
[newEntry] = recents.splice(i, 1);
|
||||
newEntry[1]++; // increment the usage count
|
||||
newEntry.total++; // increment the usage count
|
||||
} else {
|
||||
newEntry = [emoji, 1];
|
||||
newEntry = { emoji, total: 1 };
|
||||
}
|
||||
|
||||
SettingsStore.setValue(SETTING_NAME, null, SettingLevel.ACCOUNT, [newEntry, ...recents].slice(0, STORAGE_LIMIT));
|
||||
}
|
||||
|
||||
export function get(limit = 24): string[] {
|
||||
let recents = getRecentEmoji();
|
||||
|
||||
if (recents.length < 1) {
|
||||
migrate();
|
||||
recents = getRecentEmoji();
|
||||
}
|
||||
const recents = getRecentEmoji();
|
||||
|
||||
// perform a stable sort on `count` to keep the recent (date) order as a secondary sort factor
|
||||
const sorted = orderBy(recents, "1", "desc");
|
||||
return sorted.slice(0, limit).map(([emoji]) => emoji);
|
||||
const sorted = orderBy(recents, "total", "desc");
|
||||
return sorted.slice(0, limit).map(({ emoji }) => emoji);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,14 @@ import { objectClone, objectKeyChanges } from "../../utils/objects";
|
||||
import { SettingLevel } from "../SettingLevel";
|
||||
import { type WatchManager } from "../WatchManager";
|
||||
import { MEDIA_PREVIEW_ACCOUNT_DATA_TYPE } from "../../@types/media_preview";
|
||||
import { type SettingKey, type Settings } from "../Settings.tsx";
|
||||
import { mergeEmojiData, type RecentEmojiData, translateLegacyEmojiData } from "../../emojipicker/recent.ts";
|
||||
|
||||
const BREADCRUMBS_LEGACY_EVENT_TYPE = "im.vector.riot.breadcrumb_rooms";
|
||||
const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs";
|
||||
const BREADCRUMBS_EVENT_TYPES = [BREADCRUMBS_LEGACY_EVENT_TYPE, BREADCRUMBS_EVENT_TYPE];
|
||||
const RECENT_EMOJI_EVENT_TYPE = "io.element.recent_emoji";
|
||||
const LEGACY_RECENT_EMOJI_EVENT_TYPE = "io.element.recent_emoji";
|
||||
const RECENT_EMOJI_EVENT_TYPE = "m.recent_emoji";
|
||||
const INTEG_PROVISIONING_EVENT_TYPE = "im.vector.setting.integration_provisioning";
|
||||
const ANALYTICS_EVENT_TYPE = "im.vector.analytics";
|
||||
const DEFAULT_SETTINGS_EVENT_TYPE = "im.vector.web.settings";
|
||||
@@ -65,8 +68,8 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
|
||||
} else if (event.getType() === INTEG_PROVISIONING_EVENT_TYPE) {
|
||||
const val = event.getContent()["enabled"];
|
||||
this.watchers.notifyUpdate("integrationProvisioning", null, SettingLevel.ACCOUNT, val);
|
||||
} else if (event.getType() === RECENT_EMOJI_EVENT_TYPE) {
|
||||
const val = event.getContent()["enabled"];
|
||||
} else if (event.getType() === RECENT_EMOJI_EVENT_TYPE || event.getType() === LEGACY_RECENT_EMOJI_EVENT_TYPE) {
|
||||
const val = this.getRecentEmoji();
|
||||
this.watchers.notifyUpdate("recent_emoji", null, SettingLevel.ACCOUNT, val);
|
||||
} else if (event.getType() === MEDIA_PREVIEW_ACCOUNT_DATA_TYPE) {
|
||||
this.watchers.notifyUpdate("mediaPreviewConfig", null, SettingLevel.ROOM_ACCOUNT, event.getContent());
|
||||
@@ -76,30 +79,33 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
|
||||
public getValue(settingName: string, roomId: string): any {
|
||||
// Special case URL previews
|
||||
if (settingName === "urlPreviewsEnabled") {
|
||||
const content = this.getSettings("org.matrix.preview_urls") || {};
|
||||
const content = this.getSettings("org.matrix.preview_urls");
|
||||
|
||||
// Check to make sure that we actually got a boolean
|
||||
if (typeof content["disable"] !== "boolean") return null;
|
||||
if (typeof content?.["disable"] !== "boolean") return null;
|
||||
return !content["disable"];
|
||||
}
|
||||
|
||||
// Special case for breadcrumbs
|
||||
if (settingName === "breadcrumb_rooms") {
|
||||
let content = this.getSettings(BREADCRUMBS_EVENT_TYPE);
|
||||
if (!content || !content["recent_rooms"]) {
|
||||
content = this.getSettings(BREADCRUMBS_LEGACY_EVENT_TYPE);
|
||||
if (!content?.["recent_rooms"]) {
|
||||
const legacyContent = this.getSettings(BREADCRUMBS_LEGACY_EVENT_TYPE);
|
||||
|
||||
// This is a bit of a hack, but it makes things slightly easier
|
||||
if (content) content["recent_rooms"] = content["rooms"];
|
||||
if (legacyContent) {
|
||||
content = {
|
||||
recent_rooms: legacyContent["rooms"],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return content && content["recent_rooms"] ? content["recent_rooms"] : [];
|
||||
return content?.["recent_rooms"] ?? [];
|
||||
}
|
||||
|
||||
// Special case recent emoji
|
||||
if (settingName === "recent_emoji") {
|
||||
const content = this.getSettings(RECENT_EMOJI_EVENT_TYPE);
|
||||
return content ? content["recent_emoji"] : null;
|
||||
const val = this.getRecentEmoji();
|
||||
return val ?? null;
|
||||
}
|
||||
|
||||
// Special case integration manager provisioning
|
||||
@@ -109,15 +115,15 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
|
||||
}
|
||||
|
||||
if (settingName === "pseudonymousAnalyticsOptIn") {
|
||||
const content = this.getSettings(ANALYTICS_EVENT_TYPE) || {};
|
||||
const content = this.getSettings(ANALYTICS_EVENT_TYPE);
|
||||
// Check to make sure that we actually got a boolean
|
||||
if (typeof content[settingName] !== "boolean") return null;
|
||||
if (typeof content?.[settingName] !== "boolean") return null;
|
||||
return content[settingName];
|
||||
}
|
||||
|
||||
if (settingName === "MessageComposerInput.insertTrailingColon") {
|
||||
const content = this.getSettings() || {};
|
||||
const value = content[settingName];
|
||||
const content = this.getSettings();
|
||||
const value = content?.[settingName];
|
||||
if (value === null || value === undefined) {
|
||||
// Write true as it is the default. This will give us the option
|
||||
// of making this opt-in in the future, without affecting old
|
||||
@@ -128,13 +134,13 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
|
||||
return value;
|
||||
}
|
||||
|
||||
const settings = this.getSettings() || {};
|
||||
let preferredValue = settings[settingName];
|
||||
const settings = this.getSettings();
|
||||
let preferredValue = settings?.[settingName];
|
||||
|
||||
if (preferredValue === null || preferredValue === undefined) {
|
||||
// Honour the old setting on read only
|
||||
if (settingName === "hideAvatarChanges" || settingName === "hideDisplaynameChanges") {
|
||||
preferredValue = settings["hideAvatarDisplaynameChanges"];
|
||||
preferredValue = settings?.["hideAvatarDisplaynameChanges"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +156,11 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
|
||||
): Promise<void> {
|
||||
let content = this.getSettings(eventType);
|
||||
if (legacyEventType && !content?.[field]) {
|
||||
content = this.getSettings(legacyEventType);
|
||||
content = this.getSettings(legacyEventType) as AccountDataEvents[K];
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
content = {};
|
||||
content = {} as AccountDataEvents[K];
|
||||
}
|
||||
|
||||
content[field] = value;
|
||||
@@ -175,7 +181,11 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
|
||||
await deferred.promise;
|
||||
}
|
||||
|
||||
public async setValue(settingName: string, roomId: string, newValue: any): Promise<void> {
|
||||
public async setValue<S extends SettingKey>(
|
||||
settingName: S,
|
||||
roomId: string | null,
|
||||
newValue: Settings[S]["default"],
|
||||
): Promise<void> {
|
||||
switch (settingName) {
|
||||
// Special case URL previews
|
||||
case "urlPreviewsEnabled":
|
||||
@@ -186,21 +196,33 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
|
||||
return this.setAccountData(
|
||||
BREADCRUMBS_EVENT_TYPE,
|
||||
"recent_rooms",
|
||||
newValue,
|
||||
<Settings["breadcrumb_rooms"]["default"]>newValue,
|
||||
BREADCRUMBS_LEGACY_EVENT_TYPE,
|
||||
);
|
||||
|
||||
// Special case recent emoji
|
||||
case "recent_emoji":
|
||||
return this.setAccountData(RECENT_EMOJI_EVENT_TYPE, "recent_emoji", newValue);
|
||||
return this.setAccountData(
|
||||
RECENT_EMOJI_EVENT_TYPE,
|
||||
"recent_emoji",
|
||||
<Settings["recent_emoji"]["default"]>newValue,
|
||||
);
|
||||
|
||||
// Special case integration manager provisioning
|
||||
case "integrationProvisioning":
|
||||
return this.setAccountData(INTEG_PROVISIONING_EVENT_TYPE, "enabled", newValue);
|
||||
return this.setAccountData(
|
||||
INTEG_PROVISIONING_EVENT_TYPE,
|
||||
"enabled",
|
||||
<Settings["integrationProvisioning"]["default"]>newValue,
|
||||
);
|
||||
|
||||
// Special case analytics
|
||||
case "pseudonymousAnalyticsOptIn":
|
||||
return this.setAccountData(ANALYTICS_EVENT_TYPE, "pseudonymousAnalyticsOptIn", newValue);
|
||||
return this.setAccountData(
|
||||
ANALYTICS_EVENT_TYPE,
|
||||
"pseudonymousAnalyticsOptIn",
|
||||
<Settings["pseudonymousAnalyticsOptIn"]["default"]>newValue ?? undefined,
|
||||
);
|
||||
case "mediaPreviewConfig":
|
||||
// Handled in MediaPreviewConfigController.
|
||||
return;
|
||||
@@ -220,13 +242,15 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
|
||||
return this.client && !this.client.isGuest();
|
||||
}
|
||||
|
||||
private getSettings(eventType: keyof AccountDataEvents = "im.vector.web.settings"): any {
|
||||
// TODO: [TS] Types on return
|
||||
private getSettings(): AccountDataEvents["im.vector.web.settings"] | null;
|
||||
private getSettings<E extends keyof AccountDataEvents>(eventType: E): AccountDataEvents[E] | null;
|
||||
private getSettings<E extends keyof AccountDataEvents>(eventType?: E): AccountDataEvents[E] | null {
|
||||
if (!this.client) return null;
|
||||
|
||||
const event = this.client.getAccountData(eventType);
|
||||
if (!event || !event.getContent()) return null;
|
||||
return objectClone(event.getContent()); // clone to prevent mutation
|
||||
const event = this.client.getAccountData(eventType ?? "im.vector.web.settings");
|
||||
const content = event?.getContent<AccountDataEvents[E]>();
|
||||
if (!content) return null;
|
||||
return objectClone(content); // clone to prevent mutation
|
||||
}
|
||||
|
||||
private notifyBreadcrumbsUpdate(event: MatrixEvent): void {
|
||||
@@ -243,4 +267,15 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
|
||||
}
|
||||
this.watchers.notifyUpdate("breadcrumb_rooms", null, SettingLevel.ACCOUNT, val || []);
|
||||
}
|
||||
|
||||
private getRecentEmoji(): RecentEmojiData {
|
||||
let val = this.getSettings(RECENT_EMOJI_EVENT_TYPE)?.recent_emoji || [];
|
||||
|
||||
const legacyVal = this.getSettings(LEGACY_RECENT_EMOJI_EVENT_TYPE)?.recent_emoji;
|
||||
if (legacyVal) {
|
||||
val = mergeEmojiData(val, translateLegacyEmojiData(legacyVal));
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import EmojiPicker from "../../../../../src/components/views/emojipicker/EmojiPicker";
|
||||
import { stubClient } from "../../../../test-utils";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../../../src/settings/SettingLevel.ts";
|
||||
|
||||
describe("EmojiPicker", function () {
|
||||
stubClient();
|
||||
@@ -23,10 +24,7 @@ describe("EmojiPicker", function () {
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear recent emojis to prevent test pollution
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
|
||||
if (settingName === "recent_emoji") return [] as any;
|
||||
return jest.requireActual("../../../../../src/settings/SettingsStore").default.getValue(settingName);
|
||||
});
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -98,10 +96,11 @@ describe("EmojiPicker", function () {
|
||||
|
||||
it("should initialize categories with recent as firstVisible when recent emojis exist", () => {
|
||||
// Mock recent emojis
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
|
||||
if (settingName === "recent_emoji") return ["😀", "🎉", "❤️"] as any;
|
||||
return jest.requireActual("../../../../../src/settings/SettingsStore").default.getValue(settingName);
|
||||
});
|
||||
SettingsStore.setValue("recent_emoji", null, SettingLevel.ACCOUNT, [
|
||||
{ emoji: "😀", total: 3 },
|
||||
{ emoji: "🎉", total: 2 },
|
||||
{ emoji: "❤️", total: 2 },
|
||||
]);
|
||||
|
||||
const ref = createRef<EmojiPicker>();
|
||||
render(<EmojiPicker ref={ref} onChoose={(str: string) => false} onFinished={jest.fn()} />);
|
||||
@@ -394,10 +393,10 @@ describe("EmojiPicker", function () {
|
||||
|
||||
it("check tabindex for recent category when recent emojis exist", async () => {
|
||||
// Mock recent emojis
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
|
||||
if (settingName === "recent_emoji") return ["😀", "🎉"] as any;
|
||||
return jest.requireActual("../../../../../src/settings/SettingsStore").default.getValue(settingName);
|
||||
});
|
||||
SettingsStore.setValue("recent_emoji", null, SettingLevel.ACCOUNT, [
|
||||
{ emoji: "😀", total: 3 },
|
||||
{ emoji: "🎉", total: 2 },
|
||||
]);
|
||||
|
||||
const { container } = render(<EmojiPicker onChoose={jest.fn()} onFinished={jest.fn()} />);
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { translateLegacyEmojiData, mergeEmojiData } from "../../../../../src/emojipicker/recent.ts";
|
||||
|
||||
describe("recent", () => {
|
||||
describe("translateLegacyEmojiData", () => {
|
||||
it("should correctly translate to the new format", () => {
|
||||
expect(
|
||||
translateLegacyEmojiData([
|
||||
["🤩", 1],
|
||||
["😀", 2],
|
||||
]),
|
||||
).toEqual([
|
||||
{ emoji: "🤩", total: 1 },
|
||||
{ emoji: "😀", total: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeEmojiData", () => {
|
||||
it("should merge given data correctly", () => {
|
||||
expect(
|
||||
mergeEmojiData(
|
||||
[{ emoji: "🤩", total: 1 }],
|
||||
[
|
||||
{ emoji: "🤩", total: 2 },
|
||||
{ emoji: "😀", total: 1 },
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ emoji: "🤩", total: 2 },
|
||||
{ emoji: "😀", total: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle data2 being undefined", () => {
|
||||
expect(mergeEmojiData([{ emoji: "🤩", total: 1 }])).toEqual([{ emoji: "🤩", total: 1 }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ClientEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import AccountSettingsHandler from "../../../../src/settings/handlers/AccountSettingsHandler.ts";
|
||||
import { WatchManager } from "../../../../src/settings/WatchManager.ts";
|
||||
import { stubClient } from "../../../test-utils";
|
||||
|
||||
describe("AccountSettingsHandler", () => {
|
||||
const watchManager = new WatchManager();
|
||||
const handler = new AccountSettingsHandler(watchManager);
|
||||
|
||||
beforeEach(stubClient);
|
||||
|
||||
it("should notify watchers of recent_emoji on account data update", async () => {
|
||||
const fn = jest.fn();
|
||||
handler.watchers.watchSetting("recent_emoji", null, fn);
|
||||
|
||||
const ev = new MatrixEvent({
|
||||
type: "io.element.recent_emoji",
|
||||
content: {
|
||||
recent_emoji: [["🤒", 1]],
|
||||
},
|
||||
});
|
||||
mocked(handler.client.getAccountData).mockImplementation((eventType) =>
|
||||
eventType === "io.element.recent_emoji" ? ev : undefined,
|
||||
);
|
||||
handler.client.emit(ClientEvent.AccountData, ev);
|
||||
|
||||
expect(fn).toHaveBeenCalledWith(null, "account", [{ emoji: "🤒", total: 1 }]);
|
||||
});
|
||||
|
||||
it("should write value to account data correctly", async () => {
|
||||
void handler.setValue("pseudonymousAnalyticsOptIn", null, true);
|
||||
|
||||
expect(handler.client.setAccountData).toHaveBeenCalledWith("im.vector.analytics", {
|
||||
pseudonymousAnalyticsOptIn: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user