1
0
Fork 0
forked from Mirrors/gomuks

web/composer: add emoji autocompletion

This commit is contained in:
Tulir Asokan 2024-10-20 22:08:27 +03:00
parent 9b1b0426c3
commit 8ddf5f800d
12 changed files with 17715 additions and 15 deletions

1
go.mod
View file

@ -21,6 +21,7 @@ require (
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.28.0
golang.org/x/image v0.21.0 golang.org/x/image v0.21.0
golang.org/x/net v0.30.0 golang.org/x/net v0.30.0
golang.org/x/text v0.19.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.21.2-0.20241020164413-e17cb8385518 maunium.net/go/mautrix v0.21.2-0.20241020164413-e17cb8385518

2
go.sum
View file

@ -79,6 +79,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=

View file

@ -19,7 +19,7 @@ import { RoomStateStore } from "@/api/statestore"
import { EventID } from "@/api/types" import { EventID } from "@/api/types"
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
import { LightboxContext } from "./Lightbox.tsx" import { LightboxContext } from "./Lightbox.tsx"
import MessageComposer from "./MessageComposer.tsx" import MessageComposer from "./composer/MessageComposer.tsx"
import TimelineView from "./timeline/TimelineView.tsx" import TimelineView from "./timeline/TimelineView.tsx"
import BackIcon from "@/icons/back.svg?react" import BackIcon from "@/icons/back.svg?react"
import "./RoomView.css" import "./RoomView.css"

View file

@ -0,0 +1,24 @@
div.autocompletions-wrapper {
position: relative;
}
div.autocompletions {
position: absolute;
bottom: 1px;
border-top: 1px solid #ccc;
border-right: 1px solid #ccc;
background-color: #fff;
padding: .5rem;
max-height: 20rem;
overflow: auto;
> .autocompletion-item {
padding: .25rem;
border-radius: .25rem;
cursor: pointer;
&.selected, &:hover {
background-color: #f0f0f0;
}
}
}

View file

@ -0,0 +1,89 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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/>.
import { useEffect } from "react"
import { RoomStateStore } from "@/api/statestore"
import { useFilteredEmojis } from "@/util/emoji"
import useEvent from "@/util/useEvent.ts"
import type { ComposerState } from "./MessageComposer.tsx"
import "./Autocompleter.css"
export interface AutocompleteQuery {
type: "user" | "room" | "emoji"
query: string
startPos: number
endPos: number
frozenQuery?: string
selected?: number
}
export interface AutocompleterProps {
setState: (state: Partial<ComposerState>) => void
setAutocomplete: (params: AutocompleteQuery | null) => void
state: ComposerState
params: AutocompleteQuery
room: RoomStateStore
}
const positiveMod = (val: number, div: number) => (val % div + div) % div
export const EmojiAutocompleter = ({ params, state, setState, setAutocomplete }: AutocompleterProps) => {
const emojis = useFilteredEmojis((params.frozenQuery ?? params.query).slice(1), true)
const onSelect = useEvent((index: number) => {
index = positiveMod(index, emojis.length)
const emoji = emojis[index]
setState({
text: state.text.slice(0, params.startPos) + emoji.u + state.text.slice(params.endPos),
})
setAutocomplete({
...params,
endPos: params.startPos + emoji.u.length,
frozenQuery: params.frozenQuery ?? params.query,
})
})
const onClick = useEvent((evt: React.MouseEvent<HTMLDivElement>) => {
const idx = evt.currentTarget.getAttribute("data-index")
if (idx) {
onSelect(+idx)
setAutocomplete(null)
}
})
useEffect(() => {
if (params.selected !== undefined) {
onSelect(params.selected)
}
}, [onSelect, params.selected])
const selected = params.selected !== undefined ? positiveMod(params.selected, emojis.length) : -1
return <div className="autocompletions">
{emojis.map((emoji, i) => <div
onClick={onClick}
data-index={i}
className={`autocompletion-item ${selected === i ? "selected" : ""}`}
key={emoji.u}
>{emoji.u} :{emoji.n}:</div>)}
</div>
}
export const UserAutocompleter = ({ params }: AutocompleterProps) => {
return <div className="autocompletions">
Autocomplete {params.type} {params.query}
</div>
}
export const RoomAutocompleter = ({ params }: AutocompleterProps) => {
return <div className="autocompletions">
Autocomplete {params.type} {params.query}
</div>
}

View file

@ -16,10 +16,13 @@
import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react" import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners" import { ScaleLoader } from "react-spinners"
import { RoomStateStore, useRoomEvent } from "@/api/statestore" import { RoomStateStore, useRoomEvent } from "@/api/statestore"
import { EventID, MediaMessageEventContent, Mentions, RoomID } from "@/api/types" import type { EventID, MediaMessageEventContent, Mentions, RoomID } from "@/api/types"
import { ClientContext } from "./ClientContext.ts" import useEvent from "@/util/useEvent.ts"
import { ReplyBody } from "./timeline/ReplyBody.tsx" import { ClientContext } from "../ClientContext.ts"
import { useMediaContent } from "./timeline/content/useMediaContent.tsx" import { ReplyBody } from "../timeline/ReplyBody.tsx"
import { useMediaContent } from "../timeline/content/useMediaContent.tsx"
import type { AutocompleteQuery } from "./Autocompleter.tsx"
import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./getAutocompleter.ts"
import AttachIcon from "@/icons/attach.svg?react" import AttachIcon from "@/icons/attach.svg?react"
import CloseIcon from "@/icons/close.svg?react" import CloseIcon from "@/icons/close.svg?react"
import SendIcon from "@/icons/send.svg?react" import SendIcon from "@/icons/send.svg?react"
@ -31,7 +34,7 @@ interface MessageComposerProps {
setReplyToRef: React.RefObject<(evt: EventID | null) => void> setReplyToRef: React.RefObject<(evt: EventID | null) => void>
} }
interface ComposerState { export interface ComposerState {
text: string text: string
media: MediaMessageEventContent | null media: MediaMessageEventContent | null
replyTo: EventID | null replyTo: EventID | null
@ -61,8 +64,11 @@ const draftStore = {
clear: (roomID: RoomID)=> localStorage.removeItem(`draft-${roomID}`), clear: (roomID: RoomID)=> localStorage.removeItem(`draft-${roomID}`),
} }
type CaretEvent<T> = React.MouseEvent<T> | React.KeyboardEvent<T> | React.ChangeEvent<T>
const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComposerProps) => { const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComposerProps) => {
const client = use(ClientContext)! const client = use(ClientContext)!
const [autocomplete, setAutocomplete] = useState<AutocompleteQuery | null>(null)
const [state, setState] = useReducer(composerReducer, uninitedComposer) const [state, setState] = useReducer(composerReducer, uninitedComposer)
const [loadingMedia, setLoadingMedia] = useState(false) const [loadingMedia, setLoadingMedia] = useState(false)
const fileInput = useRef<HTMLInputElement>(null) const fileInput = useRef<HTMLInputElement>(null)
@ -73,12 +79,13 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
setReplyToRef.current = useCallback((evt: EventID | null) => { setReplyToRef.current = useCallback((evt: EventID | null) => {
setState({ replyTo: evt }) setState({ replyTo: evt })
}, []) }, [])
const sendMessage = useCallback((evt: React.FormEvent) => { const sendMessage = useEvent((evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
if (state.text === "" && !state.media) { if (state.text === "" && !state.media) {
return return
} }
setState(emptyComposer) setState(emptyComposer)
setAutocomplete(null)
const mentions: Mentions = { const mentions: Mentions = {
user_ids: [], user_ids: [],
room: false, room: false,
@ -93,13 +100,53 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
reply_to: replyToEvt?.event_id, reply_to: replyToEvt?.event_id,
mentions, mentions,
}).catch(err => window.alert("Failed to send message: " + err)) }).catch(err => window.alert("Failed to send message: " + err))
}, [replyToEvt, state, room, client]) })
const onKeyDown = useCallback((evt: React.KeyboardEvent) => { const onComposerCaretChange = useEvent((evt: CaretEvent<HTMLTextAreaElement>, newText?: string) => {
const area = evt.currentTarget
if (area.selectionStart <= (autocomplete?.startPos ?? 0)) {
if (autocomplete) {
setAutocomplete(null)
}
return
}
if (autocomplete?.frozenQuery) {
if (area.selectionEnd !== autocomplete.endPos) {
setAutocomplete(null)
}
} else if (autocomplete) {
const newQuery = (newText ?? state.text).slice(autocomplete.startPos, area.selectionEnd)
if (newQuery.includes(" ") || (autocomplete.type === "emoji" && !emojiQueryRegex.test(newQuery))) {
setAutocomplete(null)
} else if (newQuery !== autocomplete.query) {
setAutocomplete({ ...autocomplete, query: newQuery, endPos: area.selectionEnd })
}
} else if (area.selectionStart === area.selectionEnd) {
const acType = charToAutocompleteType(newText?.slice(area.selectionStart - 1, area.selectionStart))
if (acType && (area.selectionStart === 1 || newText?.[area.selectionStart - 2] === " ")) {
setAutocomplete({
type: acType,
query: "",
startPos: area.selectionStart - 1,
endPos: area.selectionEnd,
})
}
}
})
const onComposerKeyDown = useEvent((evt: React.KeyboardEvent) => {
if (evt.key === "Enter" && !evt.shiftKey) { if (evt.key === "Enter" && !evt.shiftKey) {
sendMessage(evt) sendMessage(evt)
} }
}, [sendMessage]) if (autocomplete && !evt.ctrlKey && !evt.altKey) {
const onChange = useCallback((evt: React.ChangeEvent<HTMLTextAreaElement>) => { if (!evt.shiftKey && (evt.key === "Tab" || evt.key === "Down")) {
setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? -1) + 1 })
evt.preventDefault()
} else if ((evt.shiftKey && evt.key === "Tab") || (!evt.shiftKey && evt.key === "Up")) {
setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? 0) - 1 })
evt.preventDefault()
}
}
})
const onChange = useEvent((evt: React.ChangeEvent<HTMLTextAreaElement>) => {
setState({ text: evt.target.value }) setState({ text: evt.target.value })
const now = Date.now() const now = Date.now()
if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) { if (evt.target.value !== "" && typingSentAt.current + 5_000 < now) {
@ -111,7 +158,8 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
client.rpc.setTyping(room.roomID, 0) client.rpc.setTyping(room.roomID, 0)
.catch(err => console.error("Failed to send stop typing notification:", err)) .catch(err => console.error("Failed to send stop typing notification:", err))
} }
}, [client, room.roomID]) onComposerCaretChange(evt, evt.target.value)
})
const doUploadFile = useCallback((file: File | null | undefined) => { const doUploadFile = useCallback((file: File | null | undefined) => {
if (!file) { if (!file) {
return return
@ -133,9 +181,8 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
.catch(err => window.alert("Failed to upload file: " + err)) .catch(err => window.alert("Failed to upload file: " + err))
.finally(() => setLoadingMedia(false)) .finally(() => setLoadingMedia(false))
}, [room]) }, [room])
const onAttachFile = useCallback( const onAttachFile = useEvent(
(evt: React.ChangeEvent<HTMLInputElement>) => doUploadFile(evt.target.files?.[0]), (evt: React.ChangeEvent<HTMLInputElement>) => doUploadFile(evt.target.files?.[0]),
[doUploadFile],
) )
useEffect(() => { useEffect(() => {
const listener = (evt: ClipboardEvent) => doUploadFile(evt.clipboardData?.files?.[0]) const listener = (evt: ClipboardEvent) => doUploadFile(evt.clipboardData?.files?.[0])
@ -147,6 +194,7 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
useLayoutEffect(() => { useLayoutEffect(() => {
const draft = draftStore.get(room.roomID) const draft = draftStore.get(room.roomID)
setState(draft ?? emptyComposer) setState(draft ?? emptyComposer)
setAutocomplete(null)
return () => { return () => {
if (typingSentAt.current > 0) { if (typingSentAt.current > 0) {
typingSentAt.current = 0 typingSentAt.current = 0
@ -168,6 +216,7 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
// This has to be called unconditionally, because setting rows = 1 messes up the scroll state otherwise // This has to be called unconditionally, because setting rows = 1 messes up the scroll state otherwise
scrollToBottomRef.current?.() scrollToBottomRef.current?.()
}, [state, scrollToBottomRef]) }, [state, scrollToBottomRef])
// Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect.
useEffect(() => { useEffect(() => {
if (state.uninited) { if (state.uninited) {
return return
@ -184,7 +233,15 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
evt.stopPropagation() evt.stopPropagation()
setState({ replyTo: null }) setState({ replyTo: null })
}, []) }, [])
const Autocompleter = getAutocompleter(autocomplete)
return <div className="message-composer"> return <div className="message-composer">
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
params={autocomplete}
room={room}
state={state}
setState={setState}
setAutocomplete={setAutocomplete}
/></div>}
{replyToEvt && <ReplyBody room={room} event={replyToEvt} onClose={closeReply}/>} {replyToEvt && <ReplyBody room={room} event={replyToEvt} onClose={closeReply}/>}
{loadingMedia && <div className="composer-media"><ScaleLoader/></div>} {loadingMedia && <div className="composer-media"><ScaleLoader/></div>}
{state.media && <ComposerMedia content={state.media} clearMedia={clearMedia}/>} {state.media && <ComposerMedia content={state.media} clearMedia={clearMedia}/>}
@ -194,7 +251,9 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
ref={textInput} ref={textInput}
rows={textRows.current} rows={textRows.current}
value={state.text} value={state.text}
onKeyDown={onKeyDown} onKeyDown={onComposerKeyDown}
onKeyUp={onComposerCaretChange}
onClick={onComposerCaretChange}
onChange={onChange} onChange={onChange}
placeholder="Send a message" placeholder="Send a message"
id="message-composer" id="message-composer"

View file

@ -0,0 +1,56 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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/>.
import {
AutocompleteQuery,
AutocompleterProps,
EmojiAutocompleter,
RoomAutocompleter,
UserAutocompleter,
} from "./Autocompleter.tsx"
export function charToAutocompleteType(newChar?: string): AutocompleteQuery["type"] | null {
switch (newChar) {
case ":":
return "emoji"
case "@":
return "user"
case "#":
return "room"
default:
return null
}
}
export const emojiQueryRegex = /[a-zA-Z0-9_+-]*$/
export function getAutocompleter(params: AutocompleteQuery | null): React.ElementType<AutocompleterProps> | null {
switch (params?.type) {
case "user":
return UserAutocompleter
case "emoji":
if (params.query.length < 3) {
return null
}
return EmojiAutocompleter
case "room":
if (params.query.length < 3) {
return null
}
return RoomAutocompleter
default:
return null
}
}

17204
web/src/util/emoji/data.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,179 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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/>.
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"slices"
"strconv"
"strings"
"go.mau.fi/util/exerrors"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type SkinVariation struct {
Unified string `json:"unified"`
NonQualified *string `json:"non_qualified"`
Image string `json:"image"`
SheetX int `json:"sheet_x"`
SheetY int `json:"sheet_y"`
AddedIn string `json:"added_in"`
HasImgApple bool `json:"has_img_apple"`
HasImgGoogle bool `json:"has_img_google"`
HasImgTwitter bool `json:"has_img_twitter"`
HasImgFacebook bool `json:"has_img_facebook"`
Obsoletes string `json:"obsoletes,omitempty"`
ObsoletedBy string `json:"obsoleted_by,omitempty"`
}
type Emoji struct {
Name string `json:"name"`
Unified string `json:"unified"`
NonQualified *string `json:"non_qualified"`
Docomo *string `json:"docomo"`
Au *string `json:"au"`
Softbank *string `json:"softbank"`
Google *string `json:"google"`
Image string `json:"image"`
SheetX int `json:"sheet_x"`
SheetY int `json:"sheet_y"`
ShortName string `json:"short_name"`
ShortNames []string `json:"short_names"`
Text *string `json:"text"`
Texts []string `json:"texts"`
Category string `json:"category"`
Subcategory string `json:"subcategory"`
SortOrder int `json:"sort_order"`
AddedIn string `json:"added_in"`
HasImgApple bool `json:"has_img_apple"`
HasImgGoogle bool `json:"has_img_google"`
HasImgTwitter bool `json:"has_img_twitter"`
HasImgFacebook bool `json:"has_img_facebook"`
SkinVariations map[string]*SkinVariation `json:"skin_variations,omitempty"`
Obsoletes string `json:"obsoletes,omitempty"`
ObsoletedBy string `json:"obsoleted_by,omitempty"`
}
func unifiedToUnicode(input string) string {
parts := strings.Split(input, "-")
output := make([]rune, len(parts))
for i, part := range parts {
output[i] = rune(exerrors.Must(strconv.ParseInt(part, 16, 32)))
}
return string(output)
}
func getVariationSequences() (output map[string]struct{}) {
variationSequences := exerrors.Must(http.Get("https://www.unicode.org/Public/15.1.0/ucd/emoji/emoji-variation-sequences.txt"))
buf := bufio.NewReader(variationSequences.Body)
output = make(map[string]struct{})
for {
line, err := buf.ReadString('\n')
if errors.Is(err, io.EOF) {
break
} else if err != nil {
panic(err)
}
parts := strings.Split(line, "; ")
if len(parts) < 2 || parts[1] != "emoji style" {
continue
}
unifiedParts := strings.Split(parts[0], " ")
output[unifiedParts[0]] = struct{}{}
}
return
}
type outputEmoji struct {
Unicode string `json:"u"`
Category int `json:"c"`
Title string `json:"t"`
Name string `json:"n"`
Shortcodes []string `json:"s"`
}
type outputData struct {
Emojis []*outputEmoji `json:"e"`
Categories []string `json:"c"`
}
type EmojibaseEmoji struct {
Hexcode string `json:"hexcode"`
Label string `json:"label"`
}
var titler = cases.Title(language.English)
func getEmojibaseNames() map[string]string {
var emojibaseEmojis []EmojibaseEmoji
resp := exerrors.Must(http.Get("https://github.com/milesj/emojibase/raw/refs/heads/master/packages/data/en/compact.raw.json"))
exerrors.PanicIfNotNil(json.NewDecoder(resp.Body).Decode(&emojibaseEmojis))
output := make(map[string]string, len(emojibaseEmojis))
for _, emoji := range emojibaseEmojis {
output[emoji.Hexcode] = titler.String(emoji.Label)
}
return output
}
func main() {
var emojis []Emoji
resp := exerrors.Must(http.Get("https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json"))
exerrors.PanicIfNotNil(json.NewDecoder(resp.Body).Decode(&emojis))
vs := getVariationSequences()
names := getEmojibaseNames()
data := &outputData{
Emojis: make([]*outputEmoji, len(emojis)),
Categories: []string{"Activities", "Animals & Nature", "Component", "Flags", "Food & Drink", "Objects", "People & Body", "Smileys & Emotion", "Symbols", "Travel & Places"},
}
for i, emoji := range emojis {
wrapped := &outputEmoji{
Unicode: unifiedToUnicode(emoji.Unified),
Name: emoji.ShortName,
Shortcodes: emoji.ShortNames,
Category: slices.Index(data.Categories, emoji.Category),
Title: names[emoji.Unified],
}
if wrapped.Category == -1 {
panic(fmt.Errorf("unknown category %q", emoji.Category))
}
for i, short := range wrapped.Shortcodes {
wrapped.Shortcodes[i] = strings.ReplaceAll(short, "_", "")
}
if wrapped.Title == "" {
wrapped.Title = titler.String(emoji.Name)
}
if _, needsVariation := vs[emoji.Unified]; needsVariation {
wrapped.Unicode += "\ufe0f"
}
data.Emojis[i] = wrapped
}
file := exerrors.Must(os.OpenFile("data.json", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644))
enc := json.NewEncoder(file)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
exerrors.PanicIfNotNil(enc.Encode(data))
exerrors.PanicIfNotNil(file.Close())
}

View file

@ -0,0 +1,73 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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/>.
import { useRef } from "react"
import data from "./data.json"
interface Emoji {
u: string // Unicode codepoint
c: number // Category number
t: string // Emoji title
n: string // Primary shortcode
s: string[] // Shortcodes without underscores
}
export const emojis: Emoji[] = data.e
export const categories = data.c
function filter(emojis: Emoji[], query: string): Emoji[] {
return emojis.filter(emoji => emoji.s.some(shortcode => shortcode.includes(query)))
}
function filterAndSort(emojis: Emoji[], query: string): Emoji[] {
return emojis
.map(emoji => {
const matchIndex = emoji.s.reduce((minIndex, shortcode) => {
const index = shortcode.indexOf(query)
return index !== -1 && (minIndex === -1 || index < minIndex) ? index : minIndex
}, -1)
return { emoji, matchIndex }
})
.filter(({ matchIndex }) => matchIndex !== -1)
.sort((e1, e2) => e1.matchIndex - e2.matchIndex)
.map(({ emoji }) => emoji)
}
export function search(query: string, sorted = false, prev?: Emoji[]): Emoji[] {
query = query.toLowerCase().replaceAll("_", "")
if (!query) return emojis
return (sorted ? filterAndSort : filter)(prev ?? emojis, query)
}
interface filteredEmojiCache {
query: string
result: Emoji[]
}
export function useFilteredEmojis(query: string, sorted = false): Emoji[] {
query = query.toLowerCase().replaceAll("_", "")
const prev = useRef<filteredEmojiCache>({ query: "", result: emojis })
if (!query) {
prev.current.query = ""
prev.current.result = emojis
} else if (prev.current.query !== query) {
prev.current.result = (sorted ? filterAndSort : filter)(
query.startsWith(prev.current.query) ? prev.current.result : emojis,
query,
)
prev.current.query = query
}
return prev.current.result
}

13
web/src/util/useEvent.ts Normal file
View file

@ -0,0 +1,13 @@
import { useLayoutEffect, useRef } from "react"
type Fn<Params extends Array<unknown>> = (...args: Params) => void
function useEvent<P extends Array<unknown>>(fn: Fn<P>): (...args: P) => void {
const ref = useRef<[Fn<P>, Fn<P>]>([fn, (...args) => ref[0](...args)]).current
useLayoutEffect(() => {
ref[0] = fn
})
return ref[1]
}
export default useEvent