Merge branch 'chat-composer' into 'develop'
Chats: move chat attachments into composer See merge request soapbox-pub/soapbox!2229
This commit is contained in:
commit
d44be7fbf8
13 changed files with 290 additions and 60 deletions
|
@ -1,13 +1,32 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { spring } from 'react-motion';
|
||||||
|
|
||||||
|
import Motion from 'soapbox/features/ui/util/optional-motion';
|
||||||
|
|
||||||
interface IProgressBar {
|
interface IProgressBar {
|
||||||
progress: number,
|
/** Number between 0 and 1 to represent the percentage complete. */
|
||||||
|
progress: number
|
||||||
|
/** Height of the progress bar. */
|
||||||
|
size?: 'sm' | 'md'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A horizontal meter filled to the given percentage. */
|
/** A horizontal meter filled to the given percentage. */
|
||||||
const ProgressBar: React.FC<IProgressBar> = ({ progress }) => (
|
const ProgressBar: React.FC<IProgressBar> = ({ progress, size = 'md' }) => (
|
||||||
<div className='h-2.5 w-full overflow-hidden rounded-full bg-gray-300 dark:bg-primary-800'>
|
<div
|
||||||
<div className='h-full bg-secondary-500' style={{ width: `${Math.floor(progress * 100)}%` }} />
|
className={clsx('h-2.5 w-full overflow-hidden rounded-lg bg-gray-300 dark:bg-primary-800', {
|
||||||
|
'h-2.5': size === 'md',
|
||||||
|
'h-[6px]': size === 'sm',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress * 100) }}>
|
||||||
|
{({ width }) => (
|
||||||
|
<div
|
||||||
|
className='h-full bg-secondary-500'
|
||||||
|
style={{ width: `${width}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Motion>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,8 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
|
||||||
hasError?: boolean,
|
hasError?: boolean,
|
||||||
/** Whether or not you can resize the teztarea */
|
/** Whether or not you can resize the teztarea */
|
||||||
isResizeable?: boolean,
|
isResizeable?: boolean,
|
||||||
|
/** Textarea theme. */
|
||||||
|
theme?: 'default' | 'transparent',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Textarea with custom styles. */
|
/** Textarea with custom styles. */
|
||||||
|
@ -37,6 +39,7 @@ const Textarea = React.forwardRef(({
|
||||||
autoGrow = false,
|
autoGrow = false,
|
||||||
maxRows = 10,
|
maxRows = 10,
|
||||||
minRows = 1,
|
minRows = 1,
|
||||||
|
theme = 'default',
|
||||||
...props
|
...props
|
||||||
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
|
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
|
||||||
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
|
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
|
||||||
|
@ -72,9 +75,10 @@ const Textarea = React.forwardRef(({
|
||||||
ref={ref}
|
ref={ref}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className={clsx({
|
className={clsx('block w-full rounded-md text-gray-900 placeholder:text-gray-600 dark:text-gray-100 dark:placeholder:text-gray-600 sm:text-sm', {
|
||||||
'bg-white dark:bg-transparent shadow-sm block w-full sm:text-sm rounded-md text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
|
'bg-white dark:bg-transparent shadow-sm border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
|
||||||
true,
|
theme === 'default',
|
||||||
|
'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent',
|
||||||
'font-mono': isCodeEditor,
|
'font-mono': isCodeEditor,
|
||||||
'text-red-600 border-red-600': hasError,
|
'text-red-600 border-red-600': hasError,
|
||||||
'resize-none': !isResizeable,
|
'resize-none': !isResizeable,
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { spring } from 'react-motion';
|
|
||||||
|
|
||||||
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
|
import { HStack, Icon, ProgressBar, Stack, Text } from 'soapbox/components/ui';
|
||||||
import Motion from 'soapbox/features/ui/util/optional-motion';
|
|
||||||
|
|
||||||
interface IUploadProgress {
|
interface IUploadProgress {
|
||||||
/** Number between 0 and 1 to represent the percentage complete. */
|
/** Number between 0 and 100 to represent the percentage complete. */
|
||||||
progress: number,
|
progress: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Displays a progress bar for uploading files. */
|
/** Displays a progress bar for uploading files. */
|
||||||
|
@ -24,16 +22,7 @@ const UploadProgress: React.FC<IUploadProgress> = ({ progress }) => {
|
||||||
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />
|
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className='relative h-1.5 w-full rounded-lg bg-gray-200'>
|
<ProgressBar progress={progress / 100} size='sm' />
|
||||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
|
|
||||||
{({ width }) =>
|
|
||||||
(<div
|
|
||||||
className='absolute left-0 top-0 h-1.5 rounded-lg bg-primary-600'
|
|
||||||
style={{ width: `${width}%` }}
|
|
||||||
/>)
|
|
||||||
}
|
|
||||||
</Motion>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,13 +3,16 @@ import { defineMessages, IntlShape, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { unblockAccount } from 'soapbox/actions/accounts';
|
import { unblockAccount } from 'soapbox/actions/accounts';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import { Button, Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover, HStack, IconButton, Stack, Text, Textarea } from 'soapbox/components/ui';
|
import { Button, Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
|
||||||
import { useChatContext } from 'soapbox/contexts/chat-context';
|
import { useChatContext } from 'soapbox/contexts/chat-context';
|
||||||
import UploadButton from 'soapbox/features/compose/components/upload-button';
|
import UploadButton from 'soapbox/features/compose/components/upload-button';
|
||||||
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
|
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
|
||||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||||
|
import { Attachment } from 'soapbox/types/entities';
|
||||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||||
|
|
||||||
|
import ChatTextarea from './chat-textarea';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
|
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
|
||||||
send: { id: 'chat.actions.send', defaultMessage: 'Send' },
|
send: { id: 'chat.actions.send', defaultMessage: 'Send' },
|
||||||
|
@ -39,7 +42,10 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
|
||||||
errorMessage: string | undefined
|
errorMessage: string | undefined
|
||||||
onSelectFile: (files: FileList, intl: IntlShape) => void
|
onSelectFile: (files: FileList, intl: IntlShape) => void
|
||||||
resetFileKey: number | null
|
resetFileKey: number | null
|
||||||
hasAttachment?: boolean
|
attachments?: Attachment[]
|
||||||
|
onDeleteAttachment?: () => void
|
||||||
|
isUploading?: boolean
|
||||||
|
uploadProgress?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Textarea input for chats. */
|
/** Textarea input for chats. */
|
||||||
|
@ -53,7 +59,10 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
resetFileKey,
|
resetFileKey,
|
||||||
onPaste,
|
onPaste,
|
||||||
hasAttachment,
|
attachments = [],
|
||||||
|
onDeleteAttachment,
|
||||||
|
isUploading,
|
||||||
|
uploadProgress,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
@ -68,6 +77,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
||||||
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 isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
|
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
|
||||||
const isSubmitDisabled = disabled || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
|
const isSubmitDisabled = disabled || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
|
||||||
|
|
||||||
|
@ -167,12 +177,9 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack grow>
|
<Stack grow>
|
||||||
<Combobox
|
<Combobox onSelect={onSelectComboboxOption}>
|
||||||
aria-labelledby='demo'
|
|
||||||
onSelect={onSelectComboboxOption}
|
|
||||||
>
|
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
as={Textarea}
|
as={ChatTextarea}
|
||||||
autoFocus
|
autoFocus
|
||||||
ref={ref}
|
ref={ref}
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
|
@ -184,6 +191,10 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
|
||||||
autoGrow
|
autoGrow
|
||||||
maxRows={5}
|
maxRows={5}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
attachments={attachments}
|
||||||
|
onDeleteAttachment={onDeleteAttachment}
|
||||||
|
isUploading={isUploading}
|
||||||
|
uploadProgress={uploadProgress}
|
||||||
/>
|
/>
|
||||||
{isSuggestionsAvailable ? (
|
{isSuggestionsAvailable ? (
|
||||||
<ComboboxPopover>
|
<ComboboxPopover>
|
||||||
|
|
|
@ -195,13 +195,12 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
|
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
|
||||||
const { attachment } = chatMessage;
|
if (!chatMessage.media_attachments.size) return null;
|
||||||
if (!attachment) return null;
|
|
||||||
return (
|
return (
|
||||||
<Bundle fetchComponent={MediaGallery}>
|
<Bundle fetchComponent={MediaGallery}>
|
||||||
{(Component: any) => (
|
{(Component: any) => (
|
||||||
<Component
|
<Component
|
||||||
media={ImmutableList([attachment])}
|
media={chatMessage.media_attachments}
|
||||||
onOpenMedia={onOpenMedia}
|
onOpenMedia={onOpenMedia}
|
||||||
visible
|
visible
|
||||||
/>
|
/>
|
||||||
|
@ -316,7 +315,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
space={0.5}
|
space={0.5}
|
||||||
className={clsx({
|
className={clsx({
|
||||||
'max-w-[85%]': true,
|
'max-w-[85%]': true,
|
||||||
'flex-1': chatMessage.attachment,
|
'flex-1': !!chatMessage.media_attachments.size,
|
||||||
'order-2': isMyMessage,
|
'order-2': isMyMessage,
|
||||||
'order-1': !isMyMessage,
|
'order-1': !isMyMessage,
|
||||||
})}
|
})}
|
||||||
|
@ -331,8 +330,8 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
className={
|
className={
|
||||||
clsx({
|
clsx({
|
||||||
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
|
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
|
||||||
'rounded-tr-sm': chatMessage.attachment && isMyMessage,
|
'rounded-tr-sm': (!!chatMessage.media_attachments.size) && isMyMessage,
|
||||||
'rounded-tl-sm': chatMessage.attachment && !isMyMessage,
|
'rounded-tl-sm': (!!chatMessage.media_attachments.size) && !isMyMessage,
|
||||||
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
|
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
|
||||||
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
|
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
|
||||||
'bg-primary-500 text-white': isMyMessage,
|
'bg-primary-500 text-white': isMyMessage,
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { ProgressBar } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
interface IChatPendingUpload {
|
||||||
|
progress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Displays a loading thumbnail for an upload in the chat composer. */
|
||||||
|
const ChatPendingUpload: React.FC<IChatPendingUpload> = ({ progress }) => {
|
||||||
|
return (
|
||||||
|
<div className='relative isolate inline-flex h-24 w-24 items-center justify-center overflow-hidden rounded-lg bg-gray-200 p-4 dark:bg-primary-900'>
|
||||||
|
<ProgressBar progress={progress} size='sm' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatPendingUpload;
|
58
app/soapbox/features/chats/components/chat-textarea.tsx
Normal file
58
app/soapbox/features/chats/components/chat-textarea.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Textarea } from 'soapbox/components/ui';
|
||||||
|
import { Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
import ChatPendingUpload from './chat-pending-upload';
|
||||||
|
import ChatUpload from './chat-upload';
|
||||||
|
|
||||||
|
interface IChatTextarea extends React.ComponentProps<typeof Textarea> {
|
||||||
|
attachments?: Attachment[]
|
||||||
|
onDeleteAttachment?: () => void
|
||||||
|
isUploading?: boolean
|
||||||
|
uploadProgress?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Custom textarea for chats. */
|
||||||
|
const ChatTextarea: React.FC<IChatTextarea> = ({
|
||||||
|
attachments,
|
||||||
|
onDeleteAttachment,
|
||||||
|
isUploading = false,
|
||||||
|
uploadProgress = 0,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`
|
||||||
|
block
|
||||||
|
w-full
|
||||||
|
rounded-md border border-gray-400
|
||||||
|
bg-white text-gray-900
|
||||||
|
shadow-sm placeholder:text-gray-600
|
||||||
|
focus-within:border-primary-500
|
||||||
|
focus-within:ring-1 focus-within:ring-primary-500 dark:border-gray-800 dark:bg-gray-800
|
||||||
|
dark:text-gray-100 dark:ring-1 dark:ring-gray-800 dark:placeholder:text-gray-600 dark:focus-within:border-primary-500
|
||||||
|
dark:focus-within:ring-primary-500 sm:text-sm
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{(!!attachments?.length || isUploading) && (
|
||||||
|
<div className='flex p-3 pb-0'>
|
||||||
|
{isUploading && (
|
||||||
|
<ChatPendingUpload progress={uploadProgress} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{attachments?.map(attachment => (
|
||||||
|
<ChatUpload
|
||||||
|
key={attachment.id}
|
||||||
|
attachment={attachment}
|
||||||
|
onDelete={onDeleteAttachment}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Textarea theme='transparent' {...rest} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatTextarea;
|
|
@ -0,0 +1,55 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Icon } from 'soapbox/components/ui';
|
||||||
|
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
|
||||||
|
|
||||||
|
import type { Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const defaultIcon = require('@tabler/icons/paperclip.svg');
|
||||||
|
|
||||||
|
interface IChatUploadPreview {
|
||||||
|
className?: string
|
||||||
|
attachment: Attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a generic preview for an upload depending on its media type.
|
||||||
|
* It fills its container and is expected to be sized by its parent.
|
||||||
|
*/
|
||||||
|
const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment }) => {
|
||||||
|
const mimeType = attachment.pleroma.get('mime_type') as string | undefined;
|
||||||
|
|
||||||
|
switch (attachment.type) {
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className='pointer-events-none h-full w-full object-cover'
|
||||||
|
src={attachment.preview_url}
|
||||||
|
alt=''
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
className='pointer-events-none h-full w-full object-cover'
|
||||||
|
src={attachment.preview_url}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
controls={false}
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className='pointer-events-none flex h-full w-full items-center justify-center'>
|
||||||
|
<Icon
|
||||||
|
className='mx-auto my-12 h-16 w-16 text-gray-800 dark:text-gray-200'
|
||||||
|
src={MIMETYPE_ICONS[mimeType || ''] || defaultIcon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatUploadPreview;
|
66
app/soapbox/features/chats/components/chat-upload.tsx
Normal file
66
app/soapbox/features/chats/components/chat-upload.tsx
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import Blurhash from 'soapbox/components/blurhash';
|
||||||
|
import { Icon } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import ChatUploadPreview from './chat-upload-preview';
|
||||||
|
|
||||||
|
import type { Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
interface IChatUpload {
|
||||||
|
attachment: Attachment,
|
||||||
|
onDelete?(): void,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An attachment uploaded to the chat composer, before sending. */
|
||||||
|
const ChatUpload: React.FC<IChatUpload> = ({ attachment, onDelete }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const clickable = attachment.type !== 'unknown';
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
dispatch(openModal('MEDIA', { media: ImmutableList.of(attachment), index: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative isolate inline-block h-24 w-24 overflow-hidden rounded-lg bg-gray-200 dark:bg-primary-900'>
|
||||||
|
<Blurhash hash={attachment.blurhash} className='absolute inset-0 -z-10 h-full w-full' />
|
||||||
|
|
||||||
|
<div className='absolute right-[6px] top-[6px]'>
|
||||||
|
<RemoveButton onClick={onDelete} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={clickable ? handleOpenModal : undefined}
|
||||||
|
className={clsx('h-full w-full', { 'cursor-zoom-in': clickable, 'cursor-default': !clickable })}
|
||||||
|
>
|
||||||
|
<ChatUploadPreview attachment={attachment} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IRemoveButton {
|
||||||
|
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Floating button to remove an attachment. */
|
||||||
|
const RemoveButton: React.FC<IRemoveButton> = ({ onClick }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={onClick}
|
||||||
|
className='flex h-5 w-5 items-center justify-center rounded-full bg-secondary-500 p-1'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className='h-3 w-3 text-white'
|
||||||
|
src={require('@tabler/icons/x.svg')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatUpload;
|
|
@ -5,8 +5,6 @@ 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 Upload from 'soapbox/components/upload';
|
|
||||||
import UploadProgress from 'soapbox/components/upload-progress';
|
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } 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';
|
||||||
|
@ -164,22 +162,6 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
<ChatMessageList chat={chat} />
|
<ChatMessageList chat={chat} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{attachment && (
|
|
||||||
<div className='relative h-48'>
|
|
||||||
<Upload
|
|
||||||
media={attachment}
|
|
||||||
onDelete={handleRemoveFile}
|
|
||||||
withPreview
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isUploading && (
|
|
||||||
<div className='p-4'>
|
|
||||||
<UploadProgress progress={uploadProgress * 100} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChatComposer
|
<ChatComposer
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
@ -190,7 +172,10 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
|
||||||
onSelectFile={handleFiles}
|
onSelectFile={handleFiles}
|
||||||
resetFileKey={resetFileKey}
|
resetFileKey={resetFileKey}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
hasAttachment={!!attachment}
|
attachments={attachment ? [attachment] : []}
|
||||||
|
onDeleteAttachment={handleRemoveFile}
|
||||||
|
isUploading={isUploading}
|
||||||
|
uploadProgress={uploadProgress}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
23
app/soapbox/normalizers/__tests__/chat-message.test.ts
Normal file
23
app/soapbox/normalizers/__tests__/chat-message.test.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
|
import { normalizeAttachment } from '../attachment';
|
||||||
|
import { normalizeChatMessage } from '../chat-message';
|
||||||
|
|
||||||
|
describe('normalizeChatMessage()', () => {
|
||||||
|
it('upgrades attachment to media_attachments', () => {
|
||||||
|
const message = {
|
||||||
|
id: 'abc',
|
||||||
|
attachment: normalizeAttachment({
|
||||||
|
id: 'def',
|
||||||
|
url: 'https://gleasonator.com/favicon.png',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = normalizeChatMessage(message);
|
||||||
|
|
||||||
|
expect(ImmutableRecord.isRecord(result)).toBe(true);
|
||||||
|
expect(result.id).toEqual('abc');
|
||||||
|
expect(result.media_attachments.first()?.id).toEqual('def');
|
||||||
|
expect(result.media_attachments.first()?.preview_url).toEqual('https://gleasonator.com/favicon.png');
|
||||||
|
});
|
||||||
|
});
|
|
@ -11,7 +11,7 @@ import type { Attachment, Card, Emoji } from 'soapbox/types/entities';
|
||||||
|
|
||||||
export const ChatMessageRecord = ImmutableRecord({
|
export const ChatMessageRecord = ImmutableRecord({
|
||||||
account_id: '',
|
account_id: '',
|
||||||
attachment: null as Attachment | null,
|
media_attachments: ImmutableList<Attachment>(),
|
||||||
card: null as Card | null,
|
card: null as Card | null,
|
||||||
chat_id: '',
|
chat_id: '',
|
||||||
content: '',
|
content: '',
|
||||||
|
@ -24,12 +24,15 @@ export const ChatMessageRecord = ImmutableRecord({
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeMedia = (status: ImmutableMap<string, any>) => {
|
const normalizeMedia = (status: ImmutableMap<string, any>) => {
|
||||||
|
const attachments = status.get('media_attachments');
|
||||||
const attachment = status.get('attachment');
|
const attachment = status.get('attachment');
|
||||||
|
|
||||||
if (attachment) {
|
if (attachments) {
|
||||||
return status.set('attachment', normalizeAttachment(attachment));
|
return status.set('media_attachments', ImmutableList(attachments.map(normalizeAttachment)));
|
||||||
|
} else if (attachment) {
|
||||||
|
return status.set('media_attachments', ImmutableList([normalizeAttachment(attachment)]));
|
||||||
} else {
|
} else {
|
||||||
return status;
|
return status.set('media_attachments', ImmutableList());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -235,7 +235,7 @@ const useChatActions = (chatId: string) => {
|
||||||
const createChatMessage = useMutation(
|
const createChatMessage = useMutation(
|
||||||
(
|
(
|
||||||
{ chatId, content, mediaId }: { chatId: string, content: string, mediaId?: string },
|
{ chatId, content, mediaId }: { chatId: string, content: string, mediaId?: string },
|
||||||
) => api.post<IChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, { content, media_id: mediaId }),
|
) => api.post<IChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, { content, media_id: mediaId, media_ids: [mediaId] }),
|
||||||
{
|
{
|
||||||
retry: false,
|
retry: false,
|
||||||
onMutate: async (variables) => {
|
onMutate: async (variables) => {
|
||||||
|
|
Loading…
Reference in a new issue