mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
|
import {
|
|
AVATAR_RECOMMENDED_SIZE_LABEL,
|
|
IMAGE_MAX_SIZE_LABEL,
|
|
STATIC_IMAGE_FORMATS,
|
|
} from '@app/features/app/config/I18nDisplayConstants';
|
|
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
|
|
import * as ChannelCommands from '@app/features/channel/commands/ChannelCommands';
|
|
import {showChannelErrorModal} from '@app/features/channel/components/alerts/ChannelErrorModalUtils';
|
|
import styles from '@app/features/channel/components/modals/EditGroupModal.module.css';
|
|
import Channels from '@app/features/channel/state/Channels';
|
|
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
|
import {AssetCropModal, AssetType} from '@app/features/expressions/components/modals/AssetCropModal';
|
|
import {openAssetSourceModal} from '@app/features/expressions/components/modals/AssetSourceModal';
|
|
import {isAnimatedFile} from '@app/features/expressions/utils/AnimatedImageUtils';
|
|
import {getAcceptStringFiltered, getAssetFormatErrorMessage} from '@app/features/expressions/utils/AssetFormatCopy';
|
|
import {formatImageUploadRecommendedHint} from '@app/features/expressions/utils/AssetUploadHintCopy';
|
|
import {isSvgFile, readImageFileAsUploadDataUrl} from '@app/features/expressions/utils/ImageUploadFileUtils';
|
|
import {
|
|
EDIT_GROUP_DESCRIPTOR,
|
|
FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR,
|
|
INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR,
|
|
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
|
import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils';
|
|
import {Button} from '@app/features/ui/button/Button';
|
|
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
|
import {modal} from '@app/features/ui/commands/ModalCommands';
|
|
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
|
import {Form} from '@app/features/ui/components/form/Form';
|
|
import {Input} from '@app/features/ui/components/form/FormInput';
|
|
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
|
import {canCropFormat} from '@app/features/voice/utils/MediaCapabilities';
|
|
import {useRemoteFormReset} from '@app/lib/forms/RemoteFormReset';
|
|
import {assignTransientUploadFieldMutation} from '@app/lib/forms/TransientUploadFields';
|
|
import {msg} from '@lingui/core/macro';
|
|
import {Trans, useLingui} from '@lingui/react/macro';
|
|
import {PlusIcon} from '@phosphor-icons/react';
|
|
import {observer} from 'mobx-react-lite';
|
|
import {useCallback, useMemo, useState} from 'react';
|
|
import {useForm} from 'react-hook-form';
|
|
|
|
const ICON_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR = msg({
|
|
message: 'Icon file is too large. Choose a file smaller than {imageMaxSizeLabel}.',
|
|
comment: 'Error message in the edit group modal. Preserve {imageMaxSizeLabel}; it is inserted by code.',
|
|
});
|
|
const ICON_FILE_IS_TOO_LARGE_TITLE_DESCRIPTOR = msg({
|
|
message: 'Icon file is too large',
|
|
comment: 'Title of the error modal shown when the selected group icon exceeds the size limit.',
|
|
});
|
|
const UNSUPPORTED_ICON_FORMAT_DESCRIPTOR = msg({
|
|
message: 'Unsupported icon format',
|
|
comment: 'Title of the error modal shown when the selected group icon format is unsupported.',
|
|
});
|
|
const ANIMATED_ICONS_ARE_NOT_SUPPORTED_PLEASE_USE_DESCRIPTOR = msg({
|
|
message: 'Animated icons are not supported. Use a static image.',
|
|
comment: 'Description text in the edit group modal.',
|
|
});
|
|
const ANIMATED_ICONS_ARE_NOT_SUPPORTED_TITLE_DESCRIPTOR = msg({
|
|
message: 'Animated icons are not supported',
|
|
comment: 'Title of the error modal shown when an animated group icon is selected.',
|
|
});
|
|
const COULDN_T_PROCESS_IMAGE_DESCRIPTOR = msg({
|
|
message: "Couldn't process image",
|
|
comment: 'Title of the error modal shown when a cropped group icon cannot be processed.',
|
|
});
|
|
const INVALID_IMAGE_DESCRIPTOR = msg({
|
|
message: 'Invalid image',
|
|
comment: 'Title of the error modal shown when the selected group icon image cannot be used.',
|
|
});
|
|
const GROUP_NAME_MUST_NOT_EXCEED_100_CHARACTERS_DESCRIPTOR = msg({
|
|
message: 'Group name must not exceed 100 characters',
|
|
comment: 'Label in the edit group modal.',
|
|
});
|
|
const GROUP_NAME_DESCRIPTOR = msg({
|
|
message: 'Group name',
|
|
comment: 'Short label in the edit group modal. Keep it concise.',
|
|
});
|
|
const MY_GROUP_DESCRIPTOR = msg({
|
|
message: 'My group',
|
|
comment: 'Short label in the edit group modal. Keep it concise.',
|
|
});
|
|
const CHANGE_ICON_DESCRIPTOR = msg({
|
|
message: 'Change icon',
|
|
comment:
|
|
'Title of the modal where the user picks a group icon source. Keep it concise. Keep the tone plain and specific.',
|
|
});
|
|
|
|
interface FormInputs {
|
|
icon?: string | null;
|
|
name: string;
|
|
}
|
|
|
|
export const EditGroupModal = observer(({channelId}: {channelId: string}) => {
|
|
const {i18n} = useLingui();
|
|
const channel = Channels.getChannel(channelId);
|
|
const [hasClearedIcon, setHasClearedIcon] = useState(false);
|
|
const [previewIconUrl, setPreviewIconUrl] = useState<string | null>(null);
|
|
const form = useForm<FormInputs>({
|
|
defaultValues: useMemo(() => ({name: channel?.name || ''}), [channel]),
|
|
});
|
|
const remoteValues: FormInputs | null = channel ? {name: channel.name || ''} : null;
|
|
const {commitRemoteValues} = useRemoteFormReset<FormInputs>({
|
|
form,
|
|
identityKey: channelId,
|
|
remoteValues,
|
|
isDirty: form.formState.isDirty || Boolean(previewIconUrl) || hasClearedIcon,
|
|
onApply: () => {
|
|
setPreviewIconUrl(null);
|
|
setHasClearedIcon(false);
|
|
},
|
|
});
|
|
const handleIconUpload = useCallback(
|
|
async (file: File | null) => {
|
|
try {
|
|
if (!file) return;
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
showChannelErrorModal({
|
|
title: i18n._(ICON_FILE_IS_TOO_LARGE_TITLE_DESCRIPTOR),
|
|
message: i18n._(ICON_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, {
|
|
imageMaxSizeLabel: IMAGE_MAX_SIZE_LABEL,
|
|
}),
|
|
dataFlx: 'channel.edit-group-modal.icon-file-too-large.generic-error-modal',
|
|
});
|
|
return;
|
|
}
|
|
const svg = isSvgFile(file);
|
|
if (!svg && !(await canCropFormat(file.type))) {
|
|
showChannelErrorModal({
|
|
title: i18n._(UNSUPPORTED_ICON_FORMAT_DESCRIPTOR),
|
|
message: getAssetFormatErrorMessage(i18n, 'guild_icon', 'unsupported_mime'),
|
|
dataFlx: 'channel.edit-group-modal.unsupported-icon-format.generic-error-modal',
|
|
});
|
|
return;
|
|
}
|
|
const animated = svg ? false : await isAnimatedFile(file);
|
|
if (animated) {
|
|
showChannelErrorModal({
|
|
title: i18n._(ANIMATED_ICONS_ARE_NOT_SUPPORTED_TITLE_DESCRIPTOR),
|
|
message: i18n._(ANIMATED_ICONS_ARE_NOT_SUPPORTED_PLEASE_USE_DESCRIPTOR),
|
|
dataFlx: 'channel.edit-group-modal.animated-icon.generic-error-modal',
|
|
});
|
|
return;
|
|
}
|
|
const base64 = svg ? await readImageFileAsUploadDataUrl(file) : await AvatarUtils.fileToBase64(file);
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<AssetCropModal
|
|
assetType={AssetType.CHANNEL_ICON}
|
|
imageUrl={base64}
|
|
sourceMimeType={svg ? 'image/svg+xml' : file.type}
|
|
onCropComplete={(croppedBlob) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const croppedBase64 = reader.result as string;
|
|
form.setValue('icon', croppedBase64);
|
|
setPreviewIconUrl(croppedBase64);
|
|
setHasClearedIcon(false);
|
|
form.clearErrors('icon');
|
|
};
|
|
reader.onerror = () => {
|
|
showChannelErrorModal({
|
|
title: i18n._(COULDN_T_PROCESS_IMAGE_DESCRIPTOR),
|
|
message: i18n._(FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR),
|
|
dataFlx: 'channel.edit-group-modal.process-cropped-image-failed.generic-error-modal',
|
|
});
|
|
};
|
|
reader.readAsDataURL(croppedBlob);
|
|
}}
|
|
onSkip={() => {
|
|
form.setValue('icon', base64);
|
|
setPreviewIconUrl(base64);
|
|
setHasClearedIcon(false);
|
|
form.clearErrors('icon');
|
|
}}
|
|
data-flx="channel.edit-group-modal.handle-icon-upload.asset-crop-modal"
|
|
/>
|
|
)),
|
|
);
|
|
} catch {
|
|
showChannelErrorModal({
|
|
title: i18n._(INVALID_IMAGE_DESCRIPTOR),
|
|
message: i18n._(INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR),
|
|
dataFlx: 'channel.edit-group-modal.invalid-image.generic-error-modal',
|
|
});
|
|
}
|
|
},
|
|
[form, i18n],
|
|
);
|
|
const handleIconUploadClick = useCallback(async () => {
|
|
const [file] = await openFilePicker({accept: getAcceptStringFiltered('guild_icon', false)});
|
|
await handleIconUpload(file ?? null);
|
|
}, [handleIconUpload]);
|
|
const handleOpenIconUpload = useCallback(() => {
|
|
openAssetSourceModal({
|
|
title: i18n._(CHANGE_ICON_DESCRIPTOR),
|
|
uploadHint: formatImageUploadRecommendedHint(i18n, {
|
|
formats: STATIC_IMAGE_FORMATS,
|
|
maxSize: IMAGE_MAX_SIZE_LABEL,
|
|
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
|
|
}),
|
|
onPickUpload: handleIconUploadClick,
|
|
showGifOption: false,
|
|
});
|
|
}, [handleIconUploadClick, i18n]);
|
|
const handleClearIcon = useCallback(() => {
|
|
form.setValue('icon', null);
|
|
setPreviewIconUrl(null);
|
|
setHasClearedIcon(true);
|
|
}, [form]);
|
|
const onSubmit = useCallback(
|
|
async (data: FormInputs) => {
|
|
const updateData: {icon?: string | null; name: string} = {name: data.name};
|
|
assignTransientUploadFieldMutation(updateData, 'icon', {
|
|
value: data.icon,
|
|
previewUrl: previewIconUrl,
|
|
hasCleared: hasClearedIcon,
|
|
});
|
|
const newChannel = await ChannelCommands.update(channelId, updateData);
|
|
commitRemoteValues({name: newChannel.name || data.name});
|
|
ToastCommands.createToast({type: 'success', children: <Trans>Group updated</Trans>});
|
|
ModalCommands.pop();
|
|
},
|
|
[channelId, commitRemoteValues, previewIconUrl, hasClearedIcon],
|
|
);
|
|
const {handleSubmit, isSubmitting} = useFormSubmit({
|
|
form,
|
|
onSubmit,
|
|
defaultErrorField: 'name',
|
|
});
|
|
if (!channel) {
|
|
return null;
|
|
}
|
|
const iconPresentable = hasClearedIcon
|
|
? null
|
|
: (previewIconUrl ?? AvatarUtils.getChannelIconURL({id: channel.id, icon: channel.icon}));
|
|
const placeholderName = channel ? ChannelUtils.getDMDisplayName(channel) : '';
|
|
return (
|
|
<Modal.Root size="small" centered data-flx="channel.edit-group-modal.modal-root">
|
|
<Form form={form} onSubmit={handleSubmit} data-flx="channel.edit-group-modal.form.submit">
|
|
<Modal.Header title={i18n._(EDIT_GROUP_DESCRIPTOR)} data-flx="channel.edit-group-modal.modal-header" />
|
|
<Modal.Content data-flx="channel.edit-group-modal.modal-content">
|
|
<Modal.ContentLayout data-flx="channel.edit-group-modal.modal-content-layout">
|
|
<div className={styles.iconSection} data-flx="channel.edit-group-modal.icon-section">
|
|
<div className={styles.iconLabel} data-flx="channel.edit-group-modal.icon-label">
|
|
<Trans>Group icon</Trans>
|
|
</div>
|
|
<div className={styles.iconContainer} data-flx="channel.edit-group-modal.icon-container">
|
|
{previewIconUrl ? (
|
|
<div
|
|
className={styles.iconPreview}
|
|
style={{
|
|
backgroundImage: `url(${previewIconUrl})`,
|
|
}}
|
|
data-flx="channel.edit-group-modal.icon-preview"
|
|
/>
|
|
) : iconPresentable ? (
|
|
<div
|
|
className={styles.iconPreview}
|
|
style={{
|
|
backgroundImage: `url(${iconPresentable})`,
|
|
}}
|
|
data-flx="channel.edit-group-modal.icon-preview--2"
|
|
/>
|
|
) : (
|
|
<div className={styles.iconPlaceholder} data-flx="channel.edit-group-modal.icon-placeholder">
|
|
<PlusIcon
|
|
weight="regular"
|
|
className={styles.iconPlaceholderIcon}
|
|
data-flx="channel.edit-group-modal.icon-placeholder-icon"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className={styles.iconActions} data-flx="channel.edit-group-modal.icon-actions">
|
|
<div className={styles.iconButtonGroup} data-flx="channel.edit-group-modal.icon-button-group">
|
|
<Button
|
|
variant="secondary"
|
|
small={true}
|
|
onClick={handleOpenIconUpload}
|
|
data-flx="channel.edit-group-modal.button.icon-upload-click"
|
|
>
|
|
{previewIconUrl || iconPresentable ? <Trans>Change icon</Trans> : <Trans>Upload icon</Trans>}
|
|
</Button>
|
|
{(previewIconUrl || iconPresentable) && (
|
|
<Button
|
|
variant="secondary"
|
|
small={true}
|
|
onClick={handleClearIcon}
|
|
data-flx="channel.edit-group-modal.button.clear-icon"
|
|
>
|
|
<Trans>Remove icon</Trans>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{form.formState.errors.icon?.message && (
|
|
<p className={styles.iconError} data-flx="channel.edit-group-modal.icon-error">
|
|
{form.formState.errors.icon.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Input
|
|
data-flx="channel.edit-group-modal.input.text"
|
|
{...form.register('name', {
|
|
maxLength: {
|
|
value: 100,
|
|
message: i18n._(GROUP_NAME_MUST_NOT_EXCEED_100_CHARACTERS_DESCRIPTOR),
|
|
},
|
|
})}
|
|
type="text"
|
|
label={i18n._(GROUP_NAME_DESCRIPTOR)}
|
|
placeholder={placeholderName || i18n._(MY_GROUP_DESCRIPTOR)}
|
|
maxLength={100}
|
|
error={form.formState.errors.name?.message}
|
|
/>
|
|
</Modal.ContentLayout>
|
|
</Modal.Content>
|
|
<Modal.Footer data-flx="channel.edit-group-modal.modal-footer">
|
|
<Button type="submit" submitting={isSubmitting} data-flx="channel.edit-group-modal.button.submit">
|
|
<Trans>Save</Trans>
|
|
</Button>
|
|
</Modal.Footer>
|
|
</Form>
|
|
</Modal.Root>
|
|
);
|
|
});
|