From 7b41fde23706c08e4474ab3aaf100bc7c376cc92 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Wed, 15 Jan 2025 14:26:50 +0530 Subject: [PATCH] add option to upload pack images and apply changes --- .../image-pack-view/ImagePackContent.tsx | 259 ++++++++++++++---- .../image-pack-view/ImagePackView.tsx | 15 +- .../components/image-pack-view/ImageTile.tsx | 33 ++- .../image-pack-view/RoomImagePack.tsx | 42 ++- .../image-pack-view/UserImagePack.tsx | 25 +- .../emojis-stickers/EmojisStickers.tsx | 2 +- src/app/hooks/useImagePacks.ts | 24 ++ .../plugins/custom-emoji/PackImageReader.ts | 9 + src/app/plugins/custom-emoji/utils.ts | 10 +- src/app/utils/common.ts | 13 + src/app/utils/dom.ts | 3 + src/app/utils/mimeTypes.ts | 5 + 12 files changed, 362 insertions(+), 78 deletions(-) diff --git a/src/app/components/image-pack-view/ImagePackContent.tsx b/src/app/components/image-pack-view/ImagePackContent.tsx index bec2d3b2..be171625 100644 --- a/src/app/components/image-pack-view/ImagePackContent.tsx +++ b/src/app/components/image-pack-view/ImagePackContent.tsx @@ -1,38 +1,84 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { as, Box, Text, config, Button, Menu } from 'folds'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { as, Box, Text, config, Button, Menu, Spinner } from 'folds'; import { ImagePack, ImageUsage, + PackContent, + PackImage, PackImageReader, packMetaEqual, PackMetaReader, } from '../../plugins/custom-emoji'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { SequenceCard } from '../sequence-card'; -import { ImageTile, ImageTileEdit } from './ImageTile'; +import { ImageTile, ImageTileEdit, ImageTileUpload } from './ImageTile'; import { SettingTile } from '../setting-tile'; import { UsageSwitcher } from './UsageSwitcher'; import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta'; import * as css from './style.css'; +import { useFilePicker } from '../../hooks/useFilePicker'; +import { CompactUploadCardRenderer } from '../upload-card'; +import { UploadSuccess } from '../../state/upload'; +import { getImageInfo, TUploadContent } from '../../utils/matrix'; +import { getImageFileUrl, loadImageElement, renameFile } from '../../utils/dom'; +import { replaceSpaceWithDash, suffixRename } from '../../utils/common'; +import { getFileNameWithoutExt } from '../../utils/mimeTypes'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; export type ImagePackContentProps = { imagePack: ImagePack; canEdit?: boolean; + onUpdate?: (packContent: PackContent) => Promise; }; export const ImagePackContent = as<'div', ImagePackContentProps>( - ({ imagePack, canEdit, ...props }, ref) => { + ({ imagePack, canEdit, onUpdate, ...props }, ref) => { const useAuthentication = useMediaAuthentication(); - const images = useMemo(() => Array.from(imagePack.images.collection.values()), [imagePack]); const [metaEditing, setMetaEditing] = useState(false); const [savedMeta, setSavedMeta] = useState(); const currentMeta = savedMeta ?? imagePack.meta; + const images = useMemo(() => Array.from(imagePack.images.collection.values()), [imagePack]); + const [files, setFiles] = useState([]); + const [uploadedImages, setUploadedImages] = useState([]); const [imagesEditing, setImagesEditing] = useState>(new Set()); const [savedImages, setSavedImages] = useState>(new Map()); const [deleteImages, setDeleteImages] = useState>(new Set()); + const hasImageWithShortcode = useCallback( + (shortcode: string): boolean => { + const hasInPack = imagePack.images.collection.has(shortcode); + if (hasInPack) return true; + const hasInUploaded = + uploadedImages.find((img) => img.shortcode === shortcode) !== undefined; + if (hasInUploaded) return true; + const hasInSaved = + Array.from(savedImages).find(([, img]) => img.shortcode === shortcode) !== undefined; + return hasInSaved; + }, + [imagePack, savedImages, uploadedImages] + ); + + const pickFiles = useFilePicker( + useCallback( + (pickedFiles: File[]) => { + const uniqueFiles = pickedFiles.map((file) => { + const fileName = replaceSpaceWithDash(file.name); + if (hasImageWithShortcode(fileName)) { + const uniqueName = suffixRename(fileName, hasImageWithShortcode); + return renameFile(file, uniqueName); + } + return fileName !== file.name ? renameFile(file, fileName) : file; + }); + + setFiles((f) => [...f, ...uniqueFiles]); + }, + [hasImageWithShortcode] + ), + true + ); + const handleMetaSave = useCallback( (editedMeta: PackMetaReader) => { setMetaEditing(false); @@ -64,6 +110,28 @@ export const ImagePackContent = as<'div', ImagePackContentProps>( [imagePack.meta] ); + const handleUploadRemove = useCallback((file: TUploadContent) => { + setFiles((fs) => fs.filter((f) => f !== file)); + }, []); + + const handleUploadComplete = useCallback( + async (data: UploadSuccess) => { + const imgEl = await loadImageElement(getImageFileUrl(data.file)); + const packImage: PackImage = { + url: data.mxc, + info: getImageInfo(imgEl, data.file), + }; + const image = PackImageReader.fromPackImage( + getFileNameWithoutExt(data.file.name), + packImage + ); + if (!image) return; + handleUploadRemove(data.file); + setUploadedImages((imgs) => [image, ...imgs]); + }, + [handleUploadRemove] + ); + const handleImageEdit = (shortcode: string) => { setImagesEditing((shortcodes) => { const shortcodeSet = new Set(shortcodes); @@ -90,27 +158,95 @@ export const ImagePackContent = as<'div', ImagePackContentProps>( const handleImageEditSave = (shortcode: string, image: PackImageReader) => { handleImageEditCancel(shortcode); + + const saveImage = + shortcode !== image.shortcode && hasImageWithShortcode(image.shortcode) + ? new PackImageReader( + suffixRename(image.shortcode, hasImageWithShortcode), + image.url, + image.content + ) + : image; + setSavedImages((sImgs) => { const imgs = new Map(sImgs); - imgs.set(shortcode, image); + imgs.set(shortcode, saveImage); return imgs; }); }; const handleResetSavedChanges = () => { setSavedMeta(undefined); + setFiles([]); + setUploadedImages([]); setSavedImages(new Map()); setDeleteImages(new Set()); }; - const handleApplySavedChanges = () => { - setSavedMeta(undefined); - }; + + const [applyState, applyChanges] = useAsyncCallback( + useCallback(async () => { + const pack: PackContent = { + pack: savedMeta?.content ?? imagePack.meta.content, + images: {}, + }; + const pushImage = (img: PackImageReader) => { + if (deleteImages.has(img.shortcode)) return; + if (!pack.images) return; + const imgToPush = savedImages.get(img.shortcode) ?? img; + pack.images[imgToPush.shortcode] = imgToPush.content; + }; + uploadedImages.forEach((img) => pushImage(img)); + images.forEach((img) => pushImage(img)); + + return onUpdate?.(pack); + }, [imagePack, images, savedMeta, uploadedImages, savedImages, deleteImages, onUpdate]) + ); + + useEffect(() => { + if (applyState.status === AsyncStatus.Success) { + handleResetSavedChanges(); + } + }, [applyState]); const savedChanges = (savedMeta && !packMetaEqual(imagePack.meta, savedMeta)) || + uploadedImages.length > 0 || savedImages.size > 0 || deleteImages.size > 0; - const canApplyChanges = !metaEditing && imagesEditing.size === 0; + const canApplyChanges = !metaEditing && imagesEditing.size === 0 && files.length === 0; + const applying = applyState.status === AsyncStatus.Loading; + + const renderImage = (image: PackImageReader) => ( + + {imagesEditing.has(image.shortcode) ? ( + + ) : ( + + )} + + ); return ( @@ -118,9 +254,15 @@ export const ImagePackContent = as<'div', ImagePackContentProps>( - - Changes saved! Apply when ready. - + {applyState.status === AsyncStatus.Error ? ( + + Failed to apply changes! Please try again. + + ) : ( + + Changes saved! Apply when ready. + + )} @@ -187,44 +330,54 @@ export const ImagePackContent = as<'div', ImagePackContentProps>( /> - {images.length > 0 && ( - - Images - - {images.map((image) => ( - + Images + + pickFiles('image/*')} > - {imagesEditing.has(image.shortcode) ? ( - - ) : ( - - )} - - ))} - - - )} + Select + + } + /> + + {files.map((file) => ( + + + {(uploadAtom) => ( + + )} + + + ))} + {uploadedImages.map(renderImage)} + {images.map(renderImage)} + ); } diff --git a/src/app/components/image-pack-view/ImagePackView.tsx b/src/app/components/image-pack-view/ImagePackView.tsx index 15b66ea1..ab81d503 100644 --- a/src/app/components/image-pack-view/ImagePackView.tsx +++ b/src/app/components/image-pack-view/ImagePackView.tsx @@ -1,19 +1,18 @@ import React from 'react'; import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds'; -import { ImagePack } from '../../plugins/custom-emoji'; +import { PackAddress } from '../../plugins/custom-emoji'; import { Page, PageHeader, PageContent } from '../page'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { RoomImagePack } from './RoomImagePack'; import { UserImagePack } from './UserImagePack'; type ImagePackViewProps = { - imagePack: ImagePack; + address: PackAddress | undefined; requestClose: () => void; }; -export function ImagePackView({ imagePack, requestClose }: ImagePackViewProps) { +export function ImagePackView({ address, requestClose }: ImagePackViewProps) { const mx = useMatrixClient(); - const { roomId } = imagePack.address ?? {}; - const room = mx.getRoom(roomId); + const room = address && mx.getRoom(address.roomId); return ( @@ -39,10 +38,10 @@ export function ImagePackView({ imagePack, requestClose }: ImagePackViewProps) { - {room ? ( - + {room && address ? ( + ) : ( - + )} diff --git a/src/app/components/image-pack-view/ImageTile.tsx b/src/app/components/image-pack-view/ImageTile.tsx index c0b61d02..45b80585 100644 --- a/src/app/components/image-pack-view/ImageTile.tsx +++ b/src/app/components/image-pack-view/ImageTile.tsx @@ -1,4 +1,4 @@ -import React, { FormEventHandler, useState } from 'react'; +import React, { FormEventHandler, ReactNode, useMemo, useState } from 'react'; import { Badge, Box, Button, Chip, Icon, Icons, Input, Text } from 'folds'; import { UsageSwitcher, useUsageStr } from './UsageSwitcher'; import { mxcUrlToHttp } from '../../utils/matrix'; @@ -6,6 +6,9 @@ import * as css from './style.css'; import { ImageUsage, imageUsageEqual, PackImageReader } from '../../plugins/custom-emoji'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { SettingTile } from '../setting-tile'; +import { useObjectURL } from '../../hooks/useObjectURL'; +import { createUploadAtom, TUploadAtom } from '../../state/upload'; +import { replaceSpaceWithDash } from '../../utils/common'; type ImageTileProps = { defaultShortcode: string; @@ -85,6 +88,21 @@ export function ImageTile({ ); } +type ImageTileUploadProps = { + file: File; + children: (uploadAtom: TUploadAtom) => ReactNode; +}; +export function ImageTileUpload({ file, children }: ImageTileUploadProps) { + const url = useObjectURL(file); + const uploadAtom = useMemo(() => createUploadAtom(file), [file]); + + return ( + }> + {children(uploadAtom)} + + ); +} + type ImageTileEditProps = { defaultShortcode: string; useAuthentication: boolean; @@ -114,12 +132,21 @@ export function ImageTileEdit({ const bodyInput = target?.bodyInput as HTMLTextAreaElement | undefined; if (!shortcodeInput || !bodyInput) return; - const shortcode = shortcodeInput.value.trim(); - const body = bodyInput.value.trim(); + const shortcode = replaceSpaceWithDash(shortcodeInput.value.trim()); + const body = bodyInput.value.trim() || undefined; const usage = unsavedUsage; if (!shortcode) return; + if ( + shortcode === image.shortcode && + body === image.body && + imageUsageEqual(usage, defaultUsage) + ) { + onCancel(defaultShortcode); + return; + } + const imageReader = new PackImageReader(shortcode, image.url, { info: image.info, body, diff --git a/src/app/components/image-pack-view/RoomImagePack.tsx b/src/app/components/image-pack-view/RoomImagePack.tsx index 9ef28c5e..b5276eaf 100644 --- a/src/app/components/image-pack-view/RoomImagePack.tsx +++ b/src/app/components/image-pack-view/RoomImagePack.tsx @@ -1,17 +1,19 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Room } from 'matrix-js-sdk'; import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { ImagePackContent } from './ImagePackContent'; -import { ImagePack } from '../../plugins/custom-emoji'; +import { ImagePack, PackContent } from '../../plugins/custom-emoji'; import { StateEvent } from '../../../types/matrix/room'; +import { useRoomImagePack } from '../../hooks/useImagePacks'; +import { randomStr } from '../../utils/common'; type RoomImagePackProps = { room: Room; - imagePack: ImagePack; + stateKey: string; }; -export function RoomImagePack({ room, imagePack }: RoomImagePackProps) { +export function RoomImagePack({ room, stateKey }: RoomImagePackProps) { const mx = useMatrixClient(); const userId = mx.getUserId()!; const powerLevels = usePowerLevels(room); @@ -19,5 +21,35 @@ export function RoomImagePack({ room, imagePack }: RoomImagePackProps) { const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels); const canEditImagePack = canSendStateEvent(StateEvent.PoniesRoomEmotes, getPowerLevel(userId)); - return ; + const fallbackPack = useMemo(() => { + const fakePackId = randomStr(4); + return new ImagePack( + fakePackId, + {}, + { + roomId: room.roomId, + stateKey, + } + ); + }, [room.roomId, stateKey]); + const imagePack = useRoomImagePack(room, stateKey) ?? fallbackPack; + + const handleUpdate = useCallback( + async (packContent: PackContent) => { + const { address } = imagePack; + if (!address) return; + + await mx.sendStateEvent( + address.roomId, + 'StateEvent.PoniesRoomEmotes', + packContent, + address.stateKey + ); + }, + [mx, imagePack] + ); + + return ( + + ); } diff --git a/src/app/components/image-pack-view/UserImagePack.tsx b/src/app/components/image-pack-view/UserImagePack.tsx index d44200e0..4987793d 100644 --- a/src/app/components/image-pack-view/UserImagePack.tsx +++ b/src/app/components/image-pack-view/UserImagePack.tsx @@ -1,11 +1,22 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { ImagePackContent } from './ImagePackContent'; -import { ImagePack } from '../../plugins/custom-emoji'; +import { ImagePack, PackContent } from '../../plugins/custom-emoji'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { AccountDataEvent } from '../../../types/matrix/accountData'; +import { useUserImagePack } from '../../hooks/useImagePacks'; -type UserImagePackProps = { - imagePack: ImagePack; -}; +export function UserImagePack() { + const mx = useMatrixClient(); -export function UserImagePack({ imagePack }: UserImagePackProps) { - return ; + const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]); + const imagePack = useUserImagePack(); + + const handleUpdate = useCallback( + async (packContent: PackContent) => { + await mx.setAccountData(AccountDataEvent.PoniesUserEmotes, packContent); + }, + [mx] + ); + + return ; } diff --git a/src/app/features/settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/settings/emojis-stickers/EmojisStickers.tsx index 0b9251fa..93715120 100644 --- a/src/app/features/settings/emojis-stickers/EmojisStickers.tsx +++ b/src/app/features/settings/emojis-stickers/EmojisStickers.tsx @@ -17,7 +17,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { }; if (imagePack) { - return ; + return ; } return ( diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts index d5f0e01f..318d6cba 100644 --- a/src/app/hooks/useImagePacks.ts +++ b/src/app/hooks/useImagePacks.ts @@ -4,6 +4,7 @@ import { AccountDataEvent } from '../../types/matrix/accountData'; import { StateEvent } from '../../types/matrix/room'; import { getGlobalImagePacks, + getRoomImagePack, getRoomImagePacks, getUserImagePack, ImagePack, @@ -72,6 +73,29 @@ export const useGlobalImagePacks = (): ImagePack[] => { return globalPacks; }; +export const useRoomImagePack = (room: Room, stateKey: string): ImagePack | undefined => { + const mx = useMatrixClient(); + const [roomPack, setRoomPack] = useState(() => getRoomImagePack(room, stateKey)); + + useStateEventCallback( + mx, + useCallback( + (mEvent) => { + if ( + mEvent.getRoomId() === room.roomId && + mEvent.getType() === StateEvent.PoniesRoomEmotes && + mEvent.getStateKey() === stateKey + ) { + setRoomPack(getRoomImagePack(room, stateKey)); + } + }, + [room, stateKey] + ) + ); + + return roomPack; +}; + export const useRoomImagePacks = (room: Room): ImagePack[] => { const mx = useMatrixClient(); const [roomPacks, setRoomPacks] = useState(() => getRoomImagePacks(room)); diff --git a/src/app/plugins/custom-emoji/PackImageReader.ts b/src/app/plugins/custom-emoji/PackImageReader.ts index f5a383b7..4bac3aad 100644 --- a/src/app/plugins/custom-emoji/PackImageReader.ts +++ b/src/app/plugins/custom-emoji/PackImageReader.ts @@ -40,4 +40,13 @@ export class PackImageReader { return knownUsage.length > 0 ? knownUsage : undefined; } + + get content(): PackImage { + return { + url: this.url, + body: this.image.body, + usage: this.image.usage, + info: this.image.info, + }; + } } diff --git a/src/app/plugins/custom-emoji/utils.ts b/src/app/plugins/custom-emoji/utils.ts index 3ff5c212..0e1524df 100644 --- a/src/app/plugins/custom-emoji/utils.ts +++ b/src/app/plugins/custom-emoji/utils.ts @@ -2,7 +2,7 @@ import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; import { ImagePack } from './ImagePack'; import { EmoteRoomsContent, ImageUsage } from './types'; import { StateEvent } from '../../../types/matrix/room'; -import { getAccountData, getStateEvents } from '../../utils/room'; +import { getAccountData, getStateEvent, getStateEvents } from '../../utils/room'; import { AccountDataEvent } from '../../../types/matrix/accountData'; import { PackMetaReader } from './PackMetaReader'; @@ -28,6 +28,14 @@ export function makeImagePacks(packEvents: MatrixEvent[]): ImagePack[] { }, []); } +export function getRoomImagePack(room: Room, stateKey: string): ImagePack | undefined { + const packEvent = getStateEvent(room, StateEvent.PoniesRoomEmotes, stateKey); + if (!packEvent) return undefined; + const packId = packEvent.getId(); + if (!packId) return undefined; + return ImagePack.fromMatrixEvent(packId, packEvent); +} + export function getRoomImagePacks(room: Room): ImagePack[] { const packEvents = getStateEvents(room, StateEvent.PoniesRoomEmotes); return makeImagePacks(packEvents); diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts index 6d7b69c1..d230c6bb 100644 --- a/src/app/utils/common.ts +++ b/src/app/utils/common.ts @@ -112,3 +112,16 @@ export const randomStr = (len = 12): string => { } return str; }; + +export const suffixRename = (name: string, validator: (newName: string) => boolean): string => { + let suffix = 1; + let newName = name; + do { + newName = name + suffix; + suffix += 1; + } while (validator(newName)); + + return newName; +}; + +export const replaceSpaceWithDash = (str: string): string => str.replace(/ /g, '-'); diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 315c68a3..f931ac45 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -75,6 +75,9 @@ export const getDataTransferFiles = (dataTransfer: DataTransfer): File[] | undef return files; }; +export const renameFile = (file: File, name: string): File => + new File([file], name, { type: file.type }); + export const getImageUrlBlob = async (url: string) => { const res = await fetch(url); const blob = await res.blob(); diff --git a/src/app/utils/mimeTypes.ts b/src/app/utils/mimeTypes.ts index ad91f18a..98c5aee9 100644 --- a/src/app/utils/mimeTypes.ts +++ b/src/app/utils/mimeTypes.ts @@ -134,3 +134,8 @@ export const getFileNameExt = (fileName: string): string => { const extStart = fileName.lastIndexOf('.') + 1; return fileName.slice(extStart); }; +export const getFileNameWithoutExt = (fileName: string): string => { + const extStart = fileName.lastIndexOf('.'); + if (extStart === 0 || extStart === -1) return fileName; + return fileName.slice(0, extStart); +};