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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go.mau.fi/util/dbutil"
|
"go.mau.fi/util/dbutil"
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
|
@ -28,16 +29,17 @@ const (
|
||||||
deleteCurrentStateQuery = `
|
deleteCurrentStateQuery = `
|
||||||
DELETE FROM current_state WHERE room_id = $1
|
DELETE FROM current_state WHERE room_id = $1
|
||||||
`
|
`
|
||||||
getCurrentRoomStateQuery = `
|
getCurrentRoomStateBaseQuery = `
|
||||||
SELECT event.rowid, -1,
|
SELECT event.rowid, -1,
|
||||||
event.room_id, event.event_id, sender, event.type, event.state_key, timestamp, content, decrypted, decrypted_type,
|
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,
|
unsigned, local_content, transaction_id, redacted_by, relates_to, relation_type,
|
||||||
megolm_session_id, decryption_error, send_error, reactions, last_edit_rowid, unread_type
|
megolm_session_id, decryption_error, send_error, reactions, last_edit_rowid, unread_type
|
||||||
FROM current_state cs
|
FROM current_state cs
|
||||||
JOIN event ON cs.event_rowid = event.rowid
|
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)")
|
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
|
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 {
|
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))
|
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 {
|
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 {
|
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 unmarshalAndCall(req.Data, func(params *getRoomStateParams) ([]*database.Event, error) {
|
||||||
return h.GetRoomState(ctx, params.RoomID, params.FetchMembers, params.Refetch)
|
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":
|
case "paginate":
|
||||||
return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) {
|
return unmarshalAndCall(req.Data, func(params *paginateParams) (*PaginationResponse, error) {
|
||||||
return h.Paginate(ctx, params.RoomID, params.MaxTimelineID, params.Limit)
|
return h.Paginate(ctx, params.RoomID, params.MaxTimelineID, params.Limit)
|
||||||
|
@ -183,6 +187,10 @@ type getRoomStateParams struct {
|
||||||
FetchMembers bool `json:"fetch_members"`
|
FetchMembers bool `json:"fetch_members"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type getSpecificRoomStateParams struct {
|
||||||
|
Keys []database.RoomStateGUID `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
type ensureGroupSessionSharedParams struct {
|
type ensureGroupSessionSharedParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_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, RoomStateGUID, UserID } from "./types"
|
import type { ClientState, EventID, EventType, ImagePackRooms, RPCEvent, RoomID, RoomStateGUID, UserID } from "./types"
|
||||||
|
|
||||||
export default class Client {
|
export default class Client {
|
||||||
readonly state = new CachedEventDispatcher<ClientState>()
|
readonly state = new CachedEventDispatcher<ClientState>()
|
||||||
|
@ -25,7 +25,7 @@ export default class Client {
|
||||||
constructor(readonly rpc: RPCClient) {
|
constructor(readonly rpc: RPCClient) {
|
||||||
this.rpc.event.listen(this.#handleEvent)
|
this.rpc.event.listen(this.#handleEvent)
|
||||||
this.store.accountDataSubs.getSubscriber("im.ponies.emote_rooms")(() =>
|
this.store.accountDataSubs.getSubscriber("im.ponies.emote_rooms")(() =>
|
||||||
queueMicrotask(() => this.#handleEmoteRoomsChange))
|
queueMicrotask(() => this.#handleEmoteRoomsChange()))
|
||||||
}
|
}
|
||||||
|
|
||||||
get userID(): UserID {
|
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) {
|
async incrementFrequentlyUsedEmoji(targetEmoji: string) {
|
||||||
if (targetEmoji.startsWith("mxc://")) {
|
if (targetEmoji.startsWith("mxc://")) {
|
||||||
return
|
return
|
||||||
|
@ -134,7 +157,9 @@ export default class Client {
|
||||||
|
|
||||||
#handleEmoteRoomsChange() {
|
#handleEmoteRoomsChange() {
|
||||||
this.store.invalidateEmojiPackKeyCache()
|
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(),
|
() => this.store.emojiRoomsSub.notify(),
|
||||||
err => console.error("Failed to load emote rooms", err),
|
err => console.error("Failed to load emote rooms", err),
|
||||||
)
|
)
|
||||||
|
|
|
@ -195,6 +195,7 @@ export class StateStore {
|
||||||
|
|
||||||
invalidateEmojiPackKeyCache() {
|
invalidateEmojiPackKeyCache() {
|
||||||
this.#emojiPackKeys = null
|
this.#emojiPackKeys = null
|
||||||
|
this.#watchedRoomEmojiPacks = null
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateEmojiPacksCache() {
|
invalidateEmojiPacksCache() {
|
||||||
|
@ -239,10 +240,12 @@ export class StateStore {
|
||||||
.map(key => {
|
.map(key => {
|
||||||
const room = this.rooms.get(key.room_id)
|
const room = this.rooms.get(key.room_id)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
|
console.warn("Failed to find room for emoji pack", key)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const pack = room.getEmojiPack(key.state_key)
|
const pack = room.getEmojiPack(key.state_key)
|
||||||
if (!pack) {
|
if (!pack) {
|
||||||
|
console.warn("Failed to find pack", key)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return [roomStateGUIDToString(key), pack]
|
return [roomStateGUIDToString(key), pack]
|
||||||
|
|
|
@ -165,7 +165,22 @@ export interface ClientWellKnown {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function roomStateGUIDToString(guid: RoomStateGUID): string {
|
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 {
|
export interface RoomStateGUID {
|
||||||
|
|
|
@ -113,6 +113,13 @@ div.emoji-picker {
|
||||||
|
|
||||||
h4.emoji-category-name {
|
h4.emoji-category-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> button {
|
||||||
|
margin-left: .5rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button.emoji-category-icon > img, button.emoji > img {
|
button.emoji-category-icon > img, button.emoji > img {
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
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 { RoomStateStore, useCustomEmojis } from "@/api/statestore"
|
import { RoomStateStore, useCustomEmojis } from "@/api/statestore"
|
||||||
|
import { roomStateGUIDToString, stringToRoomStateGUID } from "@/api/types"
|
||||||
import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji"
|
import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji"
|
||||||
import useEvent from "@/util/useEvent.ts"
|
import useEvent from "@/util/useEvent.ts"
|
||||||
import { ClientContext } from "../ClientContext.ts"
|
import { ClientContext } from "../ClientContext.ts"
|
||||||
|
@ -72,6 +73,7 @@ interface EmojiPickerProps {
|
||||||
export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSelect }: EmojiPickerProps) => {
|
export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSelect }: EmojiPickerProps) => {
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
|
const watchedEmojiPackKeys = client.store.getEmojiPackKeys().map(roomStateGUIDToString)
|
||||||
const customEmojiPacks = useCustomEmojis(client.store, room)
|
const customEmojiPacks = useCustomEmojis(client.store, room)
|
||||||
const emojis = useFilteredEmojis(query, {
|
const emojis = useFilteredEmojis(query, {
|
||||||
frequentlyUsed: client.store.frequentlyUsedEmoji,
|
frequentlyUsed: client.store.frequentlyUsedEmoji,
|
||||||
|
@ -114,6 +116,22 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl
|
||||||
setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
|
setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
|
||||||
const onMouseOutEmoji = useCallback(() => setPreviewEmoji(undefined), [])
|
const onMouseOutEmoji = useCallback(() => setPreviewEmoji(undefined), [])
|
||||||
const onClickFreeformReact = useEvent(() => onSelectWrapped({ u: query }))
|
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[] = []
|
const renderedCats: JSX.Element[] = []
|
||||||
let currentCatRender: JSX.Element[] = []
|
let currentCatRender: JSX.Element[] = []
|
||||||
|
@ -122,14 +140,38 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl
|
||||||
if (!currentCatRender.length) {
|
if (!currentCatRender.length) {
|
||||||
return
|
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
|
renderedCats.push(<div
|
||||||
className="emoji-category"
|
className="emoji-category"
|
||||||
key={currentCatNum}
|
key={currentCatNum}
|
||||||
id={`emoji-category-${categoryName}`}
|
id={`emoji-category-${currentCatNum}`}
|
||||||
style={{ containIntrinsicHeight: `${1.5 + Math.ceil(currentCatRender.length / 8) * 2.5}rem` }}
|
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">
|
<div className="emoji-category-list">
|
||||||
{currentCatRender}
|
{currentCatRender}
|
||||||
</div>
|
</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 onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
|
||||||
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
|
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
|
||||||
const categoryName = evt.currentTarget.getAttribute("title")!
|
const categoryID = evt.currentTarget.getAttribute("data-category-id")!
|
||||||
document.getElementById(`emoji-category-${categoryName}`)?.scrollIntoView({ behavior: "smooth" })
|
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [])
|
}, [])
|
||||||
return <div className="emoji-picker" style={style}>
|
return <div className="emoji-picker" style={style}>
|
||||||
<div className="emoji-category-bar">
|
<div className="emoji-category-bar">
|
||||||
|
|
|
@ -131,7 +131,7 @@ export function parseCustomEmojiPack(
|
||||||
converted.s.push(shortcode.toLowerCase().replaceAll("_", ""))
|
converted.s.push(shortcode.toLowerCase().replaceAll("_", ""))
|
||||||
} else {
|
} else {
|
||||||
converted = {
|
converted = {
|
||||||
c: name,
|
c: id,
|
||||||
u: image.url,
|
u: image.url,
|
||||||
n: shortcode,
|
n: shortcode,
|
||||||
s: [shortcode.toLowerCase().replaceAll("_", "")],
|
s: [shortcode.toLowerCase().replaceAll("_", "")],
|
||||||
|
|
Loading…
Add table
Reference in a new issue