Merge branch 'chat-composer' into 'develop'

Chats: move chat attachments into composer

See merge request soapbox-pub/soapbox!2229
environments/review-develop-3zknud/deployments/2542
Alex Gleason 2023-02-06 21:05:42 +00:00
commit d44be7fbf8
13 zmienionych plików z 290 dodań i 60 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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) => {