mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/emojipicker: add support for frequently used emojis
This commit is contained in:
parent
1e73867b9b
commit
227ba474ef
11 changed files with 223 additions and 31 deletions
|
@ -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"`
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -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") {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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][]
|
||||||
|
}
|
||||||
|
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue