diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 6ee3d5f..c691adf 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -51,6 +51,14 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) { return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content) }) + case "set_account_data": + return unmarshalAndCall(req.Data, func(params *setAccountDataParams) (bool, error) { + if params.RoomID != "" { + return true, h.Client.SetRoomAccountData(ctx, params.RoomID, params.Type, params.Content) + } else { + return true, h.Client.SetAccountData(ctx, params.Type, params.Content) + } + }) case "mark_read": return unmarshalAndCall(req.Data, func(params *markReadParams) (bool, error) { return true, h.MarkRead(ctx, params.RoomID, params.EventID, params.ReceiptType) @@ -143,6 +151,12 @@ type sendStateEventParams struct { Content json.RawMessage `json:"content"` } +type setAccountDataParams struct { + RoomID id.RoomID `json:"room_id,omitempty"` + Type string `json:"type"` + Content json.RawMessage `json:"content"` +} + type markReadParams struct { RoomID id.RoomID `json:"room_id"` EventID id.EventID `json:"event_id"` diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2cac000..1184d05 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -16,7 +16,7 @@ import { CachedEventDispatcher } from "../util/eventdispatcher.ts" import RPCClient, { SendMessageParams } from "./rpc.ts" import { RoomStateStore, StateStore } from "./statestore" -import type { ClientState, EventID, EventType, RPCEvent, RoomID, UserID } from "./types" +import type { ClientState, EventID, EventType, RPCEvent, RoomID, RoomStateGUID, UserID } from "./types" export default class Client { readonly state = new CachedEventDispatcher() @@ -101,6 +101,46 @@ export default class Client { } } + async incrementFrequentlyUsedEmoji(targetEmoji: string) { + let recentEmoji = this.store.accountData.get("io.element.recent_emoji")?.recent_emoji as + [string, number][] | undefined + if (!Array.isArray(recentEmoji)) { + recentEmoji = [] + } + let found = false + for (const [idx, [emoji, count]] of recentEmoji.entries()) { + if (emoji === targetEmoji) { + recentEmoji.splice(idx, 1) + recentEmoji.unshift([emoji, count + 1]) + found = true + break + } + } + if (!found) { + recentEmoji.unshift([targetEmoji, 1]) + } + if (recentEmoji.length > 100) { + recentEmoji.pop() + } + const newContent = { recent_emoji: recentEmoji } + this.store.accountData.set("io.element.recent_emoji", newContent) + await this.rpc.setAccountData("io.element.recent_emoji", newContent) + } + + async loadSpecificRoomState(keys: RoomStateGUID[]): Promise { + const missingKeys = keys.filter(key => { + const room = this.store.rooms.get(key.room_id) + return room && room.getStateEvent(key.type, key.state_key) === undefined + }) + if (missingKeys.length === 0) { + return + } + const events = await this.rpc.getSpecificRoomState(missingKeys) + for (const evt of events) { + this.store.rooms.get(evt.room_id)?.applyState(evt) + } + } + async loadRoomState(roomID: RoomID, refetch = false): Promise { const room = this.store.rooms.get(roomID) if (!room) { diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index ac9b53e..8f3a2a2 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -31,6 +31,7 @@ import type { ResolveAliasResponse, RoomAlias, RoomID, + RoomStateGUID, TimelineRowID, UserID, } from "./types" @@ -138,6 +139,10 @@ export default abstract class RPCClient { return this.request("set_state", { room_id, type, state_key, content }) } + setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise { + return this.request("set_account_data", { type, content, room_id }) + } + markRead(room_id: RoomID, event_id: EventID, receipt_type: ReceiptType = "m.read"): Promise { return this.request("mark_read", { room_id, event_id, receipt_type }) } @@ -150,6 +155,10 @@ export default abstract class RPCClient { return this.request("ensure_group_session_shared", { room_id }) } + getSpecificRoomState(keys: RoomStateGUID[]): Promise { + return this.request("get_specific_room_state", { keys }) + } + getRoomState(room_id: RoomID, fetch_members = false, refetch = false): Promise { return this.request("get_room_state", { room_id, fetch_members, refetch }) } diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index 04cd526..b3c725e 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -14,7 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { useSyncExternalStore } from "react" -import type { EventID, EventType, MemDBEvent } from "../types" +import type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types" +import { StateStore } from "./main.ts" import { RoomStateStore } from "./room.ts" export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { @@ -42,3 +43,10 @@ export function useRoomEvent(room: RoomStateStore, eventID: EventID | null): Mem eventID ? (() => room.eventsByID.get(eventID) ?? null) : returnNull, ) } + +export function useAccountData(ss: StateStore, type: EventType): UnknownEventContent | null { + return useSyncExternalStore( + ss.accountDataSubs.getSubscriber(type), + () => ss.accountData.get(type) ?? null, + ) +} diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 7ef7434..b76670e 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -17,6 +17,7 @@ import { getAvatarURL } from "@/api/media.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { focused } from "@/util/focus.ts" import toSearchableString from "@/util/searchablestring.ts" +import { MultiSubscribable } from "@/util/subscribable.ts" import type { ContentURI, EventRowID, @@ -26,6 +27,7 @@ import type { SendCompleteData, SyncCompleteData, SyncRoom, + UnknownEventContent, UserID, } from "../types" import { RoomStateStore } from "./room.ts" @@ -47,6 +49,9 @@ export interface RoomListEntry { export class StateStore { readonly rooms: Map = new Map() readonly roomList = new NonNullCachedEventDispatcher([]) + readonly accountData: Map = new Map() + readonly accountDataSubs = new MultiSubscribable() + #frequentlyUsedEmoji: Map | null = null switchRoom?: (roomID: RoomID) => void imageAuthToken?: string @@ -137,6 +142,13 @@ export class StateStore { } } } + for (const ad of Object.values(sync.account_data)) { + if (ad.type === "io.element.recent_emoji") { + this.#frequentlyUsedEmoji = null + } + this.accountData.set(ad.type, ad.content) + this.accountDataSubs.notify(ad.type) + } for (const roomID of sync.left_rooms) { this.rooms.delete(roomID) changedRoomListEntries.set(roomID, null) @@ -172,6 +184,22 @@ export class StateStore { } } + get frequentlyUsedEmoji(): Map { + if (this.#frequentlyUsedEmoji === null) { + const emojiData = this.accountData.get("io.element.recent_emoji") + try { + const recentList = emojiData?.recent_emoji as [string, number][] | undefined + this.#frequentlyUsedEmoji = new Map(recentList?.toSorted( + ([, count1], [, count2]) => count2 - count1, + )) + } catch (err) { + console.warn("Failed to parse recent emoji data", err, emojiData?.recent_emoji) + this.#frequentlyUsedEmoji = new Map() + } + } + return this.#frequentlyUsedEmoji + } + showNotification(room: RoomStateStore, rowid: EventRowID, sound: boolean) { const evt = room.eventsByRowID.get(rowid) if (!evt || typeof evt.content.body !== "string") { diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index ffca5e9..11833fa 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -220,6 +220,20 @@ export class RoomStateStore { this.notifyTimelineSubscribers() } + applyState(evt: RawDBEvent) { + if (evt.state_key === undefined) { + throw new Error(`Event ${evt.event_id} is missing state key`) + } + this.applyEvent(evt) + let stateMap = this.state.get(evt.type) + if (!stateMap) { + stateMap = new Map() + this.state.set(evt.type, stateMap) + } + stateMap.set(evt.state_key, evt.rowid) + this.stateSubs.notify(this.stateSubKey(evt.type, evt.state_key)) + } + applyFullState(state: RawDBEvent[]) { const newStateMap: Map> = new Map() for (const evt of state) { diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index a122ab9..d329a22 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -130,14 +130,14 @@ export interface MemDBEvent extends BaseDBEvent { export interface DBAccountData { user_id: UserID type: EventType - content: unknown + content: UnknownEventContent } export interface DBRoomAccountData { user_id: UserID room_id: RoomID type: EventType - content: unknown + content: UnknownEventContent } export interface PaginationResponse { @@ -163,3 +163,9 @@ export interface ClientWellKnown { base_url: string } } + +export interface RoomStateGUID { + room_id: RoomID + type: EventType + state_key: string +} diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 95c1481..28126d5 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -164,3 +164,28 @@ export interface LocationMessageEventContent extends BaseMessageEventContent { } export type MessageEventContent = TextMessageEventContent | MediaMessageEventContent | LocationMessageEventContent + +export type ImagePackUsage = "emoticon" | "sticker" + +export interface ImagePackEntry { + url: ContentURI + info?: MediaInfo + usage?: ImagePackUsage[] +} + +export interface ImagePack { + images: Record + pack: { + display_name?: string + avatar_url?: ContentURI + usage?: ImagePackUsage[] + } +} + +export interface ImagePackRooms { + rooms: Record>> +} + +export interface ElementRecentEmoji { + recent_emoji: [string, number][] +} diff --git a/web/src/ui/composer/Autocompleter.tsx b/web/src/ui/composer/Autocompleter.tsx index b9b005c..3b98da3 100644 --- a/web/src/ui/composer/Autocompleter.tsx +++ b/web/src/ui/composer/Autocompleter.tsx @@ -13,11 +13,12 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { JSX, useEffect } from "react" +import { JSX, use, useEffect } from "react" import { getAvatarURL } from "@/api/media.ts" import { RoomStateStore } from "@/api/statestore" import { Emoji, useFilteredEmojis } from "@/util/emoji" import useEvent from "@/util/useEvent.ts" +import { ClientContext } from "../ClientContext.ts" import type { ComposerState } from "./MessageComposer.tsx" import { AutocompleteUser, useFilteredMembers } from "./userautocomplete.ts" import "./Autocompleter.css" @@ -98,7 +99,11 @@ const emojiFuncs = { } export const EmojiAutocompleter = ({ params, ...rest }: AutocompleterProps) => { - const items = useFilteredEmojis((params.frozenQuery ?? params.query).slice(1), true) + const client = use(ClientContext)! + const items = useFilteredEmojis((params.frozenQuery ?? params.query).slice(1), { + sorted: true, + frequentlyUsed: client.store.frequentlyUsedEmoji, + }) return useAutocompleter({ params, ...rest, items, ...emojiFuncs }) } diff --git a/web/src/ui/emojipicker/EmojiPicker.tsx b/web/src/ui/emojipicker/EmojiPicker.tsx index 6db4a89..310a60b 100644 --- a/web/src/ui/emojipicker/EmojiPicker.tsx +++ b/web/src/ui/emojipicker/EmojiPicker.tsx @@ -15,7 +15,8 @@ // along with this program. If not, see . import { CSSProperties, JSX, use, useCallback, useState } from "react" import { getMediaURL } from "@/api/media.ts" -import { Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji" +import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji" +import { ClientContext } from "../ClientContext.ts" import { ModalCloseContext } from "../modal/Modal.tsx" import CloseIcon from "@/icons/close.svg?react" import ActivitiesIcon from "@/icons/emoji-categories/activities.svg?react" @@ -65,8 +66,12 @@ interface EmojiPickerProps { } export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnSelect }: EmojiPickerProps) => { + const client = use(ClientContext)! const [query, setQuery] = useState("") - const emojis = useFilteredEmojis(query) + const emojis = useFilteredEmojis(query, { + frequentlyUsed: client.store.frequentlyUsedEmoji, + frequentlyUsedAsCategory: true, + }) const [previewEmoji, setPreviewEmoji] = useState() const clearQuery = useCallback(() => setQuery(""), []) const cats: JSX.Element[] = [] @@ -75,16 +80,14 @@ export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnS const close = use(ModalCloseContext) const onSelectWrapped = (emoji: PartialEmoji) => { onSelect(emoji, selected?.includes(emoji.u)) + if (emoji.c) { + client.incrementFrequentlyUsedEmoji(emoji.u) + .catch(err => console.error("Failed to increment frequently used emoji", err)) + } if (closeOnSelect) { close() } } - cats.push(
-

Frequently Used

-
- {/* TODO */} -
-
) for (const emoji of emojis) { if (emoji.c === 2) { continue @@ -103,7 +106,7 @@ export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnS currentCat = [] } currentCat.push( {sortedEmojiCategories.map(cat =>