diff --git a/pkg/hicli/database/state.go b/pkg/hicli/database/state.go index 62e6b7b..ca92088 100644 --- a/pkg/hicli/database/state.go +++ b/pkg/hicli/database/state.go @@ -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)) } diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go index 3ee1707..e8e0a3b 100644 --- a/pkg/hicli/events.go +++ b/pkg/hicli/events.go @@ -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 { diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index c691adf..c8e0546 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -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"` } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ee521fe..c026683 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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() @@ -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), ) diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 4dba361..9a3564a 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -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] diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index 1df136a..2c2e676 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -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 { diff --git a/web/src/ui/emojipicker/EmojiPicker.css b/web/src/ui/emojipicker/EmojiPicker.css index ae8d096..cc6a9a4 100644 --- a/web/src/ui/emojipicker/EmojiPicker.css +++ b/web/src/ui/emojipicker/EmojiPicker.css @@ -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 { diff --git a/web/src/ui/emojipicker/EmojiPicker.tsx b/web/src/ui/emojipicker/EmojiPicker.tsx index 0b756f8..d031a24 100644 --- a/web/src/ui/emojipicker/EmojiPicker.tsx +++ b/web/src/ui/emojipicker/EmojiPicker.tsx @@ -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) => { + 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) => { + 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 = + } else { + headerExtra = + } + } + } renderedCats.push(
-

{categoryName}

+

{categoryName}{headerExtra}

{currentCatRender}
@@ -162,8 +204,8 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl } const onChangeQuery = useCallback((evt: React.ChangeEvent) => 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
diff --git a/web/src/util/emoji/index.ts b/web/src/util/emoji/index.ts index 8c1e0d9..75c6a01 100644 --- a/web/src/util/emoji/index.ts +++ b/web/src/util/emoji/index.ts @@ -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("_", "")],