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