diff --git a/.storybook/main.ts b/.storybook/main.ts index 00f1703c0..bb4c1d232 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -11,6 +11,7 @@ const config: StorybookConfig = { '@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions', + 'storybook-react-intl', { name: '@storybook/addon-postcss', options: { diff --git a/.storybook/preview.ts b/.storybook/preview.ts deleted file mode 100644 index c876c5abf..000000000 --- a/.storybook/preview.ts +++ /dev/null @@ -1,12 +0,0 @@ -import '../app/styles/tailwind.css'; -import '../stories/theme.css'; - -export const parameters = { - actions: { argTypesRegex: "^on[A-Z].*" }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, -} \ No newline at end of file diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 000000000..df2195f0c --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,22 @@ +import '../app/styles/tailwind.css'; +import '../stories/theme.css'; + +import { addDecorator, Story } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import React from 'react'; + +const withProvider = (Story: Story) => ( + +); + +addDecorator(withProvider); + +export const parameters = { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +}; diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d0c5977..c74318105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compatibility: improved browser support for older browsers. - Events: allow to repost events in event menu. - Groups: Initial support for groups. +- Profile: Add RSS link to user profiles. +- Reactions: adds support for reacting to chat messages. +- Groups: initial support for groups. +- Profile: add RSS link to user profiles. +- Posts: fix posts filtering. ### Changed - Chats: improved display of media attachments. - ServiceWorker: switch to a network-first strategy. The "An update is available!" prompt goes away. - Posts: increased font size of focused status in threads. - Posts: let "mute conversation" be clicked from any feed, not just noficiations. +- Posts: display all emoji reactions. +- Reactions: improved UI of reactions on statuses. ### Fixed - Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load. diff --git a/app/soapbox/actions/soapbox.ts b/app/soapbox/actions/soapbox.ts index 725ff1ae3..790997199 100644 --- a/app/soapbox/actions/soapbox.ts +++ b/app/soapbox/actions/soapbox.ts @@ -32,8 +32,8 @@ const getSoapboxConfig = createSelector([ } // If RGI reacts aren't supported, strip VS16s - // // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 - if (!features.emojiReactsRGI) { + // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 + if (features.emojiReactsNonRGI) { soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s)); } }); diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index d0ceb6595..a02b3c1d7 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -2,7 +2,7 @@ import { getSettings } from 'soapbox/actions/settings'; import messages from 'soapbox/locales/messages'; import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats'; import { queryClient } from 'soapbox/queries/client'; -import { getUnreadChatsCount, updateChatListItem } from 'soapbox/utils/chats'; +import { getUnreadChatsCount, updateChatListItem, updateChatMessage } from 'soapbox/utils/chats'; import { removePageItem } from 'soapbox/utils/queries'; import { play, soundCache } from 'soapbox/utils/sounds'; @@ -170,6 +170,9 @@ const connectTimelineStream = ( } }); break; + case 'chat_message.reaction': // TruthSocial + updateChatMessage(JSON.parse(data.payload)); + break; case 'pleroma:follow_relationships_update': dispatch(updateFollowRelationships(JSON.parse(data.payload))); break; diff --git a/app/soapbox/components/__tests__/emoji-selector.test.tsx b/app/soapbox/components/__tests__/emoji-selector.test.tsx deleted file mode 100644 index b382a4b94..000000000 --- a/app/soapbox/components/__tests__/emoji-selector.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; - -import { render, screen } from '../../jest/test-helpers'; -import EmojiSelector from '../emoji-selector'; - -describe('', () => { - it('renders correctly', () => { - const children = ; - // @ts-ignore - children.__proto__.addEventListener = () => {}; - - render(children); - - expect(screen.queryAllByRole('button')).toHaveLength(6); - }); -}); diff --git a/app/soapbox/components/emoji-selector.tsx b/app/soapbox/components/emoji-selector.tsx deleted file mode 100644 index 5ed0ccf19..000000000 --- a/app/soapbox/components/emoji-selector.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { HotKeys } from 'react-hotkeys'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui'; - -import type { List as ImmutableList } from 'immutable'; -import type { RootState } from 'soapbox/store'; - -const mapStateToProps = (state: RootState) => ({ - allowedEmoji: getSoapboxConfig(state).allowedEmoji, -}); - -interface IEmojiSelector { - allowedEmoji: ImmutableList, - onReact: (emoji: string) => void, - onUnfocus: () => void, - visible: boolean, - focused?: boolean, -} - -class EmojiSelector extends ImmutablePureComponent { - - static defaultProps: Partial = { - onReact: () => { }, - onUnfocus: () => { }, - visible: false, - }; - - handlers = { - open: () => { }, - }; - - render() { - const { visible, focused, allowedEmoji, onReact, onUnfocus } = this.props; - - return ( - - - - ); - } - -} - -export default connect(mapStateToProps)(EmojiSelector); diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index a4a5e1a70..30c757625 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -14,8 +14,8 @@ import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions import { initMuteModal } from 'soapbox/actions/mutes'; import { initReport } from 'soapbox/actions/reports'; import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; -import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import StatusActionButton from 'soapbox/components/status-action-button'; +import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper'; import { HStack } from 'soapbox/components/ui'; import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; @@ -629,7 +629,7 @@ const StatusActionBar: React.FC = ({ )} {features.emojiReacts ? ( - + = ({ emoji={meEmojiReact} text={withLabels ? meEmojiTitle : undefined} /> - + ) : ( = ({ statusId, children }): JSX.Element | null => { +const StatusReactionWrapper: React.FC = ({ statusId, children }): JSX.Element | null => { const dispatch = useAppDispatch(); const ownAccount = useOwnAccount(); const status = useAppSelector(state => state.statuses.get(statusId)); @@ -23,24 +21,8 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children const timeout = useRef(); const [visible, setVisible] = useState(false); - // const [focused, setFocused] = useState(false); - // `useRef` won't trigger a re-render, while `useState` does. - // https://popper.js.org/react-popper/v2/ const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: 'top-start', - modifiers: [ - { - name: 'offset', - options: { - offset: [-10, 0], - }, - }, - ], - }); useEffect(() => { return () => { @@ -116,29 +98,6 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children })); }; - const handleUnfocus: React.EventHandler = () => { - setVisible(false); - }; - - const selector = ( -
- -
- ); - return (
{React.cloneElement(children, { @@ -146,9 +105,14 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children ref: setReferenceElement, })} - {selector} +
); }; -export default EmojiButtonWrapper; +export default StatusReactionWrapper; diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index b9989ae4c..3996777fb 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -289,8 +289,10 @@ const Status: React.FC = (props) => { return ( -
- +
+ + +
); diff --git a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx index 84ef1fbc1..32851a4bf 100644 --- a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx +++ b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx @@ -1,15 +1,16 @@ +import { Placement } from '@popperjs/core'; import clsx from 'clsx'; -import React, { useRef } from 'react'; +import React, { useEffect, useState } from 'react'; +import { usePopper } from 'react-popper'; import { Emoji, HStack } from 'soapbox/components/ui'; +import { useSoapboxConfig } from 'soapbox/hooks'; interface IEmojiButton { /** Unicode emoji character. */ emoji: string, /** Event handler when the emoji is clicked. */ - onClick: React.EventHandler, - /** Keyboard event handler. */ - onKeyDown?: React.EventHandler, + onClick(emoji: string): void /** Extra class name on the ); }; interface IEmojiSelector { - /** List of Unicode emoji characters. */ - emojis: Iterable, + onClose?(): void /** Event handler when an emoji is clicked. */ - onReact: (emoji: string) => void, - /** Event handler when selector is escaped. */ - onUnfocus: React.KeyboardEventHandler, + onReact(emoji: string): void + /** Element that triggers the EmojiSelector Popper */ + referenceElement: HTMLElement | null + placement?: Placement /** Whether the selector should be visible. */ - visible?: boolean, - /** Whether the selector should be focused. */ - focused?: boolean, + visible?: boolean } /** Panel with a row of emoji buttons. */ -const EmojiSelector: React.FC = ({ emojis, onReact, onUnfocus, visible = false, focused = false }): JSX.Element => { - const emojiList = Array.from(emojis); - const node = useRef(null); +const EmojiSelector: React.FC = ({ + referenceElement, + onClose, + onReact, + placement = 'top', + visible = false, +}): JSX.Element => { + const soapboxConfig = useSoapboxConfig(); - const handleReact = (emoji: string): React.EventHandler => { - return (e) => { - onReact(emoji); - e.preventDefault(); - e.stopPropagation(); + // `useRef` won't trigger a re-render, while `useState` does. + // https://popper.js.org/react-popper/v2/ + const [popperElement, setPopperElement] = useState(null); + + const handleClickOutside = (event: MouseEvent) => { + if (referenceElement?.contains(event.target as Node) || popperElement?.contains(event.target as Node)) { + return; + } + + if (onClose) { + onClose(); + } + }; + + const { styles, attributes, update } = usePopper(referenceElement, popperElement, { + placement, + modifiers: [ + { + name: 'offset', + options: { + offset: [-10, 0], + }, + }, + ], + }); + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); }; - }; + }, [referenceElement]); - const selectPreviousEmoji = (i: number): void => { - if (!node.current) return; - - if (i !== 0) { - const button: HTMLButtonElement | null = node.current.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`); - button?.focus(); - } else { - const button: HTMLButtonElement | null = node.current.querySelector('.emoji-react-selector__emoji:last-child'); - button?.focus(); + useEffect(() => { + if (visible && update) { + update(); } - }; - - const selectNextEmoji = (i: number) => { - if (!node.current) return; - - if (i !== emojiList.length - 1) { - const button: HTMLButtonElement | null = node.current.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`); - button?.focus(); - } else { - const button: HTMLButtonElement | null = node.current.querySelector('.emoji-react-selector__emoji:first-child'); - button?.focus(); - } - }; - - const handleKeyDown = (i: number): React.KeyboardEventHandler => e => { - switch (e.key) { - case 'Enter': - handleReact(emojiList[i])(e as any); - break; - case 'Tab': - e.preventDefault(); - if (e.shiftKey) selectPreviousEmoji(i); - else selectNextEmoji(i); - break; - case 'Left': - case 'ArrowLeft': - selectPreviousEmoji(i); - break; - case 'Right': - case 'ArrowRight': - selectNextEmoji(i); - break; - case 'Escape': - onUnfocus(e); - break; - } - }; + }, [visible, update]); return ( - - {emojiList.map((emoji, i) => ( - - ))} - + + {Array.from(soapboxConfig.allowedEmoji).map((emoji, i) => ( + + ))} + +
); }; diff --git a/app/soapbox/components/ui/icon-button/icon-button.tsx b/app/soapbox/components/ui/icon-button/icon-button.tsx index 84096f770..df4228570 100644 --- a/app/soapbox/components/ui/icon-button/icon-button.tsx +++ b/app/soapbox/components/ui/icon-button/icon-button.tsx @@ -15,6 +15,8 @@ interface IIconButton extends React.ButtonHTMLAttributes { transparent?: boolean, /** Predefined styles to display for the button. */ theme?: 'seamless' | 'outlined', + /** Override the data-testid */ + 'data-testid'?: string } /** A clickable icon. */ @@ -31,7 +33,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef 'opacity-50': filteredProps.disabled, }, className)} {...filteredProps} - data-testid='icon-button' + data-testid={filteredProps['data-testid'] || 'icon-button'} > diff --git a/app/soapbox/components/ui/progress-bar/progress-bar.tsx b/app/soapbox/components/ui/progress-bar/progress-bar.tsx index c0161ae34..0507045e4 100644 --- a/app/soapbox/components/ui/progress-bar/progress-bar.tsx +++ b/app/soapbox/components/ui/progress-bar/progress-bar.tsx @@ -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 = ({ progress }) => ( -
-
+const ProgressBar: React.FC = ({ progress, size = 'md' }) => ( +
+ + {({ width }) => ( +
+ )} +
); diff --git a/app/soapbox/components/ui/tabs/tabs.tsx b/app/soapbox/components/ui/tabs/tabs.tsx index 0d0d4d9a8..5b8c47cbf 100644 --- a/app/soapbox/components/ui/tabs/tabs.tsx +++ b/app/soapbox/components/ui/tabs/tabs.tsx @@ -46,7 +46,7 @@ const AnimatedTabs: React.FC = ({ children, ...rest }) => { ref={ref} >
) => { const [rows, setRows] = useState(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, diff --git a/app/soapbox/components/upload-progress.tsx b/app/soapbox/components/upload-progress.tsx index 5cbb4e1b3..f434c4673 100644 --- a/app/soapbox/components/upload-progress.tsx +++ b/app/soapbox/components/upload-progress.tsx @@ -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 = ({ progress }) => { -
- - {({ width }) => - (
) - } - -
+ ); diff --git a/app/soapbox/containers/dropdown-menu-container.ts b/app/soapbox/containers/dropdown-menu-container.ts index 936e3c5c2..67d7fd07e 100644 --- a/app/soapbox/containers/dropdown-menu-container.ts +++ b/app/soapbox/containers/dropdown-menu-container.ts @@ -16,7 +16,7 @@ const mapStateToProps = (state: RootState) => ({ openedViaKeyboard: state.dropdown_menu.keyboard, }); -const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial) => ({ +const mapDispatchToProps = (dispatch: Dispatch, { status, items, ...filteredProps }: Partial) => ({ onOpen( id: number, onItemClick: React.EventHandler, @@ -28,10 +28,18 @@ const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial { id: string, - /** @deprecated Unused. */ - contextType?: any, + contextType?: string, /** @deprecated Unused. */ otherAccounts?: any, /** @deprecated Unused. */ @@ -21,10 +20,10 @@ interface IStatusContainer extends Omit { * @deprecated Use the Status component directly. */ const StatusContainer: React.FC = (props) => { - const { id, ...rest } = props; + const { id, contextType, ...rest } = props; const getStatus = useCallback(makeGetStatus(), []); - const status = useAppSelector(state => getStatus(state, { id })); + const status = useAppSelector(state => getStatus(state, { id, contextType })); if (status) { return ; diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index 368dce1b1..61978f16e 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -22,13 +22,14 @@ import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; import MovedNote from 'soapbox/features/account-timeline/components/moved-note'; import ActionButton from 'soapbox/features/ui/components/action-button'; import SubscriptionButton from 'soapbox/features/ui/components/subscription-button'; -import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { normalizeAttachment } from 'soapbox/normalizers'; import { ChatKeys, useChats } from 'soapbox/queries/chats'; import { queryClient } from 'soapbox/queries/client'; import toast from 'soapbox/toast'; import { Account } from 'soapbox/types/entities'; -import { isDefaultHeader, isRemote } from 'soapbox/utils/accounts'; +import { isDefaultHeader, isLocal, isRemote } from 'soapbox/utils/accounts'; +import { MASTODON, parseVersion } from 'soapbox/utils/features'; import type { Menu as MenuType } from 'soapbox/components/dropdown-menu'; @@ -71,6 +72,7 @@ const messages = defineMessages({ userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' }, profileExternal: { id: 'account.profile_external', defaultMessage: 'View profile on {domain}' }, header: { id: 'account.header.alt', defaultMessage: 'Profile header' }, + subscribeFeed: { id: 'account.rss_feed', defaultMessage: 'Subscribe to RSS feed' }, }); interface IHeader { @@ -85,6 +87,8 @@ const Header: React.FC = ({ account }) => { const features = useFeatures(); const ownAccount = useOwnAccount(); + const { software } = useAppSelector((state) => parseVersion(state.instance.version)); + const { getOrCreateChatByAccountId } = useChats(); const createAndNavigateToChat = useMutation((accountId: string) => { @@ -257,6 +261,10 @@ const Header: React.FC = ({ account }) => { } }; + const handleRssFeedClick = () => { + window.open(software === MASTODON ? `${account.url}.rss` : `${account.url}/feed.rss`, '_blank'); + }; + const handleShare = () => { navigator.share({ text: `@${account.acct}`, @@ -269,20 +277,43 @@ const Header: React.FC = ({ account }) => { const makeMenu = () => { const menu: MenuType = []; - if (!account || !ownAccount) { + if (!account) { return []; } + if (features.rssFeeds && isLocal(account)) { + menu.push({ + text: intl.formatMessage(messages.subscribeFeed), + action: handleRssFeedClick, + icon: require('@tabler/icons/rss.svg'), + }); + } + if ('share' in navigator) { menu.push({ text: intl.formatMessage(messages.share, { name: account.username }), action: handleShare, icon: require('@tabler/icons/upload.svg'), }); + } + + if (features.federating && isRemote(account)) { + const domain = account.fqn.split('@')[1]; + + menu.push({ + text: intl.formatMessage(messages.profileExternal, { domain }), + action: () => onProfileExternal(account.url), + icon: require('@tabler/icons/external-link.svg'), + }); + } + + if (!ownAccount) return menu; + + if (menu.length) { menu.push(null); } - if (account.id === ownAccount?.id) { + if (account.id === ownAccount.id) { menu.push({ text: intl.formatMessage(messages.edit_profile), to: '/settings/profile', @@ -435,17 +466,9 @@ const Header: React.FC = ({ account }) => { icon: require('@tabler/icons/ban.svg'), }); } - - if (features.federating) { - menu.push({ - text: intl.formatMessage(messages.profileExternal, { domain }), - action: () => onProfileExternal(account.url), - icon: require('@tabler/icons/external-link.svg'), - }); - } } - if (ownAccount?.staff) { + if (ownAccount.staff) { menu.push(null); menu.push({ @@ -463,7 +486,7 @@ const Header: React.FC = ({ account }) => { if (!account || !ownAccount) return info; - if (ownAccount?.id !== account.id && account.relationship?.followed_by) { + if (ownAccount.id !== account.id && account.relationship?.followed_by) { info.push( = ({ account }) => { title={} />, ); - } else if (ownAccount?.id !== account.id && account.relationship?.blocking) { + } else if (ownAccount.id !== account.id && account.relationship?.blocking) { info.push( = ({ account }) => { ); } - if (ownAccount?.id !== account.id && account.relationship?.muting) { + if (ownAccount.id !== account.id && account.relationship?.muting) { info.push( = ({ account }) => { title={} />, ); - } else if (ownAccount?.id !== account.id && account.relationship?.domain_blocking) { + } else if (ownAccount.id !== account.id && account.relationship?.domain_blocking) { info.push( = ({ account }) => { {renderMessageButton()} {renderShareButton()} - {ownAccount && ( + {menu.length > 0 && ( ', () => { + it('renders properly', () => { + render( + , + ); + + expect(screen.getByRole('img').getAttribute('alt')).toEqual(emojiReaction.name); + expect(screen.getByRole('button')).toHaveTextContent(String(emojiReaction.count)); + }); + + it('triggers the "onAddReaction" function', async () => { + const onAddFn = jest.fn(); + const onRemoveFn = jest.fn(); + const user = userEvent.setup(); + + render( + , + ); + + expect(onAddFn).not.toBeCalled(); + expect(onRemoveFn).not.toBeCalled(); + + await user.click(screen.getByRole('button')); + + // add function triggered + expect(onAddFn).toBeCalled(); + expect(onRemoveFn).not.toBeCalled(); + }); + + it('triggers the "onRemoveReaction" function', async () => { + const onAddFn = jest.fn(); + const onRemoveFn = jest.fn(); + const user = userEvent.setup(); + + render( + , + ); + + expect(onAddFn).not.toBeCalled(); + expect(onRemoveFn).not.toBeCalled(); + + await user.click(screen.getByRole('button')); + + // remove function triggered + expect(onAddFn).not.toBeCalled(); + expect(onRemoveFn).toBeCalled(); + }); +}); \ No newline at end of file diff --git a/app/soapbox/features/chats/components/chat-composer.tsx b/app/soapbox/features/chats/components/chat-composer.tsx index a703d772f..165e7ce9d 100644 --- a/app/soapbox/features/chats/components/chat-composer.tsx +++ b/app/soapbox/features/chats/components/chat-composer.tsx @@ -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 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 onSelectFile, resetFileKey, onPaste, - hasAttachment, + attachments = [], + onDeleteAttachment, + isUploading, + uploadProgress, }, ref) => { const intl = useIntl(); const dispatch = useAppDispatch(); @@ -68,6 +77,7 @@ const ChatComposer = React.forwardRef const [suggestions, setSuggestions] = useState(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 )} - + autoGrow maxRows={5} disabled={disabled} + attachments={attachments} + onDeleteAttachment={onDeleteAttachment} + isUploading={isUploading} + uploadProgress={uploadProgress} /> {isSuggestionsAvailable ? ( diff --git a/app/soapbox/features/chats/components/chat-message-list.tsx b/app/soapbox/features/chats/components/chat-message-list.tsx index 1a2b1bd65..326ab9b71 100644 --- a/app/soapbox/features/chats/components/chat-message-list.tsx +++ b/app/soapbox/features/chats/components/chat-message-list.tsx @@ -1,33 +1,17 @@ -import { useMutation } from '@tanstack/react-query'; -import clsx from 'clsx'; -import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; -import escape from 'lodash/escape'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso'; -import { openModal } from 'soapbox/actions/modals'; -import { initReport } from 'soapbox/actions/reports'; -import { Avatar, Button, Divider, HStack, Icon, IconButton, Spinner, Stack, Text } from 'soapbox/components/ui'; -import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; -import emojify from 'soapbox/features/emoji/emoji'; +import { Avatar, Button, Divider, Spinner, Stack, Text } from 'soapbox/components/ui'; import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message'; -import Bundle from 'soapbox/features/ui/components/bundle'; -import { MediaGallery } from 'soapbox/features/ui/util/async-components'; -import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks'; -import { normalizeAccount } from 'soapbox/normalizers'; -import { ChatKeys, IChat, IChatMessage, useChatActions, useChatMessages } from 'soapbox/queries/chats'; -import { queryClient } from 'soapbox/queries/client'; -import { stripHTML } from 'soapbox/utils/html'; -import { onlyEmoji } from 'soapbox/utils/rich-content'; +import { useAppSelector, useOwnAccount } from 'soapbox/hooks'; +import { IChat, useChatActions, useChatMessages } from 'soapbox/queries/chats'; +import ChatMessage from './chat-message'; import ChatMessageListIntro from './chat-message-list-intro'; -import type { Menu } from 'soapbox/components/dropdown-menu'; import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities'; -const BIG_EMOJI_LIMIT = 3; - const messages = defineMessages({ today: { id: 'chats.dividers.today', defaultMessage: 'Today' }, more: { id: 'chats.actions.more', defaultMessage: 'More' }, @@ -43,7 +27,7 @@ const messages = defineMessages({ type TimeFormat = 'today' | 'date'; -const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null => { +const timeChange = (prev: ChatMessageEntity, curr: ChatMessageEntity): TimeFormat | null => { const prevDate = new Date(prev.created_at).getDate(); const currDate = new Date(curr.created_at).getDate(); const nowDate = new Date().getDate(); @@ -55,10 +39,6 @@ const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null = return null; }; -const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap, emoji: ImmutableMap) => { - return map.set(`:${emoji.get('shortcode')}:`, emoji); -}, ImmutableMap()); - const START_INDEX = 10000; const List: Components['List'] = React.forwardRef((props, ref) => { @@ -89,19 +69,15 @@ interface IChatMessageList { /** Scrollable list of chat messages. */ const ChatMessageList: React.FC = ({ chat }) => { const intl = useIntl(); - const dispatch = useAppDispatch(); const account = useOwnAccount(); - const features = useFeatures(); - const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date; const myLastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === account?.id)?.date; - const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null; const myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null; const node = useRef(null); const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20); - const { deleteChatMessage, markChatAsRead } = useChatActions(chat.id); + const { markChatAsRead } = useChatActions(chat.id); const { data: chatMessages, fetchNextPage, @@ -115,24 +91,24 @@ const ChatMessageList: React.FC = ({ chat }) => { const formattedChatMessages = chatMessages || []; - const me = useAppSelector((state) => state.me); const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by'])); - const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), { - onSettled: () => { - queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id)); - }, - }); - const lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null; - const cachedChatMessages = useMemo(() => { + useEffect(() => { if (!chatMessages) { - return []; + return; } const nextFirstItemIndex = START_INDEX - chatMessages.length; setFirstItemIndex(nextFirstItemIndex); + }, [lastChatMessage]); + + const buildCachedMessages = () => { + if (!chatMessages) { + return []; + } + return chatMessages.reduce((acc: any, curr: any, idx: number) => { const lastMessage = formattedChatMessages[idx - 1]; @@ -156,32 +132,19 @@ const ChatMessageList: React.FC = ({ chat }) => { acc.push(curr); return acc; }, []); - - }, [chatMessages?.length, lastChatMessage]); - - const initialTopMostItemIndex = process.env.NODE_ENV === 'test' ? 0 : cachedChatMessages.length - 1; - - const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => { - return intl.formatDate(new Date(chatMessage.created_at), { - hour12: false, - year: 'numeric', - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); }; + const cachedChatMessages = buildCachedMessages(); - const setBubbleRef = (c: HTMLDivElement) => { - if (!c) return; - const links = c.querySelectorAll('a[rel="ugc"]'); + const initialScrollPositionProps = useMemo(() => { + if (process.env.NODE_ENV === 'test') { + return {}; + } - links.forEach(link => { - link.classList.add('chat-link'); - link.setAttribute('rel', 'ugc nofollow noopener'); - link.setAttribute('target', '_blank'); - }); - }; + return { + initialTopMostItemIndex: cachedChatMessages.length - 1, + firstItemIndex: Math.max(0, firstItemIndex), + }; + }, [cachedChatMessages.length, firstItemIndex]); const handleStartReached = useCallback(() => { if (hasNextPage && !isFetching) { @@ -190,213 +153,8 @@ const ChatMessageList: React.FC = ({ chat }) => { return false; }, [firstItemIndex, hasNextPage, isFetching]); - const onOpenMedia = (media: any, index: number) => { - dispatch(openModal('MEDIA', { media, index })); - }; - - const maybeRenderMedia = (chatMessage: ChatMessageEntity) => { - const { attachment } = chatMessage; - if (!attachment) return null; - return ( - - {(Component: any) => ( - - )} - - ); - }; - - const parsePendingContent = (content: string) => { - return escape(content).replace(/(?:\r\n|\r|\n)/g, '
'); - }; - - const parseContent = (chatMessage: ChatMessageEntity) => { - const content = chatMessage.content || ''; - const pending = chatMessage.pending; - const deleting = chatMessage.deleting; - const formatted = (pending && !deleting) ? parsePendingContent(content) : content; - const emojiMap = makeEmojiMap(chatMessage); - return emojify(formatted, emojiMap.toJS()); - }; - const renderDivider = (key: React.Key, text: string) => ; - const handleCopyText = (chatMessage: ChatMessageEntity) => { - if (navigator.clipboard) { - const text = stripHTML(chatMessage.content); - navigator.clipboard.writeText(text); - } - }; - - const renderMessage = (chatMessage: ChatMessageEntity) => { - const content = parseContent(chatMessage); - const hiddenEl = document.createElement('div'); - hiddenEl.innerHTML = content; - const isOnlyEmoji = onlyEmoji(hiddenEl, BIG_EMOJI_LIMIT, false); - - const isMyMessage = chatMessage.account_id === me; - // did this occur before this time? - const isRead = isMyMessage - && lastReadMessageTimestamp - && lastReadMessageTimestamp >= new Date(chatMessage.created_at); - - const menu: Menu = []; - - if (navigator.clipboard && chatMessage.content) { - menu.push({ - text: intl.formatMessage(messages.copy), - action: () => handleCopyText(chatMessage), - icon: require('@tabler/icons/copy.svg'), - }); - } - - if (isMyMessage) { - menu.push({ - text: intl.formatMessage(messages.delete), - action: () => handleDeleteMessage.mutate(chatMessage.id), - icon: require('@tabler/icons/trash.svg'), - destructive: true, - }); - } else { - if (features.reportChats) { - menu.push({ - text: intl.formatMessage(messages.report), - action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)), - icon: require('@tabler/icons/flag.svg'), - }); - } - menu.push({ - text: intl.formatMessage(messages.deleteForMe), - action: () => handleDeleteMessage.mutate(chatMessage.id), - icon: require('@tabler/icons/trash.svg'), - destructive: true, - }); - } - - return ( -
- - - {menu.length > 0 && ( -
- - - -
- )} - - - {maybeRenderMedia(chatMessage)} - - {content && ( - -
- -
-
- )} -
-
- - -
- - - {intl.formatTime(chatMessage.created_at)} - - - {(isMyMessage && features.chatsReadReceipts) ? ( - <> - {isRead ? ( - - - - ) : ( - - - - )} - - ) : null} - -
-
-
-
- ); - }; - useEffect(() => { const lastMessage = formattedChatMessages[formattedChatMessages.length - 1]; if (!lastMessage) { @@ -477,8 +235,7 @@ const ChatMessageList: React.FC = ({ chat }) => { = ({ chat }) => { if (chatMessage.type === 'divider') { return renderDivider(index, chatMessage.text); } else { - return ( -
- {renderMessage(chatMessage)} -
- ); + return ; } }} components={{ diff --git a/app/soapbox/features/chats/components/chat-message-reaction-wrapper/chat-message-reaction-wrapper.tsx b/app/soapbox/features/chats/components/chat-message-reaction-wrapper/chat-message-reaction-wrapper.tsx new file mode 100644 index 000000000..f6c8b7f82 --- /dev/null +++ b/app/soapbox/features/chats/components/chat-message-reaction-wrapper/chat-message-reaction-wrapper.tsx @@ -0,0 +1,49 @@ +import React, { useState, useEffect } from 'react'; + +import EmojiSelector from '../../../../components/ui/emoji-selector/emoji-selector'; + +interface IChatMessageReactionWrapper { + onOpen(isOpen: boolean): void + onSelect(emoji: string): void + children: JSX.Element +} + +/** + * Emoji Reaction Selector + */ +function ChatMessageReactionWrapper(props: IChatMessageReactionWrapper) { + const { onOpen, onSelect, children } = props; + + const [isOpen, setIsOpen] = useState(false); + + const [referenceElement, setReferenceElement] = useState(null); + + const handleSelect = (emoji: string) => { + onSelect(emoji); + setIsOpen(false); + }; + + const onToggleVisibility = () => setIsOpen((prevValue) => !prevValue); + + useEffect(() => { + onOpen(isOpen); + }, [isOpen]); + + return ( + + {React.cloneElement(children, { + ref: setReferenceElement, + onClick: onToggleVisibility, + })} + + setIsOpen(false)} + /> + + ); +} + +export default ChatMessageReactionWrapper; \ No newline at end of file diff --git a/app/soapbox/features/chats/components/chat-message-reaction.tsx b/app/soapbox/features/chats/components/chat-message-reaction.tsx new file mode 100644 index 000000000..76c9fa0f8 --- /dev/null +++ b/app/soapbox/features/chats/components/chat-message-reaction.tsx @@ -0,0 +1,45 @@ +import clsx from 'clsx'; +import React from 'react'; + +import { Text } from 'soapbox/components/ui'; +import emojify from 'soapbox/features/emoji/emoji'; +import { EmojiReaction } from 'soapbox/types/entities'; + +interface IChatMessageReaction { + emojiReaction: EmojiReaction + onRemoveReaction(emoji: string): void + onAddReaction(emoji: string): void +} + +const ChatMessageReaction = (props: IChatMessageReaction) => { + const { emojiReaction, onAddReaction, onRemoveReaction } = props; + + const isAlreadyReacted = emojiReaction.me; + + const handleClick = () => { + if (isAlreadyReacted) { + onRemoveReaction(emojiReaction.name); + } else { + onAddReaction(emojiReaction.name); + } + }; + + return ( + + ); +}; + +export default ChatMessageReaction; \ No newline at end of file diff --git a/app/soapbox/features/chats/components/chat-message.tsx b/app/soapbox/features/chats/components/chat-message.tsx new file mode 100644 index 000000000..953a9a1b8 --- /dev/null +++ b/app/soapbox/features/chats/components/chat-message.tsx @@ -0,0 +1,371 @@ +import { useMutation } from '@tanstack/react-query'; +import clsx from 'clsx'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import { escape } from 'lodash'; +import React, { useMemo, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { openModal } from 'soapbox/actions/modals'; +import { initReport } from 'soapbox/actions/reports'; +import { HStack, Icon, IconButton, Stack, Text } from 'soapbox/components/ui'; +import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; +import emojify from 'soapbox/features/emoji/emoji'; +import Bundle from 'soapbox/features/ui/components/bundle'; +import { MediaGallery } from 'soapbox/features/ui/util/async-components'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; +import { normalizeAccount } from 'soapbox/normalizers'; +import { ChatKeys, IChat, useChatActions } from 'soapbox/queries/chats'; +import { queryClient } from 'soapbox/queries/client'; +import { stripHTML } from 'soapbox/utils/html'; +import { onlyEmoji } from 'soapbox/utils/rich-content'; + +import ChatMessageReaction from './chat-message-reaction'; +import ChatMessageReactionWrapper from './chat-message-reaction-wrapper/chat-message-reaction-wrapper'; + +import type { Menu as IMenu } from 'soapbox/components/dropdown-menu'; +import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities'; + +const messages = defineMessages({ + copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' }, + delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' }, + deleteForMe: { id: 'chats.actions.deleteForMe', defaultMessage: 'Delete for me' }, + more: { id: 'chats.actions.more', defaultMessage: 'More' }, + report: { id: 'chats.actions.report', defaultMessage: 'Report' }, +}); + +const BIG_EMOJI_LIMIT = 3; + +const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap, emoji: ImmutableMap) => { + return map.set(`:${emoji.get('shortcode')}:`, emoji); +}, ImmutableMap()); + +const parsePendingContent = (content: string) => { + return escape(content).replace(/(?:\r\n|\r|\n)/g, '
'); +}; + +const parseContent = (chatMessage: ChatMessageEntity) => { + const content = chatMessage.content || ''; + const pending = chatMessage.pending; + const deleting = chatMessage.deleting; + const formatted = (pending && !deleting) ? parsePendingContent(content) : content; + const emojiMap = makeEmojiMap(chatMessage); + return emojify(formatted, emojiMap.toJS()); +}; + +interface IChatMessage { + chat: IChat + chatMessage: ChatMessageEntity +} + +const ChatMessage = (props: IChatMessage) => { + const { chat, chatMessage } = props; + + const dispatch = useAppDispatch(); + const features = useFeatures(); + const intl = useIntl(); + + const me = useAppSelector((state) => state.me); + const { createReaction, deleteChatMessage, deleteReaction } = useChatActions(chat.id); + + const [isReactionSelectorOpen, setIsReactionSelectorOpen] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), { + onSettled: () => { + queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id)); + }, + }); + + const content = parseContent(chatMessage); + const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date; + const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null; + const isMyMessage = chatMessage.account_id === me; + + // did this occur before this time? + const isRead = isMyMessage + && lastReadMessageTimestamp + && lastReadMessageTimestamp >= new Date(chatMessage.created_at); + + const isOnlyEmoji = useMemo(() => { + const hiddenEl = document.createElement('div'); + hiddenEl.innerHTML = content; + return onlyEmoji(hiddenEl, BIG_EMOJI_LIMIT, false); + }, []); + + const emojiReactionRows = useMemo(() => { + if (!chatMessage.emoji_reactions) { + return []; + } + + return chatMessage.emoji_reactions.reduce((rows: any, key: any, index) => { + return (index % 4 === 0 ? rows.push([key]) + : rows[rows.length - 1].push(key)) && rows; + }, []); + }, [chatMessage.emoji_reactions]); + + const onOpenMedia = (media: any, index: number) => { + dispatch(openModal('MEDIA', { media, index })); + }; + + const maybeRenderMedia = (chatMessage: ChatMessageEntity) => { + if (!chatMessage.media_attachments.size) return null; + + return ( + + {(Component: any) => ( + + )} + + ); + }; + + const handleCopyText = (chatMessage: ChatMessageEntity) => { + if (navigator.clipboard) { + const text = stripHTML(chatMessage.content); + navigator.clipboard.writeText(text); + } + }; + const setBubbleRef = (c: HTMLDivElement) => { + if (!c) return; + const links = c.querySelectorAll('a[rel="ugc"]'); + + links.forEach(link => { + link.classList.add('chat-link'); + link.setAttribute('rel', 'ugc nofollow noopener'); + link.setAttribute('target', '_blank'); + }); + }; + + const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => { + return intl.formatDate(new Date(chatMessage.created_at), { + hour12: false, + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const menu = useMemo(() => { + const menu: IMenu = []; + + if (navigator.clipboard && chatMessage.content) { + menu.push({ + text: intl.formatMessage(messages.copy), + action: () => handleCopyText(chatMessage), + icon: require('@tabler/icons/copy.svg'), + }); + } + + if (isMyMessage) { + menu.push({ + text: intl.formatMessage(messages.delete), + action: () => handleDeleteMessage.mutate(chatMessage.id), + icon: require('@tabler/icons/trash.svg'), + destructive: true, + }); + } else { + if (features.reportChats) { + menu.push({ + text: intl.formatMessage(messages.report), + action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)), + icon: require('@tabler/icons/flag.svg'), + }); + } + menu.push({ + text: intl.formatMessage(messages.deleteForMe), + action: () => handleDeleteMessage.mutate(chatMessage.id), + icon: require('@tabler/icons/trash.svg'), + destructive: true, + }); + } + + return menu; + }, [chatMessage, chat]); + + return ( +
+
+ + + {menu.length > 0 && ( + setIsMenuOpen(true)} + onClose={() => setIsMenuOpen(false)} + > + + + )} + + {features.chatEmojiReactions ? ( + createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })} + > + + + ) : null} + + + {maybeRenderMedia(chatMessage)} + + {content && ( + +
+ +
+
+ )} +
+
+ + {(features.chatEmojiReactions && chatMessage.emoji_reactions) ? ( +
+ {emojiReactionRows?.map((emojiReactionRow: any, idx: number) => ( + + {emojiReactionRow.map((emojiReaction: any, idx: number) => ( + createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })} + onRemoveReaction={(emoji) => deleteReaction.mutate({ emoji, messageId: chatMessage.id })} + /> + ))} + + ))} +
+ ) : null} + + +
+ + + {intl.formatTime(chatMessage.created_at)} + + + {(isMyMessage && features.chatsReadReceipts) ? ( + <> + {isRead ? ( + + + + ) : ( + + + + )} + + ) : null} + +
+
+
+
+
+ ); +}; + +export default ChatMessage; \ No newline at end of file diff --git a/app/soapbox/features/chats/components/chat-pending-upload.tsx b/app/soapbox/features/chats/components/chat-pending-upload.tsx new file mode 100644 index 000000000..373d548ce --- /dev/null +++ b/app/soapbox/features/chats/components/chat-pending-upload.tsx @@ -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 = ({ progress }) => { + return ( +
+ +
+ ); +}; + +export default ChatPendingUpload; \ No newline at end of file diff --git a/app/soapbox/features/chats/components/chat-textarea.tsx b/app/soapbox/features/chats/components/chat-textarea.tsx new file mode 100644 index 000000000..22b0877e9 --- /dev/null +++ b/app/soapbox/features/chats/components/chat-textarea.tsx @@ -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 { + attachments?: Attachment[] + onDeleteAttachment?: () => void + isUploading?: boolean + uploadProgress?: number +} + +/** Custom textarea for chats. */ +const ChatTextarea: React.FC = ({ + attachments, + onDeleteAttachment, + isUploading = false, + uploadProgress = 0, + ...rest +}) => { + return ( +
+ {(!!attachments?.length || isUploading) && ( +
+ {isUploading && ( + + )} + + {attachments?.map(attachment => ( + + ))} +
+ )} + +