1
0
Fork 0
forked from Mirrors/gomuks

web/emojipicker: add subscribe button for custom emoji packs

This commit is contained in:
Tulir Asokan 2024-10-26 16:28:56 +03:00
parent ac3b906211
commit d9d0718bc6
9 changed files with 135 additions and 14 deletions

View file

@ -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))
} }

View file

@ -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 {

View file

@ -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"`
} }

View file

@ -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),
) )

View file

@ -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]

View file

@ -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 {

View file

@ -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 {

View file

@ -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">

View file

@ -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("_", "")],