forked from Mirrors/gomuks
web/composer: add emoji autocompletion
This commit is contained in:
parent
9b1b0426c3
commit
8ddf5f800d
12 changed files with 17715 additions and 15 deletions
1
go.mod
1
go.mod
|
@ -21,6 +21,7 @@ require (
|
|||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/image v0.21.0
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/text v0.19.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
maunium.net/go/mauflag v1.0.0
|
||||
maunium.net/go/mautrix v0.21.2-0.20241020164413-e17cb8385518
|
||||
|
|
2
go.sum
2
go.sum
|
@ -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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
|
|
|
@ -19,7 +19,7 @@ import { RoomStateStore } from "@/api/statestore"
|
|||
import { EventID } from "@/api/types"
|
||||
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import { LightboxContext } from "./Lightbox.tsx"
|
||||
import MessageComposer from "./MessageComposer.tsx"
|
||||
import MessageComposer from "./composer/MessageComposer.tsx"
|
||||
import TimelineView from "./timeline/TimelineView.tsx"
|
||||
import BackIcon from "@/icons/back.svg?react"
|
||||
import "./RoomView.css"
|
||||
|
|
24
web/src/ui/composer/Autocompleter.css
Normal file
24
web/src/ui/composer/Autocompleter.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
89
web/src/ui/composer/Autocompleter.tsx
Normal file
89
web/src/ui/composer/Autocompleter.tsx
Normal 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>
|
||||
}
|
|
@ -16,10 +16,13 @@
|
|||
import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
|
||||
import { ScaleLoader } from "react-spinners"
|
||||
import { RoomStateStore, useRoomEvent } from "@/api/statestore"
|
||||
import { EventID, MediaMessageEventContent, Mentions, RoomID } from "@/api/types"
|
||||
import { ClientContext } from "./ClientContext.ts"
|
||||
import { ReplyBody } from "./timeline/ReplyBody.tsx"
|
||||
import { useMediaContent } from "./timeline/content/useMediaContent.tsx"
|
||||
import type { EventID, MediaMessageEventContent, Mentions, RoomID } from "@/api/types"
|
||||
import useEvent from "@/util/useEvent.ts"
|
||||
import { ClientContext } from "../ClientContext.ts"
|
||||
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 CloseIcon from "@/icons/close.svg?react"
|
||||
import SendIcon from "@/icons/send.svg?react"
|
||||
|
@ -31,7 +34,7 @@ interface MessageComposerProps {
|
|||
setReplyToRef: React.RefObject<(evt: EventID | null) => void>
|
||||
}
|
||||
|
||||
interface ComposerState {
|
||||
export interface ComposerState {
|
||||
text: string
|
||||
media: MediaMessageEventContent | null
|
||||
replyTo: EventID | null
|
||||
|
@ -61,8 +64,11 @@ const draftStore = {
|
|||
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 client = use(ClientContext)!
|
||||
const [autocomplete, setAutocomplete] = useState<AutocompleteQuery | null>(null)
|
||||
const [state, setState] = useReducer(composerReducer, uninitedComposer)
|
||||
const [loadingMedia, setLoadingMedia] = useState(false)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
|
@ -73,12 +79,13 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
|
|||
setReplyToRef.current = useCallback((evt: EventID | null) => {
|
||||
setState({ replyTo: evt })
|
||||
}, [])
|
||||
const sendMessage = useCallback((evt: React.FormEvent) => {
|
||||
const sendMessage = useEvent((evt: React.FormEvent) => {
|
||||
evt.preventDefault()
|
||||
if (state.text === "" && !state.media) {
|
||||
return
|
||||
}
|
||||
setState(emptyComposer)
|
||||
setAutocomplete(null)
|
||||
const mentions: Mentions = {
|
||||
user_ids: [],
|
||||
room: false,
|
||||
|
@ -93,13 +100,53 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
|
|||
reply_to: replyToEvt?.event_id,
|
||||
mentions,
|
||||
}).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) {
|
||||
sendMessage(evt)
|
||||
}
|
||||
}, [sendMessage])
|
||||
const onChange = useCallback((evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (autocomplete && !evt.ctrlKey && !evt.altKey) {
|
||||
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 })
|
||||
const now = Date.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)
|
||||
.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) => {
|
||||
if (!file) {
|
||||
return
|
||||
|
@ -133,9 +181,8 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
|
|||
.catch(err => window.alert("Failed to upload file: " + err))
|
||||
.finally(() => setLoadingMedia(false))
|
||||
}, [room])
|
||||
const onAttachFile = useCallback(
|
||||
const onAttachFile = useEvent(
|
||||
(evt: React.ChangeEvent<HTMLInputElement>) => doUploadFile(evt.target.files?.[0]),
|
||||
[doUploadFile],
|
||||
)
|
||||
useEffect(() => {
|
||||
const listener = (evt: ClipboardEvent) => doUploadFile(evt.clipboardData?.files?.[0])
|
||||
|
@ -147,6 +194,7 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
|
|||
useLayoutEffect(() => {
|
||||
const draft = draftStore.get(room.roomID)
|
||||
setState(draft ?? emptyComposer)
|
||||
setAutocomplete(null)
|
||||
return () => {
|
||||
if (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
|
||||
scrollToBottomRef.current?.()
|
||||
}, [state, scrollToBottomRef])
|
||||
// Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect.
|
||||
useEffect(() => {
|
||||
if (state.uninited) {
|
||||
return
|
||||
|
@ -184,7 +233,15 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
|
|||
evt.stopPropagation()
|
||||
setState({ replyTo: null })
|
||||
}, [])
|
||||
const Autocompleter = getAutocompleter(autocomplete)
|
||||
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}/>}
|
||||
{loadingMedia && <div className="composer-media"><ScaleLoader/></div>}
|
||||
{state.media && <ComposerMedia content={state.media} clearMedia={clearMedia}/>}
|
||||
|
@ -194,7 +251,9 @@ const MessageComposer = ({ room, scrollToBottomRef, setReplyToRef }: MessageComp
|
|||
ref={textInput}
|
||||
rows={textRows.current}
|
||||
value={state.text}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDown={onComposerKeyDown}
|
||||
onKeyUp={onComposerCaretChange}
|
||||
onClick={onComposerCaretChange}
|
||||
onChange={onChange}
|
||||
placeholder="Send a message"
|
||||
id="message-composer"
|
56
web/src/ui/composer/getAutocompleter.ts
Normal file
56
web/src/ui/composer/getAutocompleter.ts
Normal 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
17204
web/src/util/emoji/data.json
Normal file
File diff suppressed because it is too large
Load diff
179
web/src/util/emoji/generate.go
Normal file
179
web/src/util/emoji/generate.go
Normal 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())
|
||||
}
|
73
web/src/util/emoji/index.ts
Normal file
73
web/src/util/emoji/index.ts
Normal 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
13
web/src/util/useEvent.ts
Normal 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
|
Loading…
Add table
Reference in a new issue