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:
Michael Telatynski
2026-05-26 09:30:02 +01:00
committed by GitHub
parent 5cfb68962d
commit f0342ddbd2
6 changed files with 211 additions and 69 deletions
+1 -2
View File
@@ -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
+42 -24
View File
@@ -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,
});
});
});