From e255bfac3d02232b0c7eb9c5d3825055fcfe9620 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Wed, 8 Feb 2023 12:58:01 -0500 Subject: [PATCH 1/5] Improve Emoji Reactions and add support for Chat Reactions --- app/soapbox/actions/streaming.ts | 5 +- .../__tests__/emoji-selector.test.tsx | 16 - app/soapbox/components/emoji-selector.tsx | 142 ------- app/soapbox/components/status-action-bar.tsx | 6 +- ...rapper.tsx => status-reaction-wrapper.tsx} | 53 +-- .../ui/emoji-selector/emoji-selector.tsx | 114 ++++-- .../components/ui/icon-button/icon-button.tsx | 4 +- .../containers/dropdown-menu-container.ts | 10 +- .../__tests__/chat-message-list.test.tsx | 21 +- .../__tests__/chat-message-reaction.test.tsx | 78 ++++ .../chats/components/chat-message-list.tsx | 300 ++------------ .../chat-message-reaction-wrapper.tsx | 49 +++ .../components/chat-message-reaction.tsx | 45 +++ .../chats/components/chat-message.tsx | 371 ++++++++++++++++++ app/soapbox/normalizers/chat-message.ts | 17 +- app/soapbox/normalizers/emoji-reaction.ts | 14 + app/soapbox/normalizers/index.ts | 1 + app/soapbox/queries/__tests__/chats.test.ts | 63 ++- app/soapbox/queries/chats.ts | 67 +++- app/soapbox/types/entities.ts | 3 + app/soapbox/utils/__tests__/chats.test.ts | 73 ++++ app/soapbox/utils/chats.ts | 9 +- app/soapbox/utils/features.ts | 8 +- 23 files changed, 926 insertions(+), 543 deletions(-) delete mode 100644 app/soapbox/components/__tests__/emoji-selector.test.tsx delete mode 100644 app/soapbox/components/emoji-selector.tsx rename app/soapbox/components/{emoji-button-wrapper.tsx => status-reaction-wrapper.tsx} (69%) create mode 100644 app/soapbox/features/chats/components/__tests__/chat-message-reaction.test.tsx create mode 100644 app/soapbox/features/chats/components/chat-message-reaction-wrapper/chat-message-reaction-wrapper.tsx create mode 100644 app/soapbox/features/chats/components/chat-message-reaction.tsx create mode 100644 app/soapbox/features/chats/components/chat-message.tsx create mode 100644 app/soapbox/normalizers/emoji-reaction.ts create mode 100644 app/soapbox/utils/__tests__/chats.test.ts 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 2c56cd227..000000000 --- a/app/soapbox/components/emoji-selector.tsx +++ /dev/null @@ -1,142 +0,0 @@ -// import clsx from 'clsx'; -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, - }; - - node?: HTMLDivElement = undefined; - - handleBlur: React.FocusEventHandler = e => { - const { focused, onUnfocus } = this.props; - - if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) { - onUnfocus(); - } - }; - - _selectPreviousEmoji = (i: number): void => { - if (!this.node) return; - - if (i !== 0) { - const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`); - button?.focus(); - } else { - const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:last-child'); - button?.focus(); - } - }; - - _selectNextEmoji = (i: number) => { - if (!this.node) return; - - if (i !== this.props.allowedEmoji.size - 1) { - const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`); - button?.focus(); - } else { - const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:first-child'); - button?.focus(); - } - }; - - handleKeyDown = (i: number): React.KeyboardEventHandler => e => { - const { onUnfocus } = this.props; - - switch (e.key) { - case 'Tab': - e.preventDefault(); - if (e.shiftKey) this._selectPreviousEmoji(i); - else this._selectNextEmoji(i); - break; - case 'Left': - case 'ArrowLeft': - this._selectPreviousEmoji(i); - break; - case 'Right': - case 'ArrowRight': - this._selectNextEmoji(i); - break; - case 'Escape': - onUnfocus(); - break; - } - }; - - handleReact = (emoji: string) => (): void => { - const { onReact, focused, onUnfocus } = this.props; - - onReact(emoji); - - if (focused) { - onUnfocus(); - } - }; - - handlers = { - open: () => { }, - }; - - setRef = (c: HTMLDivElement): void => { - this.node = c; - }; - - render() { - const { visible, focused, allowedEmoji, onReact } = this.props; - - return ( - - {/*
- {allowedEmoji.map((emoji, i) => ( - - ))} -
*/} - -
- ); - } - -} - -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,28 +98,6 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children })); }; - // const handleUnfocus: React.EventHandler = () => { - // setFocused(false); - // }; - - const selector = ( -
- -
- ); - return (
{React.cloneElement(children, { @@ -145,9 +105,14 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children ref: setReferenceElement, })} - {selector} +
); }; -export default EmojiButtonWrapper; +export default StatusReactionWrapper; diff --git a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx index 280649ab5..32851a4bf 100644 --- a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx +++ b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx @@ -1,13 +1,16 @@ +import { Placement } from '@popperjs/core'; import clsx from 'clsx'; -import React 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, + 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, + 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, visible = false, focused = false }): JSX.Element => { +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]); + + useEffect(() => { + if (visible && update) { + update(); + } + }, [visible, update]); + return ( - - {Array.from(emojis).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/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', () => { + 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-message-list.tsx b/app/soapbox/features/chats/components/chat-message-list.tsx index b1d9a0491..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,212 +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) => { - if (!chatMessage.media_attachments.size) 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) { @@ -476,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..aaf67e272 --- /dev/null +++ b/app/soapbox/features/chats/components/chat-message-reaction.tsx @@ -0,0 +1,45 @@ +import classNames 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/normalizers/chat-message.ts b/app/soapbox/normalizers/chat-message.ts index e928eeaaf..58054f969 100644 --- a/app/soapbox/normalizers/chat-message.ts +++ b/app/soapbox/normalizers/chat-message.ts @@ -7,7 +7,9 @@ import { import { normalizeAttachment } from 'soapbox/normalizers/attachment'; -import type { Attachment, Card, Emoji } from 'soapbox/types/entities'; +import { normalizeEmojiReaction } from './emoji-reaction'; + +import type { Attachment, Card, Emoji, EmojiReaction } from 'soapbox/types/entities'; export const ChatMessageRecord = ImmutableRecord({ account_id: '', @@ -17,6 +19,8 @@ export const ChatMessageRecord = ImmutableRecord({ content: '', created_at: '', emojis: ImmutableList(), + expiration: null as number | null, + emoji_reactions: ImmutableList(), id: '', unread: false, deleting: false, @@ -36,10 +40,21 @@ const normalizeMedia = (status: ImmutableMap) => { } }; +const normalizeChatMessageEmojiReaction = (chatMessage: ImmutableMap) => { + const emojiReactions = chatMessage.get('emoji_reactions'); + + if (emojiReactions) { + return chatMessage.set('emoji_reactions', ImmutableList(emojiReactions.map(normalizeEmojiReaction))); + } else { + return chatMessage; + } +}; + export const normalizeChatMessage = (chatMessage: Record) => { return ChatMessageRecord( ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => { normalizeMedia(chatMessage); + normalizeChatMessageEmojiReaction(chatMessage); }), ); }; diff --git a/app/soapbox/normalizers/emoji-reaction.ts b/app/soapbox/normalizers/emoji-reaction.ts new file mode 100644 index 000000000..88dcfd1e4 --- /dev/null +++ b/app/soapbox/normalizers/emoji-reaction.ts @@ -0,0 +1,14 @@ +import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; + +// https://docs.joinmastodon.org/entities/emoji/ +export const EmojiReactionRecord = ImmutableRecord({ + name: '', + count: null as number | null, + me: false, +}); + +export const normalizeEmojiReaction = (emojiReaction: Record) => { + return EmojiReactionRecord( + ImmutableMap(fromJS(emojiReaction)), + ); +}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 10339e460..5b05a0e21 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -8,6 +8,7 @@ export { CardRecord, normalizeCard } from './card'; export { ChatRecord, normalizeChat } from './chat'; export { ChatMessageRecord, normalizeChatMessage } from './chat-message'; export { EmojiRecord, normalizeEmoji } from './emoji'; +export { EmojiReactionRecord } from './emoji-reaction'; export { FilterRecord, normalizeFilter } from './filter'; export { GroupRecord, normalizeGroup } from './group'; export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship'; diff --git a/app/soapbox/queries/__tests__/chats.test.ts b/app/soapbox/queries/__tests__/chats.test.ts index 0c7fdec8f..6b3688a9a 100644 --- a/app/soapbox/queries/__tests__/chats.test.ts +++ b/app/soapbox/queries/__tests__/chats.test.ts @@ -1,15 +1,17 @@ -import { Map as ImmutableMap } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import sumBy from 'lodash/sumBy'; import { useEffect } from 'react'; import { __stub } from 'soapbox/api'; import { createTestStore, mockStore, queryClient, renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers'; -import { normalizeRelationship } from 'soapbox/normalizers'; +import { normalizeChatMessage, normalizeRelationship } from 'soapbox/normalizers'; +import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction'; import { Store } from 'soapbox/store'; +import { ChatMessage } from 'soapbox/types/entities'; import { flattenPages } from 'soapbox/utils/queries'; import { IAccount } from '../accounts'; -import { ChatKeys, IChat, IChatMessage, isLastMessage, useChat, useChatActions, useChatMessages, useChats } from '../chats'; +import { ChatKeys, IChat, isLastMessage, useChat, useChatActions, useChatMessages, useChats } from '../chats'; const chat: IChat = { accepted: true, @@ -22,6 +24,7 @@ const chat: IChat = { avatar_static: 'avatar', display_name: 'my name', } as IAccount, + chat_type: 'direct', created_at: '2020-06-10T02:05:06.000Z', created_by_account: '1', discarded_at: null, @@ -33,12 +36,14 @@ const chat: IChat = { unread: 0, }; -const buildChatMessage = (id: string): IChatMessage => ({ +const buildChatMessage = (id: string) => normalizeChatMessage({ id, chat_id: '1', account_id: '1', content: `chat message #${id}`, created_at: '2020-06-10T02:05:06.000Z', + emoji_reactions: null, + expiration: 1209600, unread: true, }); @@ -365,7 +370,7 @@ describe('useChatActions', () => { const { updateChat } = useChatActions(chat.id); useEffect(() => { - updateChat.mutate({ message_expiration: 1200 }); + updateChat.mutate({ message_expiration: 1200 }); }, []); return updateChat; @@ -379,4 +384,52 @@ describe('useChatActions', () => { expect((nextQueryData as any).message_expiration).toBe(1200); }); }); + + describe('createReaction()', () => { + const chatMessage = buildChatMessage('1'); + + beforeEach(() => { + __stub((mock) => { + mock + .onPost(`/api/v1/pleroma/chats/${chat.id}/messages/${chatMessage.id}/reactions`) + .reply(200, { ...chatMessage.toJS(), emoji_reactions: [{ name: '👍', count: 1, me: true }] }); + }); + }); + + it('successfully updates the Chat Message record', async () => { + const initialQueryData = { + pages: [ + { result: [chatMessage], hasMore: false, link: undefined }, + ], + pageParams: [undefined], + }; + + queryClient.setQueryData(ChatKeys.chatMessages(chat.id), initialQueryData); + + const { result } = renderHook(() => { + const { createReaction } = useChatActions(chat.id); + + useEffect(() => { + createReaction.mutate({ + messageId: chatMessage.id, + emoji: '👍', + chatMessage, + }); + }, []); + + return createReaction; + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const updatedChatMessage = (queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0] as ChatMessage; + expect(updatedChatMessage.emoji_reactions).toEqual(ImmutableList([normalizeEmojiReaction({ + name: '👍', + count: 1, + me: true, + })])); + }); + }); }); diff --git a/app/soapbox/queries/chats.ts b/app/soapbox/queries/chats.ts index 28fb403b3..83edbfcb8 100644 --- a/app/soapbox/queries/chats.ts +++ b/app/soapbox/queries/chats.ts @@ -8,7 +8,8 @@ import { useStatContext } from 'soapbox/contexts/stat-context'; import { useApi, useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { normalizeChatMessage } from 'soapbox/normalizers'; import toast from 'soapbox/toast'; -import { reOrderChatListItems } from 'soapbox/utils/chats'; +import { ChatMessage } from 'soapbox/types/entities'; +import { reOrderChatListItems, updateChatMessage } from 'soapbox/utils/chats'; import { flattenPages, PaginatedResult, updatePageItem } from 'soapbox/utils/queries'; import { queryClient } from './client'; @@ -28,6 +29,7 @@ export enum MessageExpirationValues { export interface IChat { accepted: boolean account: IAccount + chat_type: 'channel' | 'direct' created_at: string created_by_account: string discarded_at: null | string @@ -50,20 +52,16 @@ export interface IChat { unread: number } -export interface IChatMessage { - account_id: string - chat_id: string - content: string - created_at: string - id: string - unread: boolean - pending?: boolean -} - type UpdateChatVariables = { message_expiration: MessageExpirationValues } +type CreateReactionVariables = { + messageId: string + emoji: string + chatMessage?: ChatMessage +} + const ChatKeys = { chat: (chatId?: string) => ['chats', 'chat', chatId] as const, chatMessages: (chatId: string) => ['chats', 'messages', chatId] as const, @@ -83,7 +81,7 @@ const useChatMessages = (chat: IChat) => { const api = useApi(); const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by'])); - const getChatMessages = async (chatId: string, pageParam?: any): Promise> => { + const getChatMessages = async (chatId: string, pageParam?: any): Promise> => { const nextPageLink = pageParam?.link; const uri = nextPageLink || `/api/v1/pleroma/chats/${chatId}/messages`; const response = await api.get(uri); @@ -235,7 +233,7 @@ const useChatActions = (chatId: string) => { const createChatMessage = useMutation( ( { chatId, content, mediaId }: { chatId: string, content: string, mediaId?: string }, - ) => api.post(`/api/v1/pleroma/chats/${chatId}/messages`, { content, media_id: mediaId, media_ids: [mediaId] }), + ) => api.post(`/api/v1/pleroma/chats/${chatId}/messages`, { content, media_id: mediaId, media_ids: [mediaId] }), { retry: false, onMutate: async (variables) => { @@ -245,6 +243,7 @@ const useChatActions = (chatId: string) => { // Snapshot the previous value const prevContent = variables.content; const prevChatMessages = queryClient.getQueryData(['chats', 'messages', variables.chatId]); + const pendingId = String(Number(new Date())); // Optimistically update to the new value queryClient.setQueryData(ChatKeys.chatMessages(variables.chatId), (prevResult: any) => { @@ -256,7 +255,7 @@ const useChatActions = (chatId: string) => { result: [ normalizeChatMessage({ content: variables.content, - id: String(Number(new Date())), + id: pendingId, created_at: new Date(), account_id: account?.id, pending: true, @@ -273,18 +272,21 @@ const useChatActions = (chatId: string) => { return newResult; }); - return { prevChatMessages, prevContent }; + return { prevChatMessages, prevContent, pendingId }; }, // If the mutation fails, use the context returned from onMutate to roll back onError: (_error: any, variables, context: any) => { queryClient.setQueryData(['chats', 'messages', variables.chatId], context.prevChatMessages); }, - onSuccess: (response, variables) => { + onSuccess: (response: any, variables, context) => { const nextChat = { ...chat, last_message: response.data }; updatePageItem(ChatKeys.chatSearch(), nextChat, (o, n) => o.id === n.id); + updatePageItem( + ChatKeys.chatMessages(variables.chatId), + normalizeChatMessage(response.data), + (o) => o.id === context.pendingId, + ); reOrderChatListItems(); - - queryClient.invalidateQueries(ChatKeys.chatMessages(variables.chatId)); }, }, ); @@ -336,7 +338,34 @@ const useChatActions = (chatId: string) => { }, }); - return { createChatMessage, markChatAsRead, deleteChatMessage, updateChat, acceptChat, deleteChat }; + const createReaction = useMutation((data: CreateReactionVariables) => api.post(`/api/v1/pleroma/chats/${chatId}/messages/${data.messageId}/reactions`, { + emoji: data.emoji, + }), { + // TODO: add optimistic updates + onSuccess(response) { + updateChatMessage(response.data); + }, + }); + + const deleteReaction = useMutation( + (data: CreateReactionVariables) => api.delete(`/api/v1/pleroma/chats/${chatId}/messages/${data.messageId}/reactions/${data.emoji}`), + { + onSuccess() { + queryClient.invalidateQueries(ChatKeys.chatMessages(chatId)); + }, + }, + ); + + return { + acceptChat, + createChatMessage, + createReaction, + deleteChat, + deleteChatMessage, + deleteReaction, + markChatAsRead, + updateChat, + }; }; export { ChatKeys, useChat, useChatActions, useChats, useChatMessages, isLastMessage }; diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index e432a0b81..10df96188 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -9,6 +9,7 @@ import { ChatRecord, ChatMessageRecord, EmojiRecord, + EmojiReactionRecord, FieldRecord, FilterRecord, GroupRecord, @@ -40,6 +41,7 @@ type Card = ReturnType; type Chat = ReturnType; type ChatMessage = ReturnType; type Emoji = ReturnType; +type EmojiReaction = ReturnType; type Field = ReturnType; type Filter = ReturnType; type Group = ReturnType; @@ -84,6 +86,7 @@ export { Chat, ChatMessage, Emoji, + EmojiReaction, Field, Filter, Group, diff --git a/app/soapbox/utils/__tests__/chats.test.ts b/app/soapbox/utils/__tests__/chats.test.ts new file mode 100644 index 000000000..d1e4ce7f6 --- /dev/null +++ b/app/soapbox/utils/__tests__/chats.test.ts @@ -0,0 +1,73 @@ +import { normalizeChatMessage } from 'soapbox/normalizers'; +import { IAccount } from 'soapbox/queries/accounts'; +import { ChatKeys, IChat } from 'soapbox/queries/chats'; +import { queryClient } from 'soapbox/queries/client'; + +import { updateChatMessage } from '../chats'; + +const chat: IChat = { + accepted: true, + account: { + username: 'username', + verified: true, + id: '1', + acct: 'acct', + avatar: 'avatar', + avatar_static: 'avatar', + display_name: 'my name', + } as IAccount, + chat_type: 'direct', + created_at: '2020-06-10T02:05:06.000Z', + created_by_account: '1', + discarded_at: null, + id: '1', + last_message: null, + latest_read_message_by_account: [], + latest_read_message_created_at: null, + message_expiration: 1209600, + unread: 0, +}; + +const buildChatMessage = (id: string) => normalizeChatMessage({ + id, + chat_id: '1', + account_id: '1', + content: `chat message #${id}`, + created_at: '2020-06-10T02:05:06.000Z', + emoji_reactions: null, + expiration: 1209600, + unread: true, +}); + +describe('chat utils', () => { + describe('updateChatMessage()', () => { + const initialChatMessage = buildChatMessage('1'); + + beforeEach(() => { + const initialQueryData = { + pages: [ + { result: [initialChatMessage], hasMore: false, link: undefined }, + ], + pageParams: [undefined], + }; + + queryClient.setQueryData(ChatKeys.chatMessages(chat.id), initialQueryData); + }); + + it('correctly updates the chat message', () => { + expect( + (queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0].content, + ).toEqual(initialChatMessage.content); + + const nextChatMessage = normalizeChatMessage({ + ...initialChatMessage.toJS(), + content: 'new content', + }); + + updateChatMessage(nextChatMessage); + expect( + (queryClient.getQueryData(ChatKeys.chatMessages(chat.id)) as any).pages[0].result[0].content, + ).toEqual(nextChatMessage.content); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/utils/chats.ts b/app/soapbox/utils/chats.ts index 71a416562..737103316 100644 --- a/app/soapbox/utils/chats.ts +++ b/app/soapbox/utils/chats.ts @@ -84,4 +84,11 @@ const getUnreadChatsCount = (): number => { return sumBy(chats, chat => chat.unread); }; -export { updateChatListItem, getUnreadChatsCount, reOrderChatListItems }; \ No newline at end of file +/** Update the query cache for an individual Chat Message */ +const updateChatMessage = (chatMessage: ChatMessage) => updatePageItem( + ChatKeys.chatMessages(chatMessage.chat_id), + normalizeChatMessage(chatMessage), + (o, n) => o.id === n.id, +); + +export { updateChatListItem, updateChatMessage, getUnreadChatsCount, reOrderChatListItems }; \ No newline at end of file diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index b343761fb..1910a359c 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -248,6 +248,11 @@ const getInstanceFeatures = (instance: Instance) => { */ chatAcceptance: v.software === TRUTHSOCIAL, + /** + * Ability to add reactions to chat messages. + */ + chatEmojiReactions: v.software === TRUTHSOCIAL, + /** * Pleroma chats API. * @see {@link https://docs.pleroma.social/backend/development/API/chats/} @@ -304,6 +309,7 @@ const getInstanceFeatures = (instance: Instance) => { */ chatsWithFollowers: v.software === TRUTHSOCIAL, + /** * Mastodon's newer solution for direct messaging. * @see {@link https://docs.joinmastodon.org/methods/timelines/conversations/} @@ -377,7 +383,7 @@ const getInstanceFeatures = (instance: Instance) => { * The backend allows only RGI ("Recommended for General Interchange") emoji reactions. * @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji */ - emojiReactsRGI: v.software === PLEROMA && gte(v.version, '2.2.49'), + emojiReactsRGI: (v.software === PLEROMA && gte(v.version, '2.2.49')) || v.software === TRUTHSOCIAL, /** * Sign in with an Ethereum wallet. From 3ca491c03f205abbfc23e7c6bedb9a4493ea399a Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Wed, 8 Feb 2023 14:06:41 -0500 Subject: [PATCH 2/5] classNames -> clsx --- .../features/chats/components/chat-message-reaction.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/soapbox/features/chats/components/chat-message-reaction.tsx b/app/soapbox/features/chats/components/chat-message-reaction.tsx index aaf67e272..76c9fa0f8 100644 --- a/app/soapbox/features/chats/components/chat-message-reaction.tsx +++ b/app/soapbox/features/chats/components/chat-message-reaction.tsx @@ -1,4 +1,4 @@ -import classNames from 'clsx'; +import clsx from 'clsx'; import React from 'react'; import { Text } from 'soapbox/components/ui'; @@ -29,8 +29,8 @@ const ChatMessageReaction = (props: IChatMessageReaction) => { type='button' onClick={handleClick} className={ - classNames({ - 'w-12 rounded-lg flex items-center justify-between text-sm border border-solid text-gray-700 dark:text-gray-600 px-2 py-1 space-x-1.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-800 whitespace-nowrap': true, + clsx({ + 'w-12 rounded-lg flex justify-between text-sm border items-center border-solid text-gray-700 dark:text-gray-600 px-2 py-1 space-x-1.5 transition-colors hover:bg-gray-200 dark:hover:bg-gray-800 whitespace-nowrap': true, 'border-primary-500 dark:border-primary-400': emojiReaction.me, 'border-gray-300 dark:border-gray-800': !emojiReaction.me, }) From 9d08467b07192af60fdd8a2b7192b15cc6198be5 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 9 Feb 2023 13:02:03 -0500 Subject: [PATCH 3/5] Update Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48fb6ddc0..ebda645b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. ### 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. +- 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. From 2def1d0170672d041922483c54ae4804cec8cc25 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 9 Feb 2023 13:13:01 -0500 Subject: [PATCH 4/5] Discard changes to 'emojiReactsRGI' feature --- app/soapbox/utils/features.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 1910a359c..7f81ccd8e 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -309,7 +309,6 @@ const getInstanceFeatures = (instance: Instance) => { */ chatsWithFollowers: v.software === TRUTHSOCIAL, - /** * Mastodon's newer solution for direct messaging. * @see {@link https://docs.joinmastodon.org/methods/timelines/conversations/} @@ -383,7 +382,7 @@ const getInstanceFeatures = (instance: Instance) => { * The backend allows only RGI ("Recommended for General Interchange") emoji reactions. * @see PUT /api/v1/pleroma/statuses/:id/reactions/:emoji */ - emojiReactsRGI: (v.software === PLEROMA && gte(v.version, '2.2.49')) || v.software === TRUTHSOCIAL, + emojiReactsRGI: v.software === PLEROMA && gte(v.version, '2.2.49'), /** * Sign in with an Ethereum wallet. From 48eb42687abda7ca752af374bd0599f9feb86e5e Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 9 Feb 2023 13:16:06 -0500 Subject: [PATCH 5/5] Disable chatEmojiReacts --- app/soapbox/utils/features.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 7f81ccd8e..fc6bcc7e6 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -251,7 +251,7 @@ const getInstanceFeatures = (instance: Instance) => { /** * Ability to add reactions to chat messages. */ - chatEmojiReactions: v.software === TRUTHSOCIAL, + chatEmojiReactions: false, // v.software === TRUTHSOCIAL, /** * Pleroma chats API.