web/emojipicker: add support for frequently used emojis

This commit is contained in:
Tulir Asokan 2024-10-25 23:02:58 +03:00
parent 1e73867b9b
commit 227ba474ef
11 changed files with 223 additions and 31 deletions

View file

@ -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 unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) {
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content) 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": case "mark_read":
return unmarshalAndCall(req.Data, func(params *markReadParams) (bool, error) { return unmarshalAndCall(req.Data, func(params *markReadParams) (bool, error) {
return true, h.MarkRead(ctx, params.RoomID, params.EventID, params.ReceiptType) return true, h.MarkRead(ctx, params.RoomID, params.EventID, params.ReceiptType)
@ -143,6 +151,12 @@ type sendStateEventParams struct {
Content json.RawMessage `json:"content"` 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 { type markReadParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
EventID id.EventID `json:"event_id"` EventID id.EventID `json:"event_id"`

View file

@ -16,7 +16,7 @@
import { CachedEventDispatcher } from "../util/eventdispatcher.ts" import { CachedEventDispatcher } from "../util/eventdispatcher.ts"
import RPCClient, { SendMessageParams } from "./rpc.ts" import RPCClient, { SendMessageParams } from "./rpc.ts"
import { RoomStateStore, StateStore } from "./statestore" 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 { export default class Client {
readonly state = new CachedEventDispatcher<ClientState>() readonly state = new CachedEventDispatcher<ClientState>()
@ -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<void> {
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<void> { async loadRoomState(roomID: RoomID, refetch = false): Promise<void> {
const room = this.store.rooms.get(roomID) const room = this.store.rooms.get(roomID)
if (!room) { if (!room) {

View file

@ -31,6 +31,7 @@ import type {
ResolveAliasResponse, ResolveAliasResponse,
RoomAlias, RoomAlias,
RoomID, RoomID,
RoomStateGUID,
TimelineRowID, TimelineRowID,
UserID, UserID,
} from "./types" } from "./types"
@ -138,6 +139,10 @@ export default abstract class RPCClient {
return this.request("set_state", { room_id, type, state_key, content }) return this.request("set_state", { room_id, type, state_key, content })
} }
setAccountData(type: EventType, content: unknown, room_id?: RoomID): Promise<boolean> {
return this.request("set_account_data", { type, content, room_id })
}
markRead(room_id: RoomID, event_id: EventID, receipt_type: ReceiptType = "m.read"): Promise<boolean> { markRead(room_id: RoomID, event_id: EventID, receipt_type: ReceiptType = "m.read"): Promise<boolean> {
return this.request("mark_read", { room_id, event_id, receipt_type }) 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 }) return this.request("ensure_group_session_shared", { room_id })
} }
getSpecificRoomState(keys: RoomStateGUID[]): Promise<RawDBEvent[]> {
return this.request("get_specific_room_state", { keys })
}
getRoomState(room_id: RoomID, fetch_members = false, refetch = false): Promise<RawDBEvent[]> { getRoomState(room_id: RoomID, fetch_members = false, refetch = false): Promise<RawDBEvent[]> {
return this.request("get_room_state", { room_id, fetch_members, refetch }) return this.request("get_room_state", { room_id, fetch_members, refetch })
} }

View file

@ -14,7 +14,8 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useSyncExternalStore } from "react" 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" import { RoomStateStore } from "./room.ts"
export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { 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, 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,
)
}

View file

@ -17,6 +17,7 @@ import { getAvatarURL } from "@/api/media.ts"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
import { focused } from "@/util/focus.ts" import { focused } from "@/util/focus.ts"
import toSearchableString from "@/util/searchablestring.ts" import toSearchableString from "@/util/searchablestring.ts"
import { MultiSubscribable } from "@/util/subscribable.ts"
import type { import type {
ContentURI, ContentURI,
EventRowID, EventRowID,
@ -26,6 +27,7 @@ import type {
SendCompleteData, SendCompleteData,
SyncCompleteData, SyncCompleteData,
SyncRoom, SyncRoom,
UnknownEventContent,
UserID, UserID,
} from "../types" } from "../types"
import { RoomStateStore } from "./room.ts" import { RoomStateStore } from "./room.ts"
@ -47,6 +49,9 @@ export interface RoomListEntry {
export class StateStore { export class StateStore {
readonly rooms: Map<RoomID, RoomStateStore> = new Map() readonly rooms: Map<RoomID, RoomStateStore> = new Map()
readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([]) readonly roomList = new NonNullCachedEventDispatcher<RoomListEntry[]>([])
readonly accountData: Map<string, UnknownEventContent> = new Map()
readonly accountDataSubs = new MultiSubscribable()
#frequentlyUsedEmoji: Map<string, number> | null = null
switchRoom?: (roomID: RoomID) => void switchRoom?: (roomID: RoomID) => void
imageAuthToken?: string 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) { for (const roomID of sync.left_rooms) {
this.rooms.delete(roomID) this.rooms.delete(roomID)
changedRoomListEntries.set(roomID, null) changedRoomListEntries.set(roomID, null)
@ -172,6 +184,22 @@ export class StateStore {
} }
} }
get frequentlyUsedEmoji(): Map<string, number> {
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) { showNotification(room: RoomStateStore, rowid: EventRowID, sound: boolean) {
const evt = room.eventsByRowID.get(rowid) const evt = room.eventsByRowID.get(rowid)
if (!evt || typeof evt.content.body !== "string") { if (!evt || typeof evt.content.body !== "string") {

View file

@ -220,6 +220,20 @@ export class RoomStateStore {
this.notifyTimelineSubscribers() 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[]) { applyFullState(state: RawDBEvent[]) {
const newStateMap: Map<EventType, Map<string, EventRowID>> = new Map() const newStateMap: Map<EventType, Map<string, EventRowID>> = new Map()
for (const evt of state) { for (const evt of state) {

View file

@ -130,14 +130,14 @@ export interface MemDBEvent extends BaseDBEvent {
export interface DBAccountData { export interface DBAccountData {
user_id: UserID user_id: UserID
type: EventType type: EventType
content: unknown content: UnknownEventContent
} }
export interface DBRoomAccountData { export interface DBRoomAccountData {
user_id: UserID user_id: UserID
room_id: RoomID room_id: RoomID
type: EventType type: EventType
content: unknown content: UnknownEventContent
} }
export interface PaginationResponse { export interface PaginationResponse {
@ -163,3 +163,9 @@ export interface ClientWellKnown {
base_url: string base_url: string
} }
} }
export interface RoomStateGUID {
room_id: RoomID
type: EventType
state_key: string
}

View file

@ -164,3 +164,28 @@ export interface LocationMessageEventContent extends BaseMessageEventContent {
} }
export type MessageEventContent = TextMessageEventContent | MediaMessageEventContent | LocationMessageEventContent 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<string, ImagePackEntry>
pack: {
display_name?: string
avatar_url?: ContentURI
usage?: ImagePackUsage[]
}
}
export interface ImagePackRooms {
rooms: Record<RoomID, Record<string, Record<string, never>>>
}
export interface ElementRecentEmoji {
recent_emoji: [string, number][]
}

View file

@ -13,11 +13,12 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX, useEffect } from "react" import { JSX, use, useEffect } from "react"
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore } from "@/api/statestore"
import { Emoji, useFilteredEmojis } from "@/util/emoji" import { Emoji, useFilteredEmojis } from "@/util/emoji"
import useEvent from "@/util/useEvent.ts" import useEvent from "@/util/useEvent.ts"
import { ClientContext } from "../ClientContext.ts"
import type { ComposerState } from "./MessageComposer.tsx" import type { ComposerState } from "./MessageComposer.tsx"
import { AutocompleteUser, useFilteredMembers } from "./userautocomplete.ts" import { AutocompleteUser, useFilteredMembers } from "./userautocomplete.ts"
import "./Autocompleter.css" import "./Autocompleter.css"
@ -98,7 +99,11 @@ const emojiFuncs = {
} }
export const EmojiAutocompleter = ({ params, ...rest }: AutocompleterProps) => { 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 }) return useAutocompleter({ params, ...rest, items, ...emojiFuncs })
} }

View file

@ -15,7 +15,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CSSProperties, JSX, use, useCallback, useState } from "react" import { CSSProperties, JSX, use, useCallback, useState } from "react"
import { getMediaURL } from "@/api/media.ts" 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 { ModalCloseContext } from "../modal/Modal.tsx"
import CloseIcon from "@/icons/close.svg?react" import CloseIcon from "@/icons/close.svg?react"
import ActivitiesIcon from "@/icons/emoji-categories/activities.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) => { export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnSelect }: EmojiPickerProps) => {
const client = use(ClientContext)!
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const emojis = useFilteredEmojis(query) const emojis = useFilteredEmojis(query, {
frequentlyUsed: client.store.frequentlyUsedEmoji,
frequentlyUsedAsCategory: true,
})
const [previewEmoji, setPreviewEmoji] = useState<Emoji>() const [previewEmoji, setPreviewEmoji] = useState<Emoji>()
const clearQuery = useCallback(() => setQuery(""), []) const clearQuery = useCallback(() => setQuery(""), [])
const cats: JSX.Element[] = [] const cats: JSX.Element[] = []
@ -75,16 +80,14 @@ export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnS
const close = use(ModalCloseContext) const close = use(ModalCloseContext)
const onSelectWrapped = (emoji: PartialEmoji) => { const onSelectWrapped = (emoji: PartialEmoji) => {
onSelect(emoji, selected?.includes(emoji.u)) 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) { if (closeOnSelect) {
close() close()
} }
} }
cats.push(<div className="emoji-category" data-emoji-category="Frequently Used">
<h4 className="emoji-category-name">Frequently Used</h4>
<div className="emoji-category-list">
{/* TODO */}
</div>
</div>)
for (const emoji of emojis) { for (const emoji of emojis) {
if (emoji.c === 2) { if (emoji.c === 2) {
continue continue
@ -103,7 +106,7 @@ export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnS
currentCat = [] currentCat = []
} }
currentCat.push(<button currentCat.push(<button
key={emoji.u} key={emoji.c === CATEGORY_FREQUENTLY_USED ? `freq-${emoji.u}` : emoji.u}
className={`emoji ${selected?.includes(emoji.u) ? "selected" : ""}`} className={`emoji ${selected?.includes(emoji.u) ? "selected" : ""}`}
onMouseOver={() => setPreviewEmoji(emoji)} onMouseOver={() => setPreviewEmoji(emoji)}
onMouseOut={() => setPreviewEmoji(undefined)} onMouseOut={() => setPreviewEmoji(undefined)}
@ -128,7 +131,8 @@ export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnS
<div className="emoji-category-bar"> <div className="emoji-category-bar">
<button <button
className="emoji-category-icon" className="emoji-category-icon"
title="Frequently Used" title={CATEGORY_FREQUENTLY_USED}
onClick={onClickCategoryButton}
>{<RecentIcon/>}</button> >{<RecentIcon/>}</button>
{sortedEmojiCategories.map(cat => {sortedEmojiCategories.map(cat =>
<button <button

View file

@ -13,28 +13,39 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useRef } from "react" import { useMemo, useRef } from "react"
import { EventID, ReactionEventContent } from "@/api/types" import { EventID, ReactionEventContent } from "@/api/types"
import data from "./data.json" import data from "./data.json"
export interface PartialEmoji { export interface EmojiMetadata {
u: string // Unicode codepoint or custom emoji mxc:// URI c: number | string // Category number or custom emoji pack name
c?: number | string // Category number or custom emoji pack name t: string // Emoji title
t?: string // Emoji title n: string // Primary shortcode
n?: string // Primary shortcode s: string[] // Shortcodes without underscores
s?: string[] // Shortcodes without underscores
} }
export type Emoji = Required<PartialEmoji> export interface EmojiText {
u: string // Unicode codepoint or custom emoji mxc:// URI
}
export type PartialEmoji = EmojiText & Partial<EmojiMetadata>
export type Emoji = EmojiText & EmojiMetadata
export const emojis: Emoji[] = data.e export const emojis: Emoji[] = data.e
export const emojiMap = new Map<string, Emoji>()
export const categories = data.c export const categories = data.c
export const CATEGORY_FREQUENTLY_USED = "Frequently Used"
for (const emoji of emojis) {
emojiMap.set(emoji.u, emoji)
}
function filter(emojis: Emoji[], query: string): Emoji[] { function filter(emojis: Emoji[], query: string): Emoji[] {
return emojis.filter(emoji => emoji.s.some(shortcode => shortcode.includes(query))) return emojis.filter(emoji => emoji.s.some(shortcode => shortcode.includes(query)))
} }
function filterAndSort(emojis: Emoji[], query: string): Emoji[] { function filterAndSort(emojis: Emoji[], query: string, frequentlyUsed?: Map<string, number>): Emoji[] {
return emojis return emojis
.map(emoji => { .map(emoji => {
const matchIndex = emoji.s.reduce((minIndex, shortcode) => { const matchIndex = emoji.s.reduce((minIndex, shortcode) => {
@ -44,7 +55,10 @@ function filterAndSort(emojis: Emoji[], query: string): Emoji[] {
return { emoji, matchIndex } return { emoji, matchIndex }
}) })
.filter(({ matchIndex }) => matchIndex !== -1) .filter(({ matchIndex }) => matchIndex !== -1)
.sort((e1, e2) => e1.matchIndex - e2.matchIndex) .sort((e1, e2) =>
e1.matchIndex === e2.matchIndex
? (frequentlyUsed?.get(e2.emoji.u) ?? 0) - (frequentlyUsed?.get(e1.emoji.u) ?? 0)
: e1.matchIndex - e2.matchIndex)
.map(({ emoji }) => emoji) .map(({ emoji }) => emoji)
} }
@ -80,16 +94,41 @@ interface filteredEmojiCache {
result: Emoji[] result: Emoji[]
} }
export function useFilteredEmojis(query: string, sorted = false): Emoji[] { interface useFilteredEmojisParams {
sorted?: boolean
frequentlyUsed?: Map<string, number>
frequentlyUsedAsCategory?: boolean
}
export function useFilteredEmojis(query: string, params: useFilteredEmojisParams = {}): Emoji[] {
query = query.toLowerCase().replaceAll("_", "") query = query.toLowerCase().replaceAll("_", "")
const prev = useRef<filteredEmojiCache>({ query: "", result: emojis }) const allEmojis: Emoji[] = useMemo(() => {
let output: Emoji[] = []
if (params.frequentlyUsedAsCategory && params.frequentlyUsed) {
output = Array.from(params.frequentlyUsed.keys()
.map(key => {
const emoji = emojiMap.get(key)
if (!emoji) {
return undefined
}
return { ...emoji, c: CATEGORY_FREQUENTLY_USED } as Emoji
})
.filter((emoji, index): emoji is Emoji => emoji !== undefined && index < 24))
}
if (output.length === 0) {
return emojis
}
return output.concat(emojis)
}, [params.frequentlyUsed, params.frequentlyUsedAsCategory])
const prev = useRef<filteredEmojiCache>({ query: "", result: allEmojis })
if (!query) { if (!query) {
prev.current.query = "" prev.current.query = ""
prev.current.result = emojis prev.current.result = allEmojis
} else if (prev.current.query !== query) { } else if (prev.current.query !== query) {
prev.current.result = (sorted ? filterAndSort : filter)( prev.current.result = (params.sorted ? filterAndSort : filter)(
query.startsWith(prev.current.query) ? prev.current.result : emojis, query.startsWith(prev.current.query) ? prev.current.result : allEmojis,
query, query,
params.frequentlyUsed,
) )
prev.current.query = query prev.current.query = query
} }