forked from Mirrors/gomuks
web/emojipicker: add subscribe button for custom emoji packs
This commit is contained in:
parent
ac3b906211
commit
d9d0718bc6
9 changed files with 135 additions and 14 deletions
|
@ -10,6 +10,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"go.mau.fi/util/dbutil"
|
||||
"maunium.net/go/mautrix/event"
|
||||
|
@ -28,16 +29,17 @@ const (
|
|||
deleteCurrentStateQuery = `
|
||||
DELETE FROM current_state WHERE room_id = $1
|
||||
`
|
||||
getCurrentRoomStateQuery = `
|
||||
getCurrentRoomStateBaseQuery = `
|
||||
SELECT event.rowid, -1,
|
||||
event.room_id, event.event_id, sender, event.type, event.state_key, timestamp, content, decrypted, decrypted_type,
|
||||
unsigned, local_content, transaction_id, redacted_by, relates_to, relation_type,
|
||||
megolm_session_id, decryption_error, send_error, reactions, last_edit_rowid, unread_type
|
||||
FROM current_state cs
|
||||
JOIN event ON cs.event_rowid = event.rowid
|
||||
WHERE cs.room_id = $1
|
||||
`
|
||||
getCurrentStateEventQuery = getCurrentRoomStateQuery + `AND cs.event_type = $2 AND cs.state_key = $3`
|
||||
getCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1`
|
||||
getManyCurrentRoomStateQuery = getCurrentRoomStateBaseQuery + `WHERE (cs.room_id, cs.event_type, cs.state_key) IN (%s)`
|
||||
getCurrentStateEventQuery = getCurrentRoomStateBaseQuery + `WHERE cs.room_id = $1 AND cs.event_type = $2 AND cs.state_key = $3`
|
||||
)
|
||||
|
||||
var massInsertCurrentStateBuilder = dbutil.NewMassInsertBuilder[*CurrentStateEntry, [1]any](addCurrentStateQuery, "($1, $%d, $%d, $%d, $%d)")
|
||||
|
@ -81,6 +83,25 @@ func (csq *CurrentStateQuery) AddMany(ctx context.Context, roomID id.RoomID, del
|
|||
return nil
|
||||
}
|
||||
|
||||
type RoomStateGUID struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
Type event.Type `json:"type"`
|
||||
StateKey string `json:"state_key"`
|
||||
}
|
||||
|
||||
func (csq *CurrentStateQuery) GetMany(ctx context.Context, keys []RoomStateGUID) ([]*Event, error) {
|
||||
args := make([]any, len(keys)*3)
|
||||
placeholders := make([]string, len(keys))
|
||||
for i, key := range keys {
|
||||
args[i*3] = key.RoomID
|
||||
args[i*3+1] = key.Type.Type
|
||||
args[i*3+2] = key.StateKey
|
||||
placeholders[i] = fmt.Sprintf("($%d, $%d, $%d)", i*3+1, i*3+2, i*3+3)
|
||||
}
|
||||
query := fmt.Sprintf(getManyCurrentRoomStateQuery, strings.Join(placeholders, ", "))
|
||||
return csq.QueryMany(ctx, query, args...)
|
||||
}
|
||||
|
||||
func (csq *CurrentStateQuery) Add(ctx context.Context, roomID id.RoomID, eventType event.Type, stateKey string, eventRowID EventRowID, membership event.Membership) error {
|
||||
return csq.Exec(ctx, addCurrentStateQuery, roomID, eventType.Type, stateKey, eventRowID, dbutil.StrPtr(membership))
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ type SyncComplete struct {
|
|||
}
|
||||
|
||||
func (c *SyncComplete) IsEmpty() bool {
|
||||
return len(c.Rooms) == 0
|
||||
return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.AccountData) == 0
|
||||
}
|
||||
|
||||
type EventsDecrypted struct {
|
||||
|
|
|
@ -79,6 +79,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
|||
return unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
|
||||
return h.GetRoomState(ctx, params.RoomID, params.FetchMembers, params.Refetch)
|
||||
})
|
||||
case "get_specific_room_state":
|
||||
return unmarshalAndCall(req.Data, func(params *getSpecificRoomStateParams) ([]*database.Event, error) {
|
||||
return h.DB.CurrentState.GetMany(ctx, params.Keys)
|
||||
})
|
||||
case "paginate":
|
||||
return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) {
|
||||
return h.Paginate(ctx, params.RoomID, params.MaxTimelineID, params.Limit)
|
||||
|
@ -183,6 +187,10 @@ type getRoomStateParams struct {
|
|||
FetchMembers bool `json:"fetch_members"`
|
||||
}
|
||||
|
||||
type getSpecificRoomStateParams struct {
|
||||
Keys []database.RoomStateGUID `json:"keys"`
|
||||
}
|
||||
|
||||
type ensureGroupSessionSharedParams struct {
|
||||
RoomID id.RoomID `json:"room_id"`
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
import { CachedEventDispatcher } from "../util/eventdispatcher.ts"
|
||||
import RPCClient, { SendMessageParams } from "./rpc.ts"
|
||||
import { RoomStateStore, StateStore } from "./statestore"
|
||||
import type { ClientState, EventID, EventType, RPCEvent, RoomID, RoomStateGUID, UserID } from "./types"
|
||||
import type { ClientState, EventID, EventType, ImagePackRooms, RPCEvent, RoomID, RoomStateGUID, UserID } from "./types"
|
||||
|
||||
export default class Client {
|
||||
readonly state = new CachedEventDispatcher<ClientState>()
|
||||
|
@ -25,7 +25,7 @@ export default class Client {
|
|||
constructor(readonly rpc: RPCClient) {
|
||||
this.rpc.event.listen(this.#handleEvent)
|
||||
this.store.accountDataSubs.getSubscriber("im.ponies.emote_rooms")(() =>
|
||||
queueMicrotask(() => this.#handleEmoteRoomsChange))
|
||||
queueMicrotask(() => this.#handleEmoteRoomsChange()))
|
||||
}
|
||||
|
||||
get userID(): UserID {
|
||||
|
@ -103,6 +103,29 @@ export default class Client {
|
|||
}
|
||||
}
|
||||
|
||||
async subscribeToEmojiPack(pack: RoomStateGUID, subscribe: boolean = true) {
|
||||
const emoteRooms = (this.store.accountData.get("im.ponies.emote_rooms") ?? {}) as ImagePackRooms
|
||||
if (!emoteRooms.rooms) {
|
||||
emoteRooms.rooms = {}
|
||||
}
|
||||
if (!emoteRooms.rooms[pack.room_id]) {
|
||||
emoteRooms.rooms[pack.room_id] = {}
|
||||
}
|
||||
if (emoteRooms.rooms[pack.room_id][pack.state_key]) {
|
||||
if (subscribe) {
|
||||
return
|
||||
}
|
||||
delete emoteRooms.rooms[pack.room_id][pack.state_key]
|
||||
} else {
|
||||
if (!subscribe) {
|
||||
return
|
||||
}
|
||||
emoteRooms.rooms[pack.room_id][pack.state_key] = {}
|
||||
}
|
||||
console.log("Changing subscription state for emoji pack", pack, "to", subscribe)
|
||||
await this.rpc.setAccountData("im.ponies.emote_rooms", emoteRooms)
|
||||
}
|
||||
|
||||
async incrementFrequentlyUsedEmoji(targetEmoji: string) {
|
||||
if (targetEmoji.startsWith("mxc://")) {
|
||||
return
|
||||
|
@ -134,7 +157,9 @@ export default class Client {
|
|||
|
||||
#handleEmoteRoomsChange() {
|
||||
this.store.invalidateEmojiPackKeyCache()
|
||||
this.loadSpecificRoomState(this.store.getEmojiPackKeys()).then(
|
||||
const keys = this.store.getEmojiPackKeys()
|
||||
console.log("Loading subscribed emoji pack states", keys)
|
||||
this.loadSpecificRoomState(keys).then(
|
||||
() => this.store.emojiRoomsSub.notify(),
|
||||
err => console.error("Failed to load emote rooms", err),
|
||||
)
|
||||
|
|
|
@ -195,6 +195,7 @@ export class StateStore {
|
|||
|
||||
invalidateEmojiPackKeyCache() {
|
||||
this.#emojiPackKeys = null
|
||||
this.#watchedRoomEmojiPacks = null
|
||||
}
|
||||
|
||||
invalidateEmojiPacksCache() {
|
||||
|
@ -239,10 +240,12 @@ export class StateStore {
|
|||
.map(key => {
|
||||
const room = this.rooms.get(key.room_id)
|
||||
if (!room) {
|
||||
console.warn("Failed to find room for emoji pack", key)
|
||||
return null
|
||||
}
|
||||
const pack = room.getEmojiPack(key.state_key)
|
||||
if (!pack) {
|
||||
console.warn("Failed to find pack", key)
|
||||
return null
|
||||
}
|
||||
return [roomStateGUIDToString(key), pack]
|
||||
|
|
|
@ -165,7 +165,22 @@ export interface ClientWellKnown {
|
|||
}
|
||||
|
||||
export function roomStateGUIDToString(guid: RoomStateGUID): string {
|
||||
return `${guid.room_id}/${guid.type}/${guid.state_key}`
|
||||
return `${encodeURIComponent(guid.room_id)}/${guid.type}/${encodeURIComponent(guid.state_key)}`
|
||||
}
|
||||
|
||||
export function stringToRoomStateGUID(str?: string | null): RoomStateGUID | undefined {
|
||||
if (!str) {
|
||||
return
|
||||
}
|
||||
const [roomID, type, stateKey] = str.split("/")
|
||||
if (!roomID || !type || !stateKey) {
|
||||
return
|
||||
}
|
||||
return {
|
||||
room_id: decodeURIComponent(roomID) as RoomID,
|
||||
type: type as EventType,
|
||||
state_key: decodeURIComponent(stateKey),
|
||||
}
|
||||
}
|
||||
|
||||
export interface RoomStateGUID {
|
||||
|
|
|
@ -113,6 +113,13 @@ div.emoji-picker {
|
|||
|
||||
h4.emoji-category-name {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> button {
|
||||
margin-left: .5rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
}
|
||||
|
||||
button.emoji-category-icon > img, button.emoji > img {
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
import { CSSProperties, JSX, use, useCallback, useState } from "react"
|
||||
import { getMediaURL } from "@/api/media.ts"
|
||||
import { RoomStateStore, useCustomEmojis } from "@/api/statestore"
|
||||
import { roomStateGUIDToString, stringToRoomStateGUID } from "@/api/types"
|
||||
import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji"
|
||||
import useEvent from "@/util/useEvent.ts"
|
||||
import { ClientContext } from "../ClientContext.ts"
|
||||
|
@ -72,6 +73,7 @@ interface EmojiPickerProps {
|
|||
export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSelect }: EmojiPickerProps) => {
|
||||
const client = use(ClientContext)!
|
||||
const [query, setQuery] = useState("")
|
||||
const watchedEmojiPackKeys = client.store.getEmojiPackKeys().map(roomStateGUIDToString)
|
||||
const customEmojiPacks = useCustomEmojis(client.store, room)
|
||||
const emojis = useFilteredEmojis(query, {
|
||||
frequentlyUsed: client.store.frequentlyUsedEmoji,
|
||||
|
@ -114,6 +116,22 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl
|
|||
setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
|
||||
const onMouseOutEmoji = useCallback(() => setPreviewEmoji(undefined), [])
|
||||
const onClickFreeformReact = useEvent(() => onSelectWrapped({ u: query }))
|
||||
const onClickSubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
|
||||
if (!guid) {
|
||||
return
|
||||
}
|
||||
client.subscribeToEmojiPack(guid, true)
|
||||
.catch(err => window.alert(`Failed to subscribe to emoji pack: ${err}`))
|
||||
})
|
||||
const onClickUnsubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
|
||||
if (!guid) {
|
||||
return
|
||||
}
|
||||
client.subscribeToEmojiPack(guid, false)
|
||||
.catch(err => window.alert(`Failed to unsubscribe from emoji pack: ${err}`))
|
||||
})
|
||||
|
||||
const renderedCats: JSX.Element[] = []
|
||||
let currentCatRender: JSX.Element[] = []
|
||||
|
@ -122,14 +140,38 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl
|
|||
if (!currentCatRender.length) {
|
||||
return
|
||||
}
|
||||
const categoryName = typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum
|
||||
let categoryName: string
|
||||
let headerExtra: JSX.Element | null = null
|
||||
if (typeof currentCatNum === "number") {
|
||||
categoryName = categories[currentCatNum]
|
||||
} else if (currentCatNum === CATEGORY_FREQUENTLY_USED) {
|
||||
categoryName = CATEGORY_FREQUENTLY_USED
|
||||
} else {
|
||||
const customPack = customEmojiPacks.find(pack => pack.id === currentCatNum)
|
||||
categoryName = customPack?.name ?? "Unknown name"
|
||||
if (customPack && customPack.id !== "personal") {
|
||||
if (watchedEmojiPackKeys.includes(customPack.id)) {
|
||||
headerExtra = <button
|
||||
className="emoji-category-add"
|
||||
onClick={onClickUnsubscribePack}
|
||||
data-pack-id={customPack.id}
|
||||
>Unsubscribe</button>
|
||||
} else {
|
||||
headerExtra = <button
|
||||
className="emoji-category-add"
|
||||
onClick={onClickSubscribePack}
|
||||
data-pack-id={customPack.id}
|
||||
>Subscribe</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
renderedCats.push(<div
|
||||
className="emoji-category"
|
||||
key={currentCatNum}
|
||||
id={`emoji-category-${categoryName}`}
|
||||
id={`emoji-category-${currentCatNum}`}
|
||||
style={{ containIntrinsicHeight: `${1.5 + Math.ceil(currentCatRender.length / 8) * 2.5}rem` }}
|
||||
>
|
||||
<h4 className="emoji-category-name">{categoryName}</h4>
|
||||
<h4 className="emoji-category-name">{categoryName}{headerExtra}</h4>
|
||||
<div className="emoji-category-list">
|
||||
{currentCatRender}
|
||||
</div>
|
||||
|
@ -162,8 +204,8 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl
|
|||
}
|
||||
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
|
||||
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
|
||||
const categoryName = evt.currentTarget.getAttribute("title")!
|
||||
document.getElementById(`emoji-category-${categoryName}`)?.scrollIntoView({ behavior: "smooth" })
|
||||
const categoryID = evt.currentTarget.getAttribute("data-category-id")!
|
||||
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [])
|
||||
return <div className="emoji-picker" style={style}>
|
||||
<div className="emoji-category-bar">
|
||||
|
|
|
@ -131,7 +131,7 @@ export function parseCustomEmojiPack(
|
|||
converted.s.push(shortcode.toLowerCase().replaceAll("_", ""))
|
||||
} else {
|
||||
converted = {
|
||||
c: name,
|
||||
c: id,
|
||||
u: image.url,
|
||||
n: shortcode,
|
||||
s: [shortcode.toLowerCase().replaceAll("_", "")],
|
||||
|
|
Loading…
Add table
Reference in a new issue