add option to upload pack images and apply changes

This commit is contained in:
Ajay Bura 2025-01-15 14:26:50 +05:30
parent 632e54d97e
commit 7b41fde237
12 changed files with 362 additions and 78 deletions

View file

@ -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<void>;
};
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<PackMetaReader>();
const currentMeta = savedMeta ?? imagePack.meta;
const images = useMemo(() => Array.from(imagePack.images.collection.values()), [imagePack]);
const [files, setFiles] = useState<File[]>([]);
const [uploadedImages, setUploadedImages] = useState<PackImageReader[]>([]);
const [imagesEditing, setImagesEditing] = useState<Set<string>>(new Set());
const [savedImages, setSavedImages] = useState<Map<string, PackImageReader>>(new Map());
const [deleteImages, setDeleteImages] = useState<Set<string>>(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) => (
<SequenceCard
key={image.shortcode}
style={{ padding: config.space.S300 }}
variant={deleteImages.has(image.shortcode) ? 'Critical' : 'SurfaceVariant'}
direction="Column"
gap="400"
>
{imagesEditing.has(image.shortcode) ? (
<ImageTileEdit
defaultShortcode={image.shortcode}
image={savedImages.get(image.shortcode) ?? image}
packUsage={currentMeta.usage}
useAuthentication={useAuthentication}
onCancel={handleImageEditCancel}
onSave={handleImageEditSave}
/>
) : (
<ImageTile
defaultShortcode={image.shortcode}
image={savedImages.get(image.shortcode) ?? image}
packUsage={currentMeta.usage}
useAuthentication={useAuthentication}
canEdit={canEdit}
onEdit={handleImageEdit}
deleted={deleteImages.has(image.shortcode)}
onDeleteToggle={handleDeleteToggle}
/>
)}
</SequenceCard>
);
return (
<Box grow="Yes" direction="Column" gap="700" {...props} ref={ref}>
@ -118,9 +254,15 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
<Menu className={css.UnsavedMenu} variant="Success">
<Box alignItems="Center" gap="400">
<Box grow="Yes" direction="Column">
{applyState.status === AsyncStatus.Error ? (
<Text size="T200">
<b>Failed to apply changes! Please try again.</b>
</Text>
) : (
<Text size="T200">
<b>Changes saved! Apply when ready.</b>
</Text>
)}
</Box>
<Box shrink="No" gap="200">
<Button
@ -128,7 +270,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
variant="Success"
fill="None"
radii="300"
disabled={!canApplyChanges}
disabled={!canApplyChanges || applying}
onClick={handleResetSavedChanges}
>
<Text size="B300">Reset</Text>
@ -137,8 +279,9 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
size="300"
variant="Success"
radii="300"
disabled={!canApplyChanges}
onClick={handleApplySavedChanges}
disabled={!canApplyChanges || applying}
before={applying && <Spinner variant="Success" fill="Soft" size="100" />}
onClick={applyChanges}
>
<Text size="B300">Apply Changes</Text>
</Button>
@ -187,45 +330,55 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
/>
</SequenceCard>
</Box>
{images.length > 0 && (
<Box direction="Column" gap="100">
<Text size="L400">Images</Text>
<Box direction="Column" gap="100">
{images.map((image) => (
<SequenceCard
key={image.shortcode}
style={{ padding: config.space.S300 }}
variant={deleteImages.has(image.shortcode) ? 'Critical' : 'SurfaceVariant'}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
{imagesEditing.has(image.shortcode) ? (
<ImageTileEdit
defaultShortcode={image.shortcode}
image={savedImages.get(image.shortcode) ?? image}
packUsage={currentMeta.usage}
useAuthentication={useAuthentication}
onCancel={handleImageEditCancel}
onSave={handleImageEditSave}
<SettingTile
title="Upload Images"
description="Select images from your storage to upload them in pack."
after={
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
type="button"
outlined
onClick={() => pickFiles('image/*')}
>
<Text size="B300">Select</Text>
</Button>
}
/>
) : (
<ImageTile
defaultShortcode={image.shortcode}
image={savedImages.get(image.shortcode) ?? image}
packUsage={currentMeta.usage}
useAuthentication={useAuthentication}
canEdit={canEdit}
onEdit={handleImageEdit}
deleted={deleteImages.has(image.shortcode)}
onDeleteToggle={handleDeleteToggle}
</SequenceCard>
{files.map((file) => (
<SequenceCard
key={file.name}
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<ImageTileUpload file={file}>
{(uploadAtom) => (
<CompactUploadCardRenderer
uploadAtom={uploadAtom}
onRemove={handleUploadRemove}
onComplete={handleUploadComplete}
/>
)}
</ImageTileUpload>
</SequenceCard>
))}
{uploadedImages.map(renderImage)}
{images.map(renderImage)}
</Box>
</Box>
)}
</Box>
);
}
);

View file

@ -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 (
<Page>
@ -39,10 +38,10 @@ export function ImagePackView({ imagePack, requestClose }: ImagePackViewProps) {
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
{room ? (
<RoomImagePack room={room} imagePack={imagePack} />
{room && address ? (
<RoomImagePack room={room} stateKey={address.stateKey} />
) : (
<UserImagePack imagePack={imagePack} />
<UserImagePack />
)}
</PageContent>
</Scroll>

View file

@ -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 (
<SettingTile before={<img className={css.ImagePackImage} src={url} alt={file.name} />}>
{children(uploadAtom)}
</SettingTile>
);
}
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,

View file

@ -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 <ImagePackContent imagePack={imagePack} canEdit={canEditImagePack} />;
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 (
<ImagePackContent imagePack={imagePack} canEdit={canEditImagePack} onUpdate={handleUpdate} />
);
}

View file

@ -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 <ImagePackContent imagePack={imagePack} canEdit />;
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 <ImagePackContent imagePack={imagePack ?? defaultPack} canEdit onUpdate={handleUpdate} />;
}

View file

@ -17,7 +17,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
};
if (imagePack) {
return <ImagePackView imagePack={imagePack} requestClose={handleImagePackViewClose} />;
return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
}
return (

View file

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

View file

@ -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,
};
}
}

View file

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

View file

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

View file

@ -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();

View file

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