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 { spring } from 'react-motion';
import Motion from 'soapbox/features/ui/util/optional-motion';
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. */
const ProgressBar: React.FC<IProgressBar> = ({ progress }) => (
<div className='h-2.5 w-full overflow-hidden rounded-full bg-gray-300 dark:bg-primary-800'>
<div className='h-full bg-secondary-500' style={{ width: `${Math.floor(progress * 100)}%` }} />
const ProgressBar: React.FC<IProgressBar> = ({ progress, size = 'md' }) => (
<div
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>
);

Wyświetl plik

@ -26,6 +26,8 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
hasError?: boolean,
/** Whether or not you can resize the teztarea */
isResizeable?: boolean,
/** Textarea theme. */
theme?: 'default' | 'transparent',
}
/** Textarea with custom styles. */
@ -37,6 +39,7 @@ const Textarea = React.forwardRef(({
autoGrow = false,
maxRows = 10,
minRows = 1,
theme = 'default',
...props
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
@ -72,9 +75,10 @@ const Textarea = React.forwardRef(({
ref={ref}
rows={rows}
onChange={handleChange}
className={clsx({
'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':
true,
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 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':
theme === 'default',
'bg-transparent border-0 focus:border-0 focus:ring-0': theme === 'transparent',
'font-mono': isCodeEditor,
'text-red-600 border-red-600': hasError,
'resize-none': !isResizeable,

Wyświetl plik

@ -1,13 +1,11 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import Motion from 'soapbox/features/ui/util/optional-motion';
import { HStack, Icon, ProgressBar, Stack, Text } from 'soapbox/components/ui';
interface IUploadProgress {
/** Number between 0 and 1 to represent the percentage complete. */
progress: number,
/** Number between 0 and 100 to represent the percentage complete. */
progress: number
}
/** Displays a progress bar for uploading files. */
@ -24,16 +22,7 @@ const UploadProgress: React.FC<IUploadProgress> = ({ progress }) => {
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />
</Text>
<div className='relative h-1.5 w-full rounded-lg bg-gray-200'>
<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>
<ProgressBar progress={progress / 100} size='sm' />
</Stack>
</HStack>
);

Wyświetl plik

@ -3,13 +3,16 @@ import { defineMessages, IntlShape, useIntl } from 'react-intl';
import { unblockAccount } from 'soapbox/actions/accounts';
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 UploadButton from 'soapbox/features/compose/components/upload-button';
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import ChatTextarea from './chat-textarea';
const messages = defineMessages({
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
send: { id: 'chat.actions.send', defaultMessage: 'Send' },
@ -39,7 +42,10 @@ interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaEl
errorMessage: string | undefined
onSelectFile: (files: FileList, intl: IntlShape) => void
resetFileKey: number | null
hasAttachment?: boolean
attachments?: Attachment[]
onDeleteAttachment?: () => void
isUploading?: boolean
uploadProgress?: number
}
/** Textarea input for chats. */
@ -53,7 +59,10 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
onSelectFile,
resetFileKey,
onPaste,
hasAttachment,
attachments = [],
onDeleteAttachment,
isUploading,
uploadProgress,
}, ref) => {
const intl = useIntl();
const dispatch = useAppDispatch();
@ -68,6 +77,7 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
const isSuggestionsAvailable = suggestions.list.length > 0;
const hasAttachment = attachments.length > 0;
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
const isSubmitDisabled = disabled || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
@ -167,12 +177,9 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
)}
<Stack grow>
<Combobox
aria-labelledby='demo'
onSelect={onSelectComboboxOption}
>
<Combobox onSelect={onSelectComboboxOption}>
<ComboboxInput
as={Textarea}
as={ChatTextarea}
autoFocus
ref={ref}
placeholder={intl.formatMessage(messages.placeholder)}
@ -184,6 +191,10 @@ const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>
autoGrow
maxRows={5}
disabled={disabled}
attachments={attachments}
onDeleteAttachment={onDeleteAttachment}
isUploading={isUploading}
uploadProgress={uploadProgress}
/>
{isSuggestionsAvailable ? (
<ComboboxPopover>

Wyświetl plik

@ -195,13 +195,12 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
};
const maybeRenderMedia = (chatMessage: ChatMessageEntity) => {
const { attachment } = chatMessage;
if (!attachment) return null;
if (!chatMessage.media_attachments.size) return null;
return (
<Bundle fetchComponent={MediaGallery}>
{(Component: any) => (
<Component
media={ImmutableList([attachment])}
media={chatMessage.media_attachments}
onOpenMedia={onOpenMedia}
visible
/>
@ -316,7 +315,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
space={0.5}
className={clsx({
'max-w-[85%]': true,
'flex-1': chatMessage.attachment,
'flex-1': !!chatMessage.media_attachments.size,
'order-2': isMyMessage,
'order-1': !isMyMessage,
})}
@ -331,8 +330,8 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
className={
clsx({
'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-tl-sm': chatMessage.attachment && !isMyMessage,
'rounded-tr-sm': (!!chatMessage.media_attachments.size) && isMyMessage,
'rounded-tl-sm': (!!chatMessage.media_attachments.size) && !isMyMessage,
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
'[&_.mention]:text-white dark:[&_.mention]: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 { Stack } from 'soapbox/components/ui';
import Upload from 'soapbox/components/upload';
import UploadProgress from 'soapbox/components/upload-progress';
import { useAppDispatch } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers';
import { IChat, useChatActions } from 'soapbox/queries/chats';
@ -164,22 +162,6 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
<ChatMessageList chat={chat} />
</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
ref={inputRef}
onKeyDown={handleKeyDown}
@ -190,7 +172,10 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
onSelectFile={handleFiles}
resetFileKey={resetFileKey}
onPaste={handlePaste}
hasAttachment={!!attachment}
attachments={attachment ? [attachment] : []}
onDeleteAttachment={handleRemoveFile}
isUploading={isUploading}
uploadProgress={uploadProgress}
/>
</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({
account_id: '',
attachment: null as Attachment | null,
media_attachments: ImmutableList<Attachment>(),
card: null as Card | null,
chat_id: '',
content: '',
@ -24,12 +24,15 @@ export const ChatMessageRecord = ImmutableRecord({
});
const normalizeMedia = (status: ImmutableMap<string, any>) => {
const attachments = status.get('media_attachments');
const attachment = status.get('attachment');
if (attachment) {
return status.set('attachment', normalizeAttachment(attachment));
if (attachments) {
return status.set('media_attachments', ImmutableList(attachments.map(normalizeAttachment)));
} else if (attachment) {
return status.set('media_attachments', ImmutableList([normalizeAttachment(attachment)]));
} else {
return status;
return status.set('media_attachments', ImmutableList());
}
};

Wyświetl plik

@ -235,7 +235,7 @@ const useChatActions = (chatId: string) => {
const createChatMessage = useMutation(
(
{ 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,
onMutate: async (variables) => {