diff --git a/apps/web/src/@types/matrix-js-sdk.d.ts b/apps/web/src/@types/matrix-js-sdk.d.ts index 3f36190e4e2..59c57102849 100644 --- a/apps/web/src/@types/matrix-js-sdk.d.ts +++ b/apps/web/src/@types/matrix-js-sdk.d.ts @@ -16,6 +16,7 @@ import type { DeviceClientInformation } from "../utils/device/types.ts"; import type { UserWidget } from "../utils/WidgetUtils-types.ts"; import { type MediaPreviewConfig } from "./media_preview.ts"; import { type INVITE_RULES_ACCOUNT_DATA_TYPE, type InviteConfigAccountData } from "./invite-rules.ts"; +import { type LegacyRecentEmojiData } from "../emojipicker/recent.ts"; // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types declare module "matrix-js-sdk/src/types" { @@ -71,9 +72,9 @@ 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[] }; + "io.element.recent_emoji": { recent_emoji: LegacyRecentEmojiData }; "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; // URL preview account data event diff --git a/apps/web/src/emojipicker/recent.ts b/apps/web/src/emojipicker/recent.ts index 05c1321716b..09d88b0aa33 100644 --- a/apps/web/src/emojipicker/recent.ts +++ b/apps/web/src/emojipicker/recent.ts @@ -8,16 +8,13 @@ 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] -} - -// New format tries to be more space efficient for synchronization. Ordered by Date descending. -export type RecentEmojiData = [emoji: string, count: number][]; +export type RecentEmojiData = AccountDataEvents["m.recent_emoji"]["recent_emoji"]; +export type LegacyRecentEmojiData = [emoji: string, count: number][]; const SETTING_NAME = "recent_emoji"; @@ -25,43 +22,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(([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, + ), + ); +} + 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; + 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); + return sorted.slice(0, limit).map(({ emoji }) => emoji); } diff --git a/apps/web/src/settings/handlers/AccountSettingsHandler.ts b/apps/web/src/settings/handlers/AccountSettingsHandler.ts index 29117778d69..5ac1302af47 100644 --- a/apps/web/src/settings/handlers/AccountSettingsHandler.ts +++ b/apps/web/src/settings/handlers/AccountSettingsHandler.ts @@ -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); - - // This is a bit of a hack, but it makes things slightly easier - if (content) content["recent_rooms"] = content["rooms"]; + if (!content?.["recent_rooms"]) { + const legacyContent = this.getSettings(BREADCRUMBS_LEGACY_EVENT_TYPE); + + 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 { 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 { + public async setValue( + settingName: S, + roomId: string, + newValue: Settings[S]["default"], + ): Promise { 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, + 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", + 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", + newValue, + ); // Special case analytics case "pseudonymousAnalyticsOptIn": - return this.setAccountData(ANALYTICS_EVENT_TYPE, "pseudonymousAnalyticsOptIn", newValue); + return this.setAccountData( + ANALYTICS_EVENT_TYPE, + "pseudonymousAnalyticsOptIn", + 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(eventType: E): AccountDataEvents[E] | null; + private getSettings(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(); + 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; + } }