environments/review-improve-re-2blzmq/deployments/2560
parent
dbf2e53b93
commit
e255bfac3d
@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '../../jest/test-helpers';
|
||||
import EmojiSelector from '../emoji-selector';
|
||||
|
||||
describe('<EmojiSelector />', () => {
|
||||
it('renders correctly', () => {
|
||||
const children = <EmojiSelector />;
|
||||
// @ts-ignore
|
||||
children.__proto__.addEventListener = () => {};
|
||||
|
||||
render(children);
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(6);
|
||||
});
|
||||
});
|
@ -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<string>,
|
||||
onReact: (emoji: string) => void,
|
||||
onUnfocus: () => void,
|
||||
visible: boolean,
|
||||
focused?: boolean,
|
||||
}
|
||||
|
||||
class EmojiSelector extends ImmutablePureComponent<IEmojiSelector> {
|
||||
|
||||
static defaultProps: Partial<IEmojiSelector> = {
|
||||
onReact: () => { },
|
||||
onUnfocus: () => { },
|
||||
visible: false,
|
||||
};
|
||||
|
||||
node?: HTMLDivElement = undefined;
|
||||
|
||||
handleBlur: React.FocusEventHandler<HTMLDivElement> = 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 (
|
||||
<HotKeys handlers={this.handlers}>
|
||||
{/*<div
|
||||
className={clsx('flex absolute bg-white dark:bg-gray-500 px-2 py-3 rounded-full shadow-md opacity-0 pointer-events-none duration-100 w-max', { 'opacity-100 pointer-events-auto z-[999]': visible || focused })}
|
||||
onBlur={this.handleBlur}
|
||||
ref={this.setRef}
|
||||
>
|
||||
{allowedEmoji.map((emoji, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className='emoji-react-selector__emoji'
|
||||
onClick={this.handleReact(emoji)}
|
||||
onKeyDown={this.handleKeyDown(i)}
|
||||
tabIndex={(visible || focused) ? 0 : -1}
|
||||
>
|
||||
<Emoji emoji={emoji} />
|
||||
</button>
|
||||
))}
|
||||
</div>*/}
|
||||
<RealEmojiSelector
|
||||
emojis={allowedEmoji.toArray()}
|
||||
onReact={onReact}
|
||||
visible={visible}
|
||||
focused={focused}
|
||||
/>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(EmojiSelector);
|
@ -0,0 +1,78 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { normalizeEmojiReaction } from 'soapbox/normalizers/emoji-reaction';
|
||||
|
||||
import { render, screen } from '../../../../jest/test-helpers';
|
||||
import ChatMessageReaction from '../chat-message-reaction';
|
||||
|
||||
const emojiReaction = normalizeEmojiReaction({
|
||||
name: '👍',
|
||||
count: 1,
|
||||
me: false,
|
||||
});
|
||||
|
||||
describe('<ChatMessageReaction />', () => {
|
||||
it('renders properly', () => {
|
||||
render(
|
||||
<ChatMessageReaction
|
||||
emojiReaction={emojiReaction}
|
||||
onAddReaction={jest.fn()}
|
||||
onRemoveReaction={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ChatMessageReaction
|
||||
emojiReaction={emojiReaction}
|
||||
onAddReaction={onAddFn}
|
||||
onRemoveReaction={onRemoveFn}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ChatMessageReaction
|
||||
emojiReaction={normalizeEmojiReaction({
|
||||
name: '👍',
|
||||
count: 1,
|
||||
me: true,
|
||||
})}
|
||||
onAddReaction={onAddFn}
|
||||
onRemoveReaction={onRemoveFn}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onAddFn).not.toBeCalled();
|
||||
expect(onRemoveFn).not.toBeCalled();
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
// remove function triggered
|
||||
expect(onAddFn).not.toBeCalled();
|
||||
expect(onRemoveFn).toBeCalled();
|
||||
});
|
||||
});
|
@ -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<HTMLDivElement | null>(null);
|
||||
|
||||
const handleSelect = (emoji: string) => {
|
||||
onSelect(emoji);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onToggleVisibility = () => setIsOpen((prevValue) => !prevValue);
|
||||
|
||||
useEffect(() => {
|
||||
onOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{React.cloneElement(children, {
|
||||
ref: setReferenceElement,
|
||||
onClick: onToggleVisibility,
|
||||
})}
|
||||
|
||||
<EmojiSelector
|
||||
visible={isOpen}
|
||||
referenceElement={referenceElement}
|
||||
onReact={handleSelect}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatMessageReactionWrapper;
|
@ -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 (
|
||||
<button
|
||||
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,
|
||||
'border-primary-500 dark:border-primary-400': emojiReaction.me,
|
||||
'border-gray-300 dark:border-gray-800': !emojiReaction.me,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: emojify(emojiReaction.name) }} />
|
||||
<Text tag='span' weight='medium' size='sm'>{emojiReaction.count}</Text>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageReaction;
|
@ -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<string, any>, emoji: ImmutableMap<string, any>) => {
|
||||
return map.set(`:${emoji.get('shortcode')}:`, emoji);
|
||||
}, ImmutableMap());
|
||||
|
||||
const parsePendingContent = (content: string) => {
|
||||
return escape(content).replace(/(?:\r\n|\r|\n)/g, '<br>');
|
||||
};
|
||||
|
||||
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<boolean>(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(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 (
|
||||
<Bundle fetchComponent={MediaGallery}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
media={chatMessage.media_attachments}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className='px-4 py-2'>
|
||||
<div key={chatMessage.id} className='group' data-testid='chat-message'>
|
||||
<Stack
|
||||
space={1.5}
|
||||
className={clsx({
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
>
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent={isMyMessage ? 'end' : 'start'}
|
||||
className={clsx({
|
||||
'opacity-50': chatMessage.pending,
|
||||
})}
|
||||
>
|
||||
{menu.length > 0 && (
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
className={clsx({
|
||||
'opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100 flex text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500': true,
|
||||
'mr-2 order-2': isMyMessage,
|
||||
'ml-2 order-2': !isMyMessage,
|
||||
'!text-gray-700 dark:!text-gray-500': isMenuOpen,
|
||||
'!opacity-100': isMenuOpen || isReactionSelectorOpen,
|
||||
})}
|
||||
data-testid='chat-message-menu'
|
||||
iconClassName='w-4 h-4'
|
||||
/>
|
||||
</DropdownMenuContainer>
|
||||
)}
|
||||
|
||||
{features.chatEmojiReactions ? (
|
||||
<ChatMessageReactionWrapper
|
||||
onOpen={setIsReactionSelectorOpen}
|
||||
onSelect={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
|
||||
>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/mood-smile.svg')}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
className={clsx({
|
||||
'opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity flex text-gray-600 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-500 focus:text-gray-700 dark:focus:text-gray-500': true,
|
||||
'mr-2 order-1': isMyMessage,
|
||||
'ml-2 order-3': !isMyMessage,
|
||||
'!text-gray-700 dark:!text-gray-500': isReactionSelectorOpen,
|
||||
'!opacity-100': isMenuOpen || isReactionSelectorOpen,
|
||||
})}
|
||||
iconClassName='w-5 h-5'
|
||||
/>
|
||||
</ChatMessageReactionWrapper>
|
||||
) : null}
|
||||
|
||||
<Stack
|
||||
space={0.5}
|
||||
className={clsx({
|
||||
'max-w-[85%]': true,
|
||||
'flex-1': !!chatMessage.media_attachments.size,
|
||||
'order-3': isMyMessage,
|
||||
'order-1': !isMyMessage,
|
||||
})}
|
||||
alignItems={isMyMessage ? 'end' : 'start'}
|
||||
>
|
||||
{maybeRenderMedia(chatMessage)}
|
||||
|
||||
{content && (
|
||||
<HStack alignItems='bottom' className='max-w-full'>
|
||||
<div
|
||||
title={getFormattedTimestamp(chatMessage)}
|
||||
className={
|
||||
clsx({
|
||||
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2 [&_.mention]:underline': true,
|
||||
'rounded-tr-sm': (!!chatMessage.media_attachments.size) && isMyMessage,
|
||||
'rounded-tl-sm': (!!chatMessage.media_attachments.size) && !isMyMessage,
|
||||
'[&_.mention]:text-primary-600 dark:[&_.mention]:text-accent-blue': !isMyMessage,
|
||||
'[&_.mention]:text-white dark:[&_.mention]:white': isMyMessage,
|
||||
'bg-primary-500 text-white': isMyMessage,
|
||||
'bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100': !isMyMessage,
|
||||
'!bg-transparent !p-0 emoji-lg': isOnlyEmoji,
|
||||
})
|
||||
}
|
||||
ref={setBubbleRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Text
|
||||
size='sm'
|
||||
theme='inherit'
|
||||
className='break-word-nested'
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
{(features.chatEmojiReactions && chatMessage.emoji_reactions) ? (
|
||||
<div
|
||||
className={clsx({
|
||||
'space-y-1': true,
|
||||
'ml-auto': isMyMessage,
|
||||
'mr-auto': !isMyMessage,
|
||||
})}
|
||||
>
|
||||
{emojiReactionRows?.map((emojiReactionRow: any, idx: number) => (
|
||||
<HStack
|
||||
key={idx}
|
||||
className={
|
||||
clsx({
|
||||
'flex items-center gap-1': true,
|
||||
'flex-row-reverse': isMyMessage,
|
||||
})
|
||||
}
|
||||
>
|
||||
{emojiReactionRow.map((emojiReaction: any, idx: number) => (
|
||||
<ChatMessageReaction
|
||||
key={idx}
|
||||
emojiReaction={emojiReaction}
|
||||
onAddReaction={(emoji) => createReaction.mutate({ emoji, messageId: chatMessage.id, chatMessage })}
|
||||
onRemoveReaction={(emoji) => deleteReaction.mutate({ emoji, messageId: chatMessage.id })}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<HStack
|
||||
alignItems='center'
|
||||
space={2}
|
||||
className={clsx({
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={clsx({
|
||||
'text-right': isMyMessage,
|
||||
'order-2': !isMyMessage,
|
||||
})}
|
||||
>
|
||||
<span className='flex items-center space-x-1.5'>
|
||||
<Text theme='muted' size='xs'>
|
||||
{intl.formatTime(chatMessage.created_at)}
|
||||
</Text>
|
||||
|
||||
{(isMyMessage && features.chatsReadReceipts) ? (
|
||||
<>
|
||||
{isRead ? (
|
||||
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-primary-500 p-0.5 text-white dark:border-primary-400 dark:bg-primary-400 dark:text-primary-900'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/check.svg')}
|
||||
strokeWidth={3}
|
||||
className='h-2.5 w-2.5'
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className='flex flex-col items-center justify-center rounded-full border border-solid border-primary-500 bg-transparent p-0.5 text-primary-500 dark:border-primary-400 dark:text-primary-400'>
|
||||
<Icon
|
||||
src={require('@tabler/icons/check.svg')}
|
||||
strokeWidth={3}
|
||||
className='h-2.5 w-2.5'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
@ -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<string, any>) => {
|
||||
return EmojiReactionRecord(
|
||||
ImmutableMap(fromJS(emojiReaction)),
|
||||
);
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in new issue