Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/web/src/@types/matrix-js-sdk.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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<string, any>;

// URL preview account data event
Expand Down
61 changes: 36 additions & 25 deletions apps/web/src/emojipicker/recent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,60 +8,71 @@ 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";

// we store more recents than we typically query but this lets us sort by weighted usage
// 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<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;
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);
}
99 changes: 67 additions & 32 deletions apps/web/src/settings/handlers/AccountSettingsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"];
}
}

Expand All @@ -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;
Expand All @@ -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,
newValue: Settings[S]["default"],
): Promise<void> {
switch (settingName) {
// Special case URL previews
case "urlPreviewsEnabled":
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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;
}
}
Loading