diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx index 309d3cbe..ffe8d271 100644 --- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx +++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; import { Box, Text, @@ -9,16 +9,191 @@ import { Avatar, AvatarImage, AvatarFallback, + config, + Spinner, + Menu, + RectCords, + PopOut, + Checkbox, + toRem, + Scroll, + Header, + Line, + Chip, } from 'folds'; -import { useGlobalImagePacks } from '../../../hooks/useImagePacks'; +import FocusTrap from 'focus-trap-react'; +import { useAtomValue } from 'jotai'; +import { Room } from 'matrix-js-sdk'; +import { useGlobalImagePacks, useRoomsImagePacks } from '../../../hooks/useImagePacks'; import { SequenceCardStyle } from '../styles.css'; import { SequenceCard } from '../../../components/sequence-card'; import { SettingTile } from '../../../components/setting-tile'; import { mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { ImagePack, ImageUsage } from '../../../plugins/custom-emoji'; +import { + EmoteRoomsContent, + ImagePack, + ImageUsage, + PackAddress, + packAddressEqual, +} from '../../../plugins/custom-emoji'; import { LineClamp2 } from '../../../styles/Text.css'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { AccountDataEvent } from '../../../../types/matrix/accountData'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { stopPropagation } from '../../../utils/keyboard'; + +function GlobalPackSelector({ + packs, + useAuthentication, + onSelect, +}: { + packs: ImagePack[]; + useAuthentication: boolean; + onSelect: (addresses: PackAddress[]) => void; +}) { + const mx = useMatrixClient(); + const roomToPacks = useMemo(() => { + const rToP = new Map(); + packs + .filter((pack) => !pack.deleted) + .forEach((pack) => { + if (!pack.address) return; + const pks = rToP.get(pack.address.roomId) ?? []; + pks.push(pack); + rToP.set(pack.address.roomId, pks); + }); + return rToP; + }, [packs]); + + const [selected, setSelected] = useState([]); + const toggleSelect = (address: PackAddress) => { + setSelected((addresses) => { + const newAddresses = addresses.filter((addr) => !packAddressEqual(addr, address)); + if (newAddresses.length !== addresses.length) { + return newAddresses; + } + newAddresses.push(address); + return newAddresses; + }); + }; + + const hasSelected = selected.length > 0; + return ( + +
+ + + Room Packs + + + + onSelect(selected)} + > + {hasSelected ? 'Save' : 'Close'} + + +
+ + + + + {Array.from(roomToPacks.entries()).map(([roomId, roomPacks]) => { + const room = mx.getRoom(roomId); + if (!room) return null; + return ( + + {room.name} + {roomPacks.map((pack) => { + const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon); + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) + : undefined; + const { address } = pack; + if (!address) return null; + + const added = selected.find((addr) => packAddressEqual(addr, address)); + return ( + + {pack.meta.attribution}} + before={ + + + {avatarUrl ? ( + + ) : ( + + + + )} + + + } + after={ + toggleSelect(address)} /> + } + /> + + ); + })} + + ); + })} + + {roomToPacks.size === 0 && ( + + + + No Packs + + + Pack from rooms will appear here. You do not have any room with packs yet. + + + + )} + + + +
+ ); +} type GlobalPacksProps = { onViewPack: (imagePack: ImagePack) => void; @@ -27,73 +202,287 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const globalPacks = useGlobalImagePacks(); + const [menuCords, setMenuCords] = useState(); - return ( - - Favorite Packs + const roomIds = useAtomValue(allRoomsAtom); + const rooms = useMemo(() => { + const rs: Room[] = []; + roomIds.forEach((rId) => { + const r = mx.getRoom(rId); + if (r) rs.push(r); + }); + return rs; + }, [mx, roomIds]); + const roomsImagePack = useRoomsImagePacks(rooms); + const nonGlobalPacks = useMemo( + () => + roomsImagePack.filter( + (pack) => !globalPacks.find((p) => packAddressEqual(pack.address, p.address)) + ), + [roomsImagePack, globalPacks] + ); + + const [selectedPacks, setSelectedPacks] = useState([]); + const [removedPacks, setRemovedPacks] = useState([]); + + const unselectedGlobalPacks = useMemo( + () => + nonGlobalPacks.filter( + (pack) => !selectedPacks.find((addr) => packAddressEqual(pack.address, addr)) + ), + [selectedPacks, nonGlobalPacks] + ); + + const handleRemove = (address: PackAddress) => { + setRemovedPacks((addresses) => [...addresses, address]); + }; + + const handleUndoRemove = (address: PackAddress) => { + setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address))); + }; + + const handleSelected = (addresses: PackAddress[]) => { + setMenuCords(undefined); + if (addresses.length > 0) { + setSelectedPacks((a) => [...addresses, ...a]); + } + }; + + const [applyState, applyChanges] = useAsyncCallback( + useCallback(async () => { + const content = + mx.getAccountData(AccountDataEvent.PoniesEmoteRooms)?.getContent() ?? {}; + const updatedContent: EmoteRoomsContent = JSON.parse(JSON.stringify(content)); + + selectedPacks.forEach((addr) => { + const roomsToState = updatedContent.rooms ?? {}; + const stateKeyToObj = roomsToState[addr.roomId] ?? {}; + stateKeyToObj[addr.stateKey] = {}; + roomsToState[addr.roomId] = stateKeyToObj; + updatedContent.rooms = roomsToState; + }); + + removedPacks.forEach((addr) => { + if (updatedContent.rooms?.[addr.roomId]?.[addr.stateKey]) { + delete updatedContent.rooms?.[addr.roomId][addr.stateKey]; + } + }); + + await mx.setAccountData(AccountDataEvent.PoniesEmoteRooms, updatedContent); + }, [mx, selectedPacks, removedPacks]) + ); + + const resetChanges = useCallback(() => { + setSelectedPacks([]); + setRemovedPacks([]); + }, []); + + useEffect(() => { + if (applyState.status === AsyncStatus.Success) { + resetChanges(); + } + }, [applyState, resetChanges]); + + const handleSelectMenu: MouseEventHandler = (evt) => { + setMenuCords(evt.currentTarget.getBoundingClientRect()); + }; + + const applyingChanges = applyState.status === AsyncStatus.Loading; + const hasChanges = removedPacks.length > 0 || selectedPacks.length > 0; + + const renderPack = (pack: ImagePack) => { + const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon); + const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined; + const { address } = pack; + if (!address) return null; + const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address)); + + return ( + {pack.meta.name ?? 'Unknown'} + + } + description={{pack.meta.attribution}} + before={ + + {removed ? ( + handleUndoRemove(address)} + disabled={applyingChanges} + > + + + ) : ( + handleRemove(address)} + disabled={applyingChanges} + > + + + )} + + {avatarUrl ? ( + + ) : ( + + + + )} + + + } after={ - + !removed && ( + + ) } /> - {globalPacks.map((pack) => { - const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon); - const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined; + ); + }; - return ( - - {pack.meta.attribution}} - before={ - - - - - - {avatarUrl ? ( - - ) : ( - - - - )} - - - } - after={ + return ( + <> + + Favorite Packs + + - } - /> - - ); - })} - + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + + + } + /> + + } + /> + + {globalPacks.map(renderPack)} + {nonGlobalPacks + .filter((pack) => !!selectedPacks.find((addr) => packAddressEqual(pack.address, addr))) + .map(renderPack)} + + {hasChanges && ( + + + + {applyState.status === AsyncStatus.Error ? ( + + Failed to apply changes! Please try again. + + ) : ( + + Changes saved! Apply when ready. + + )} + + + + + + + + )} + ); } diff --git a/src/app/plugins/custom-emoji/ImagePack.ts b/src/app/plugins/custom-emoji/ImagePack.ts index a1cefb7f..1685362e 100644 --- a/src/app/plugins/custom-emoji/ImagePack.ts +++ b/src/app/plugins/custom-emoji/ImagePack.ts @@ -8,6 +8,8 @@ import { ImageUsage, PackContent } from './types'; export class ImagePack { public readonly id: string; + public readonly deleted: boolean; + public readonly address: PackAddress | undefined; public readonly meta: PackMetaReader; @@ -23,6 +25,8 @@ export class ImagePack { this.address = address; + this.deleted = content.pack === undefined && content.images === undefined; + this.meta = new PackMetaReader(content.pack ?? {}); this.images = new PackImagesReader(content.images ?? {}); } diff --git a/src/app/plugins/custom-emoji/types.ts b/src/app/plugins/custom-emoji/types.ts index a9a01f95..45967075 100644 --- a/src/app/plugins/custom-emoji/types.ts +++ b/src/app/plugins/custom-emoji/types.ts @@ -5,10 +5,10 @@ import { IImageInfo } from '../../../types/matrix/common'; /** * im.ponies.emote_rooms content */ -export type PackStateKeyToUnknown = Record; -export type RoomIdToPackInfo = Record; +export type PackStateKeyToObject = Record; +export type RoomIdToStateKey = Record; export type EmoteRoomsContent = { - rooms?: RoomIdToPackInfo; + rooms?: RoomIdToStateKey; }; /** diff --git a/src/app/plugins/custom-emoji/utils.ts b/src/app/plugins/custom-emoji/utils.ts index 0e1524df..cb80bb1c 100644 --- a/src/app/plugins/custom-emoji/utils.ts +++ b/src/app/plugins/custom-emoji/utils.ts @@ -5,6 +5,13 @@ import { StateEvent } from '../../../types/matrix/room'; import { getAccountData, getStateEvent, getStateEvents } from '../../utils/room'; import { AccountDataEvent } from '../../../types/matrix/accountData'; import { PackMetaReader } from './PackMetaReader'; +import { PackAddress } from './PackAddress'; + +export function packAddressEqual(a1?: PackAddress, a2?: PackAddress): boolean { + if (!a1 && !a2) return true; + if (!a1 || !a2) return false; + return a1.roomId === a2.roomId && a1.stateKey === a2.stateKey; +} export function imageUsageEqual(u1: ImageUsage[], u2: ImageUsage[]) { return u1.length === u2.length && u1.every((u) => u2.includes(u));