Merge branch 'multiple-attachments' into 'develop'
Chats: allow uploading multiple attachments See merge request soapbox-pub/soapbox!2268
This commit is contained in:
commit
783a79d306
|
@ -44,7 +44,7 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
|
||||||
resetFileKey: number | null
|
resetFileKey: number | null
|
||||||
resetContentKey: number | null
|
resetContentKey: number | null
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[]
|
||||||
onDeleteAttachment?: () => void
|
onDeleteAttachment?: (i: number) => void
|
||||||
isUploading?: boolean
|
isUploading?: boolean
|
||||||
uploadProgress?: number
|
uploadProgress?: number
|
||||||
}
|
}
|
||||||
|
@ -75,13 +75,14 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
||||||
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocked_by']));
|
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocked_by']));
|
||||||
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
|
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
|
||||||
const maxCharacterCount = useAppSelector((state) => state.instance.getIn(['configuration', 'chats', 'max_characters']) as number);
|
const maxCharacterCount = useAppSelector((state) => state.instance.getIn(['configuration', 'chats', 'max_characters']) as number);
|
||||||
|
const attachmentLimit = useAppSelector(state => state.instance.configuration.getIn(['chats', 'max_media_attachments']) as number);
|
||||||
|
|
||||||
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
|
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
|
||||||
const isSuggestionsAvailable = suggestions.list.length > 0;
|
const isSuggestionsAvailable = suggestions.list.length > 0;
|
||||||
|
|
||||||
const hasAttachment = attachments.length > 0;
|
const hasAttachment = attachments.length > 0;
|
||||||
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
|
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
|
||||||
const isSubmitDisabled = disabled || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
|
const isSubmitDisabled = disabled || isUploading || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
|
||||||
|
|
||||||
const overLimitText = maxCharacterCount ? maxCharacterCount - value?.length : '';
|
const overLimitText = maxCharacterCount ? maxCharacterCount - value?.length : '';
|
||||||
|
|
||||||
|
@ -174,6 +175,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
||||||
resetFileKey={resetFileKey}
|
resetFileKey={resetFileKey}
|
||||||
iconClassName='w-5 h-5'
|
iconClassName='w-5 h-5'
|
||||||
className='text-primary-500'
|
className='text-primary-500'
|
||||||
|
disabled={isUploading || (attachments.length >= attachmentLimit)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Textarea } from 'soapbox/components/ui';
|
import { HStack, Textarea } from 'soapbox/components/ui';
|
||||||
import { Attachment } from 'soapbox/types/entities';
|
import { Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
import ChatPendingUpload from './chat-pending-upload';
|
import ChatPendingUpload from './chat-pending-upload';
|
||||||
|
@ -8,7 +8,7 @@ import ChatUpload from './chat-upload';
|
||||||
|
|
||||||
interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[]
|
||||||
onDeleteAttachment?: () => void
|
onDeleteAttachment?: (i: number) => void
|
||||||
isUploading?: boolean
|
isUploading?: boolean
|
||||||
uploadProgress?: number
|
uploadProgress?: number
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,14 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
|
||||||
uploadProgress = 0,
|
uploadProgress = 0,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
|
const handleDeleteAttachment = (i: number) => {
|
||||||
|
return () => {
|
||||||
|
if (onDeleteAttachment) {
|
||||||
|
onDeleteAttachment(i);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`
|
<div className={`
|
||||||
block
|
block
|
||||||
|
@ -35,19 +43,23 @@ const ChatTextarea: React.FC<IChatTextarea> = ({
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{(!!attachments?.length || isUploading) && (
|
{(!!attachments?.length || isUploading) && (
|
||||||
<div className='flex p-3 pb-0'>
|
<HStack className='-ml-2 -mt-2 p-3 pb-0' wrap>
|
||||||
{isUploading && (
|
{attachments?.map((attachment, i) => (
|
||||||
<ChatPendingUpload progress={uploadProgress} />
|
<div className='ml-2 mt-2 flex'>
|
||||||
)}
|
|
||||||
|
|
||||||
{attachments?.map(attachment => (
|
|
||||||
<ChatUpload
|
<ChatUpload
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
attachment={attachment}
|
attachment={attachment}
|
||||||
onDelete={onDeleteAttachment}
|
onDelete={handleDeleteAttachment(i)}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isUploading && (
|
||||||
|
<div className='ml-2 mt-2 flex'>
|
||||||
|
<ChatPendingUpload progress={uploadProgress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Textarea theme='transparent' {...rest} />
|
<Textarea theme='transparent' {...rest} />
|
||||||
|
|
|
@ -21,6 +21,7 @@ const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment
|
||||||
|
|
||||||
switch (attachment.type) {
|
switch (attachment.type) {
|
||||||
case 'image':
|
case 'image':
|
||||||
|
case 'gifv':
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className='pointer-events-none h-full w-full object-cover'
|
className='pointer-events-none h-full w-full object-cover'
|
||||||
|
|
|
@ -5,17 +5,21 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { uploadMedia } from 'soapbox/actions/media';
|
import { uploadMedia } from 'soapbox/actions/media';
|
||||||
import { Stack } from 'soapbox/components/ui';
|
import { Stack } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
import { IChat, useChatActions } from 'soapbox/queries/chats';
|
import { IChat, useChatActions } from 'soapbox/queries/chats';
|
||||||
|
import toast from 'soapbox/toast';
|
||||||
|
|
||||||
import ChatComposer from './chat-composer';
|
import ChatComposer from './chat-composer';
|
||||||
import ChatMessageList from './chat-message-list';
|
import ChatMessageList from './chat-message-list';
|
||||||
|
|
||||||
|
import type { Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000));
|
const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000));
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
failedToSend: { id: 'chat.failed_to_send', defaultMessage: 'Message failed to send.' },
|
failedToSend: { id: 'chat.failed_to_send', defaultMessage: 'Message failed to send.' },
|
||||||
|
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ChatInterface {
|
interface ChatInterface {
|
||||||
|
@ -49,19 +53,20 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const { createChatMessage, acceptChat } = useChatActions(chat.id);
|
const { createChatMessage, acceptChat } = useChatActions(chat.id);
|
||||||
|
const attachmentLimit = useAppSelector(state => state.instance.configuration.getIn(['chats', 'max_media_attachments']) as number);
|
||||||
|
|
||||||
const [content, setContent] = useState<string>('');
|
const [content, setContent] = useState<string>('');
|
||||||
const [attachment, setAttachment] = useState<any>(undefined);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [resetContentKey, setResetContentKey] = useState<number>(fileKeyGen());
|
const [resetContentKey, setResetContentKey] = useState<number>(fileKeyGen());
|
||||||
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
|
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
|
||||||
const [errorMessage, setErrorMessage] = useState<string>();
|
const [errorMessage, setErrorMessage] = useState<string>();
|
||||||
|
|
||||||
const isSubmitDisabled = content.length === 0 && !attachment;
|
const isSubmitDisabled = content.length === 0 && attachments.length === 0;
|
||||||
|
|
||||||
const submitMessage = () => {
|
const submitMessage = () => {
|
||||||
createChatMessage.mutate({ chatId: chat.id, content, mediaId: attachment?.id }, {
|
createChatMessage.mutate({ chatId: chat.id, content, mediaIds: attachments.map(a => a.id) }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setErrorMessage(undefined);
|
setErrorMessage(undefined);
|
||||||
},
|
},
|
||||||
|
@ -80,7 +85,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
clearNativeInputValue(inputRef.current);
|
clearNativeInputValue(inputRef.current);
|
||||||
}
|
}
|
||||||
setContent('');
|
setContent('');
|
||||||
setAttachment(undefined);
|
setAttachments([]);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setResetFileKey(fileKeyGen());
|
setResetFileKey(fileKeyGen());
|
||||||
|
@ -128,8 +133,10 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
|
|
||||||
const handleMouseOver = () => markRead();
|
const handleMouseOver = () => markRead();
|
||||||
|
|
||||||
const handleRemoveFile = () => {
|
const handleRemoveFile = (i: number) => {
|
||||||
setAttachment(undefined);
|
const newAttachments = [...attachments];
|
||||||
|
newAttachments.splice(i, 1);
|
||||||
|
setAttachments(newAttachments);
|
||||||
setResetFileKey(fileKeyGen());
|
setResetFileKey(fileKeyGen());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -139,13 +146,18 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFiles = (files: FileList) => {
|
const handleFiles = (files: FileList) => {
|
||||||
|
if (files.length + attachments.length > attachmentLimit) {
|
||||||
|
toast.error(messages.uploadErrorLimit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
data.append('file', files[0]);
|
data.append('file', files[0]);
|
||||||
|
|
||||||
dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => {
|
dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => {
|
||||||
setAttachment(normalizeAttachment(response.data));
|
setAttachments([...attachments, normalizeAttachment(response.data)]);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
@ -175,7 +187,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
resetFileKey={resetFileKey}
|
resetFileKey={resetFileKey}
|
||||||
resetContentKey={resetContentKey}
|
resetContentKey={resetContentKey}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
attachments={attachment ? [attachment] : []}
|
attachments={attachments}
|
||||||
onDeleteAttachment={handleRemoveFile}
|
onDeleteAttachment={handleRemoveFile}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
uploadProgress={uploadProgress}
|
uploadProgress={uploadProgress}
|
||||||
|
|
|
@ -10,7 +10,8 @@ describe('normalizeInstance()', () => {
|
||||||
configuration: {
|
configuration: {
|
||||||
media_attachments: {},
|
media_attachments: {},
|
||||||
chats: {
|
chats: {
|
||||||
max_characters: 500,
|
max_characters: 5000,
|
||||||
|
max_media_attachments: 1,
|
||||||
},
|
},
|
||||||
polls: {
|
polls: {
|
||||||
max_options: 4,
|
max_options: 4,
|
||||||
|
|
|
@ -22,7 +22,8 @@ export const InstanceRecord = ImmutableRecord({
|
||||||
configuration: ImmutableMap<string, any>({
|
configuration: ImmutableMap<string, any>({
|
||||||
media_attachments: ImmutableMap<string, any>(),
|
media_attachments: ImmutableMap<string, any>(),
|
||||||
chats: ImmutableMap<string, number>({
|
chats: ImmutableMap<string, number>({
|
||||||
max_characters: 500,
|
max_characters: 5000,
|
||||||
|
max_media_attachments: 1,
|
||||||
}),
|
}),
|
||||||
polls: ImmutableMap<string, number>({
|
polls: ImmutableMap<string, number>({
|
||||||
max_options: 4,
|
max_options: 4,
|
||||||
|
|
|
@ -231,9 +231,13 @@ const useChatActions = (chatId: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const createChatMessage = useMutation(
|
const createChatMessage = useMutation(
|
||||||
(
|
({ chatId, content, mediaIds }: { chatId: string, content: string, mediaIds?: string[] }) => {
|
||||||
{ chatId, content, mediaId }: { chatId: string, content: string, mediaId?: string },
|
return api.post<ChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, {
|
||||||
) => api.post<ChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, { content, media_id: mediaId, media_ids: [mediaId] }),
|
content,
|
||||||
|
media_id: (mediaIds && mediaIds.length === 1) ? mediaIds[0] : undefined, // Pleroma backwards-compat
|
||||||
|
media_ids: mediaIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
{
|
{
|
||||||
retry: false,
|
retry: false,
|
||||||
onMutate: async (variables) => {
|
onMutate: async (variables) => {
|
||||||
|
|
|
@ -13,7 +13,8 @@ describe('instance reducer', () => {
|
||||||
description_limit: 1500,
|
description_limit: 1500,
|
||||||
configuration: {
|
configuration: {
|
||||||
chats: {
|
chats: {
|
||||||
max_characters: 500,
|
max_characters: 5000,
|
||||||
|
max_media_attachments: 1,
|
||||||
},
|
},
|
||||||
statuses: {
|
statuses: {
|
||||||
max_characters: 500,
|
max_characters: 500,
|
||||||
|
|
Loading…
Reference in New Issue