sforkowany z mirror/soapbox
Merge branch 'improve-reactions' into 'develop'
Improve Emoji Reactions and add support for Chat Reactions See merge request soapbox-pub/soapbox!2267renovate/docker-23.x
commit
380d2b763f
|
@ -14,6 +14,7 @@ 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.
|
||||
|
@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Posts: increased font size of focused status in threads.
|
||||
- Posts: let "mute conversation" be clicked from any feed, not just noficiations.
|
||||
- Posts: display all emoji reactions.
|
||||
- Reactions: improved UI of reactions on statuses.
|
||||
|
||||
### Fixed
|
||||
- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
|
@ -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<IStatusActionBar> = ({
|
|||
)}
|
||||
|
||||
{features.emojiReacts ? (
|
||||
<EmojiButtonWrapper statusId={status.id}>
|
||||
<StatusReactionWrapper statusId={status.id}>
|
||||
<StatusActionButton
|
||||
title={meEmojiTitle}
|
||||
icon={require('@tabler/icons/heart.svg')}
|
||||
|
@ -640,7 +640,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
emoji={meEmojiReact}
|
||||
text={withLabels ? meEmojiTitle : undefined}
|
||||
/>
|
||||
</EmojiButtonWrapper>
|
||||
</StatusReactionWrapper>
|
||||
) : (
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.favourite)}
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
|
@ -9,13 +7,13 @@ import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from
|
|||
import { isUserTouching } from 'soapbox/is-mobile';
|
||||
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
|
||||
|
||||
interface IEmojiButtonWrapper {
|
||||
interface IStatusReactionWrapper {
|
||||
statusId: string,
|
||||
children: JSX.Element,
|
||||
}
|
||||
|
||||
/** Provides emoji reaction functionality to the underlying button component */
|
||||
const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children }): JSX.Element | null => {
|
||||
const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ 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<IEmojiButtonWrapper> = ({ statusId, children
|
|||
|
||||
const timeout = useRef<NodeJS.Timeout>();
|
||||
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<HTMLDivElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(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<IEmojiButtonWrapper> = ({ statusId, children
|
|||
}));
|
||||
};
|
||||
|
||||
// const handleUnfocus: React.EventHandler<React.KeyboardEvent> = () => {
|
||||
// setFocused(false);
|
||||
// };
|
||||
|
||||
const selector = (
|
||||
<div
|
||||
className={clsx('z-50 transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !visible,
|
||||
})}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<EmojiSelector
|
||||
emojis={soapboxConfig.allowedEmoji}
|
||||
onReact={handleReact}
|
||||
// focused={focused}
|
||||
// onUnfocus={handleUnfocus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
{React.cloneElement(children, {
|
||||
|
@ -145,9 +105,14 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
|
|||
ref: setReferenceElement,
|
||||
})}
|
||||
|
||||
{selector}
|
||||
<EmojiSelector
|
||||
placement='top-start'
|
||||
referenceElement={referenceElement}
|
||||
onReact={handleReact}
|
||||
visible={visible}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiButtonWrapper;
|
||||
export default StatusReactionWrapper;
|
|
@ -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<React.MouseEvent>,
|
||||
onClick(emoji: string): void
|
||||
/** Extra class name on the <button> element. */
|
||||
className?: string,
|
||||
/** Tab order of the button. */
|
||||
|
@ -16,48 +19,103 @@ interface IEmojiButton {
|
|||
|
||||
/** Clickable emoji button that scales when hovered. */
|
||||
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
|
||||
const handleClick: React.EventHandler<React.MouseEvent> = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
onClick(emoji);
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={clsx(className)} onClick={onClick} tabIndex={tabIndex}>
|
||||
<Emoji className='h-8 w-8 duration-100 hover:scale-125' emoji={emoji} />
|
||||
<button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}>
|
||||
<Emoji className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface IEmojiSelector {
|
||||
/** List of Unicode emoji characters. */
|
||||
emojis: Iterable<string>,
|
||||
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<IEmojiSelector> = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => {
|
||||
const EmojiSelector: React.FC<IEmojiSelector> = ({
|
||||
referenceElement,
|
||||
onClose,
|
||||
onReact,
|
||||
placement = 'top',
|
||||
visible = false,
|
||||
}): JSX.Element => {
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const handleReact = (emoji: string): React.EventHandler<React.MouseEvent> => {
|
||||
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<HTMLDivElement | null>(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 (
|
||||
<HStack
|
||||
className={clsx('z-[999] w-max max-w-[100vw] flex-wrap gap-2 rounded-full bg-white p-3 shadow-md dark:bg-gray-900')}
|
||||
<div
|
||||
className={clsx('z-50 transition-opacity duration-100', {
|
||||
'opacity-0 pointer-events-none': !visible,
|
||||
})}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
{Array.from(emojis).map((emoji, i) => (
|
||||
<EmojiButton
|
||||
key={i}
|
||||
emoji={emoji}
|
||||
onClick={handleReact(emoji)}
|
||||
tabIndex={(visible || focused) ? 0 : -1}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
<HStack
|
||||
className={clsx('z-[999] flex w-max max-w-[100vw] flex-wrap space-x-3 rounded-full bg-white px-3 py-2.5 shadow-lg focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700')}
|
||||
>
|
||||
{Array.from(soapboxConfig.allowedEmoji).map((emoji, i) => (
|
||||
<EmojiButton
|
||||
key={i}
|
||||
emoji={emoji}
|
||||
onClick={onReact}
|
||||
tabIndex={visible ? 0 : -1}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -15,6 +15,8 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|||
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'}
|
||||
>
|
||||
<SvgIcon src={src} className={iconClassName} />
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ const mapStateToProps = (state: RootState) => ({
|
|||
openedViaKeyboard: state.dropdown_menu.keyboard,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial<IDropdown>) => ({
|
||||
const mapDispatchToProps = (dispatch: Dispatch, { status, items, ...filteredProps }: Partial<IDropdown>) => ({
|
||||
onOpen(
|
||||
id: number,
|
||||
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
||||
|
@ -28,10 +28,18 @@ const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial<IDrop
|
|||
actions: items,
|
||||
onClick: onItemClick,
|
||||
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
||||
|
||||
if (filteredProps.onOpen) {
|
||||
filteredProps.onOpen(id, onItemClick, dropdownPlacement, keyboard);
|
||||
}
|
||||
},
|
||||
onClose(id: number) {
|
||||
dispatch(closeModal('ACTIONS'));
|
||||
dispatch(closeDropdownMenu(id));
|
||||
|
||||
if (filteredProps.onClose) {
|
||||
filteredProps.onClose(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -2,13 +2,15 @@ import userEvent from '@testing-library/user-event';
|
|||
import React from 'react';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
|
||||
|
||||
import { ChatContext } from 'soapbox/contexts/chat-context';
|
||||
import { normalizeInstance } from 'soapbox/normalizers';
|
||||
import { normalizeChatMessage, normalizeInstance } from 'soapbox/normalizers';
|
||||
import { IAccount } from 'soapbox/queries/accounts';
|
||||
import { ChatMessage } from 'soapbox/types/entities';
|
||||
|
||||
import { __stub } from '../../../../api';
|
||||
import { queryClient, render, rootState, screen, waitFor } from '../../../../jest/test-helpers';
|
||||
import { IChat, IChatMessage } from '../../../../queries/chats';
|
||||
import { IChat } from '../../../../queries/chats';
|
||||
import ChatMessageList from '../chat-message-list';
|
||||
|
||||
const chat: IChat = {
|
||||
|
@ -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: '2',
|
||||
discarded_at: null,
|
||||
|
@ -33,25 +36,29 @@ const chat: IChat = {
|
|||
unread: 5,
|
||||
};
|
||||
|
||||
const chatMessages: IChatMessage[] = [
|
||||
{
|
||||
const chatMessages: ChatMessage[] = [
|
||||
normalizeChatMessage({
|
||||
account_id: '1',
|
||||
chat_id: '14',
|
||||
content: 'this is the first chat',
|
||||
created_at: '2022-09-09T16:02:26.186Z',
|
||||
emoji_reactions: null,
|
||||
expiration: 1209600,
|
||||
id: '1',
|
||||
unread: false,
|
||||
pending: false,
|
||||
},
|
||||
{
|
||||
}),
|
||||
normalizeChatMessage({
|
||||
account_id: '2',
|
||||
chat_id: '14',
|
||||
content: 'this is the second chat',
|
||||
created_at: '2022-09-09T16:04:26.186Z',
|
||||
emoji_reactions: null,
|
||||
expiration: 1209600,
|
||||
id: '2',
|
||||
unread: true,
|
||||
pending: false,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Mock scrollIntoView function.
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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<string, any>, emoji: ImmutableMap<string, any>) => {
|
||||
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<IChatMessageList> = ({ 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<VirtuosoHandle>(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<IChatMessageList> = ({ 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<IChatMessageList> = ({ 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<IChatMessageList> = ({ 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 (
|
||||
<Bundle fetchComponent={MediaGallery}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
media={chatMessage.media_attachments}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
};
|
||||
|
||||
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());
|
||||
};
|
||||
|
||||
const renderDivider = (key: React.Key, text: string) => <Divider key={key} text={text} textSize='xs' />;
|
||||
|
||||
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 (
|
||||
<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 && (
|
||||
<div
|
||||
className={clsx({
|
||||
'hidden focus:block group-hover:block text-gray-500': true,
|
||||
'mr-2 order-1': isMyMessage,
|
||||
'ml-2 order-2': !isMyMessage,
|
||||
})}
|
||||
data-testid='chat-message-menu'
|
||||
>
|
||||
<DropdownMenuContainer items={menu}>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
className='text-gray-600 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-500'
|
||||
iconClassName='w-4 h-4'
|
||||
/>
|
||||
</DropdownMenuContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
space={0.5}
|
||||
className={clsx({
|
||||
'max-w-[85%]': true,
|
||||
'flex-1': !!chatMessage.media_attachments.size,
|
||||
'order-2': 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>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const lastMessage = formattedChatMessages[formattedChatMessages.length - 1];
|
||||
if (!lastMessage) {
|
||||
|
@ -476,8 +235,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
|||
<Virtuoso
|
||||
ref={node}
|
||||
alignToBottom
|
||||
firstItemIndex={Math.max(0, firstItemIndex)}
|
||||
initialTopMostItemIndex={initialTopMostItemIndex}
|
||||
{...initialScrollPositionProps}
|
||||
data={cachedChatMessages}
|
||||
startReached={handleStartReached}
|
||||
followOutput='auto'
|
||||
|
@ -485,11 +243,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
|||
if (chatMessage.type === 'divider') {
|
||||
return renderDivider(index, chatMessage.text);
|
||||
} else {
|
||||
return (
|
||||
<div className='px-4 py-2'>
|
||||
{renderMessage(chatMessage)}
|
||||
</div>
|
||||
);
|
||||
return <ChatMessage chat={chat} chatMessage={chatMessage} />;
|
||||
}
|
||||
}}
|
||||
components={{
|
||||
|
|
|
@ -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 clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { EmojiReaction } from 'soapbox/types/entities';
|
||||
|
||||
interface IChatMessageReaction {
|
||||
emojiReaction: EmojiReaction
|
||||
onRemoveReaction(emoji: string): void
|
||||
onAddReaction(emoji: string): void
|
||||
}
|
||||
|
||||
const ChatMessageReaction = (props: IChatMessageReaction) => {
|
||||
const { emojiReaction, onAddReaction, onRemoveReaction } = props;
|
||||
|
||||
const isAlreadyReacted = emojiReaction.me;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isAlreadyReacted) {
|
||||
onRemoveReaction(emojiReaction.name);
|
||||
} else {
|
||||
onAddReaction(emojiReaction.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleClick}
|
||||
className={
|
||||
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,
|
||||
})
|
||||
}
|
||||
>
|
||||
<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;
|
|
@ -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<Emoji>(),
|
||||
expiration: null as number | null,
|
||||
emoji_reactions: ImmutableList<EmojiReaction>(),
|
||||
id: '',
|
||||
unread: false,
|
||||
deleting: false,
|
||||
|
@ -36,10 +40,21 @@ const normalizeMedia = (status: ImmutableMap<string, any>) => {
|
|||
}
|
||||
};
|
||||
|
||||
const normalizeChatMessageEmojiReaction = (chatMessage: ImmutableMap<string, any>) => {
|
||||
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<string, any>) => {
|
||||
return ChatMessageRecord(
|
||||
ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => {
|
||||
normalizeMedia(chatMessage);
|
||||
normalizeChatMessageEmojiReaction(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)),
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
})]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<PaginatedResult<IChatMessage>> => {
|
||||
const getChatMessages = async (chatId: string, pageParam?: any): Promise<PaginatedResult<ChatMessage>> => {
|
||||
const nextPageLink = pageParam?.link;
|
||||
const uri = nextPageLink || `/api/v1/pleroma/chats/${chatId}/messages`;
|
||||
const response = await api.get<any[]>(uri);
|
||||
|
@ -235,7 +233,7 @@ const useChatActions = (chatId: string) => {
|
|||
const createChatMessage = useMutation(
|
||||
(
|
||||
{ chatId, content, mediaId }: { chatId: string, content: string, mediaId?: string },
|
||||
) => api.post<IChatMessage>(`/api/v1/pleroma/chats/${chatId}/messages`, { content, media_id: mediaId, media_ids: [mediaId] }),
|
||||
) => api.post<ChatMessage>(`/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 };
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
ChatRecord,
|
||||
ChatMessageRecord,
|
||||
EmojiRecord,
|
||||
EmojiReactionRecord,
|
||||
FieldRecord,
|
||||
FilterRecord,
|
||||
GroupRecord,
|
||||
|
@ -40,6 +41,7 @@ type Card = ReturnType<typeof CardRecord>;
|
|||
type Chat = ReturnType<typeof ChatRecord>;
|
||||
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
||||
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||
type EmojiReaction = ReturnType<typeof EmojiReactionRecord>;
|
||||
type Field = ReturnType<typeof FieldRecord>;
|
||||
type Filter = ReturnType<typeof FilterRecord>;
|
||||
type Group = ReturnType<typeof GroupRecord>;
|
||||
|
@ -84,6 +86,7 @@ export {
|
|||
Chat,
|
||||
ChatMessage,
|
||||
Emoji,
|
||||
EmojiReaction,
|
||||
Field,
|
||||
Filter,
|
||||
Group,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -84,4 +84,11 @@ const getUnreadChatsCount = (): number => {
|
|||
return sumBy(chats, chat => chat.unread);
|
||||
};
|
||||
|
||||
export { updateChatListItem, getUnreadChatsCount, reOrderChatListItems };
|
||||
/** 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 };
|
|
@ -248,6 +248,11 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
chatAcceptance: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Ability to add reactions to chat messages.
|
||||
*/
|
||||
chatEmojiReactions: false, // v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Pleroma chats API.
|
||||
* @see {@link https://docs.pleroma.social/backend/development/API/chats/}
|
||||
|
|
Ładowanie…
Reference in New Issue