Merge branch 'improve-reactions' into 'develop'
Improve Emoji Reactions and add support for Chat Reactions See merge request soapbox-pub/soapbox!2267
This commit is contained in:
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.
|
- Events: allow to repost events in event menu.
|
||||||
- Groups: Initial support for groups.
|
- Groups: Initial support for groups.
|
||||||
- Profile: Add RSS link to user profiles.
|
- Profile: Add RSS link to user profiles.
|
||||||
|
- Reactions: adds support for reacting to chat messages.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Chats: improved display of media attachments.
|
- 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: increased font size of focused status in threads.
|
||||||
- Posts: let "mute conversation" be clicked from any feed, not just noficiations.
|
- Posts: let "mute conversation" be clicked from any feed, not just noficiations.
|
||||||
- Posts: display all emoji reactions.
|
- Posts: display all emoji reactions.
|
||||||
|
- Reactions: improved UI of reactions on statuses.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Chats: media attachments rendering at the wrong size and/or causing the chat to scroll on load.
|
- 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 messages from 'soapbox/locales/messages';
|
||||||
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
|
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
|
||||||
import { queryClient } from 'soapbox/queries/client';
|
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 { removePageItem } from 'soapbox/utils/queries';
|
||||||
import { play, soundCache } from 'soapbox/utils/sounds';
|
import { play, soundCache } from 'soapbox/utils/sounds';
|
||||||
|
|
||||||
|
@ -170,6 +170,9 @@ const connectTimelineStream = (
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'chat_message.reaction': // TruthSocial
|
||||||
|
updateChatMessage(JSON.parse(data.payload));
|
||||||
|
break;
|
||||||
case 'pleroma:follow_relationships_update':
|
case 'pleroma:follow_relationships_update':
|
||||||
dispatch(updateFollowRelationships(JSON.parse(data.payload)));
|
dispatch(updateFollowRelationships(JSON.parse(data.payload)));
|
||||||
break;
|
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 { initMuteModal } from 'soapbox/actions/mutes';
|
||||||
import { initReport } from 'soapbox/actions/reports';
|
import { initReport } from 'soapbox/actions/reports';
|
||||||
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
|
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 StatusActionButton from 'soapbox/components/status-action-button';
|
||||||
|
import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper';
|
||||||
import { HStack } from 'soapbox/components/ui';
|
import { HStack } from 'soapbox/components/ui';
|
||||||
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
|
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
|
||||||
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
@ -629,7 +629,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{features.emojiReacts ? (
|
{features.emojiReacts ? (
|
||||||
<EmojiButtonWrapper statusId={status.id}>
|
<StatusReactionWrapper statusId={status.id}>
|
||||||
<StatusActionButton
|
<StatusActionButton
|
||||||
title={meEmojiTitle}
|
title={meEmojiTitle}
|
||||||
icon={require('@tabler/icons/heart.svg')}
|
icon={require('@tabler/icons/heart.svg')}
|
||||||
|
@ -640,7 +640,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||||
emoji={meEmojiReact}
|
emoji={meEmojiReact}
|
||||||
text={withLabels ? meEmojiTitle : undefined}
|
text={withLabels ? meEmojiTitle : undefined}
|
||||||
/>
|
/>
|
||||||
</EmojiButtonWrapper>
|
</StatusReactionWrapper>
|
||||||
) : (
|
) : (
|
||||||
<StatusActionButton
|
<StatusActionButton
|
||||||
title={intl.formatMessage(messages.favourite)}
|
title={intl.formatMessage(messages.favourite)}
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import clsx from 'clsx';
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { usePopper } from 'react-popper';
|
|
||||||
|
|
||||||
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
|
import { simpleEmojiReact } from 'soapbox/actions/emoji-reacts';
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
@ -9,13 +7,13 @@ import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig } from
|
||||||
import { isUserTouching } from 'soapbox/is-mobile';
|
import { isUserTouching } from 'soapbox/is-mobile';
|
||||||
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
|
import { getReactForStatus } from 'soapbox/utils/emoji-reacts';
|
||||||
|
|
||||||
interface IEmojiButtonWrapper {
|
interface IStatusReactionWrapper {
|
||||||
statusId: string,
|
statusId: string,
|
||||||
children: JSX.Element,
|
children: JSX.Element,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Provides emoji reaction functionality to the underlying button component */
|
/** 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 dispatch = useAppDispatch();
|
||||||
const ownAccount = useOwnAccount();
|
const ownAccount = useOwnAccount();
|
||||||
const status = useAppSelector(state => state.statuses.get(statusId));
|
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 timeout = useRef<NodeJS.Timeout>();
|
||||||
const [visible, setVisible] = useState(false);
|
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 [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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
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 (
|
return (
|
||||||
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||||
{React.cloneElement(children, {
|
{React.cloneElement(children, {
|
||||||
|
@ -145,9 +105,14 @@ const EmojiButtonWrapper: React.FC<IEmojiButtonWrapper> = ({ statusId, children
|
||||||
ref: setReferenceElement,
|
ref: setReferenceElement,
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{selector}
|
<EmojiSelector
|
||||||
|
placement='top-start'
|
||||||
|
referenceElement={referenceElement}
|
||||||
|
onReact={handleReact}
|
||||||
|
visible={visible}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EmojiButtonWrapper;
|
export default StatusReactionWrapper;
|
|
@ -1,13 +1,16 @@
|
||||||
|
import { Placement } from '@popperjs/core';
|
||||||
import clsx from 'clsx';
|
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 { Emoji, HStack } from 'soapbox/components/ui';
|
||||||
|
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
interface IEmojiButton {
|
interface IEmojiButton {
|
||||||
/** Unicode emoji character. */
|
/** Unicode emoji character. */
|
||||||
emoji: string,
|
emoji: string,
|
||||||
/** Event handler when the emoji is clicked. */
|
/** Event handler when the emoji is clicked. */
|
||||||
onClick: React.EventHandler<React.MouseEvent>,
|
onClick(emoji: string): void
|
||||||
/** Extra class name on the <button> element. */
|
/** Extra class name on the <button> element. */
|
||||||
className?: string,
|
className?: string,
|
||||||
/** Tab order of the button. */
|
/** Tab order of the button. */
|
||||||
|
@ -16,48 +19,103 @@ interface IEmojiButton {
|
||||||
|
|
||||||
/** Clickable emoji button that scales when hovered. */
|
/** Clickable emoji button that scales when hovered. */
|
||||||
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
|
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 (
|
return (
|
||||||
<button className={clsx(className)} onClick={onClick} tabIndex={tabIndex}>
|
<button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}>
|
||||||
<Emoji className='h-8 w-8 duration-100 hover:scale-125' emoji={emoji} />
|
<Emoji className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IEmojiSelector {
|
interface IEmojiSelector {
|
||||||
/** List of Unicode emoji characters. */
|
onClose?(): void
|
||||||
emojis: Iterable<string>,
|
|
||||||
/** Event handler when an emoji is clicked. */
|
/** 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. */
|
/** Whether the selector should be visible. */
|
||||||
visible?: boolean,
|
visible?: boolean
|
||||||
/** Whether the selector should be focused. */
|
|
||||||
focused?: boolean,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Panel with a row of emoji buttons. */
|
/** 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> => {
|
// `useRef` won't trigger a re-render, while `useState` does.
|
||||||
return (e) => {
|
// https://popper.js.org/react-popper/v2/
|
||||||
onReact(emoji);
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
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 (
|
return (
|
||||||
<HStack
|
<div
|
||||||
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')}
|
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) => (
|
<HStack
|
||||||
<EmojiButton
|
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')}
|
||||||
key={i}
|
>
|
||||||
emoji={emoji}
|
{Array.from(soapboxConfig.allowedEmoji).map((emoji, i) => (
|
||||||
onClick={handleReact(emoji)}
|
<EmojiButton
|
||||||
tabIndex={(visible || focused) ? 0 : -1}
|
key={i}
|
||||||
/>
|
emoji={emoji}
|
||||||
))}
|
onClick={onReact}
|
||||||
</HStack>
|
tabIndex={visible ? 0 : -1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,8 @@ interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
transparent?: boolean,
|
transparent?: boolean,
|
||||||
/** Predefined styles to display for the button. */
|
/** Predefined styles to display for the button. */
|
||||||
theme?: 'seamless' | 'outlined',
|
theme?: 'seamless' | 'outlined',
|
||||||
|
/** Override the data-testid */
|
||||||
|
'data-testid'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A clickable icon. */
|
/** A clickable icon. */
|
||||||
|
@ -31,7 +33,7 @@ const IconButton = React.forwardRef((props: IIconButton, ref: React.ForwardedRef
|
||||||
'opacity-50': filteredProps.disabled,
|
'opacity-50': filteredProps.disabled,
|
||||||
}, className)}
|
}, className)}
|
||||||
{...filteredProps}
|
{...filteredProps}
|
||||||
data-testid='icon-button'
|
data-testid={filteredProps['data-testid'] || 'icon-button'}
|
||||||
>
|
>
|
||||||
<SvgIcon src={src} className={iconClassName} />
|
<SvgIcon src={src} className={iconClassName} />
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ const mapStateToProps = (state: RootState) => ({
|
||||||
openedViaKeyboard: state.dropdown_menu.keyboard,
|
openedViaKeyboard: state.dropdown_menu.keyboard,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial<IDropdown>) => ({
|
const mapDispatchToProps = (dispatch: Dispatch, { status, items, ...filteredProps }: Partial<IDropdown>) => ({
|
||||||
onOpen(
|
onOpen(
|
||||||
id: number,
|
id: number,
|
||||||
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
|
||||||
|
@ -28,10 +28,18 @@ const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial<IDrop
|
||||||
actions: items,
|
actions: items,
|
||||||
onClick: onItemClick,
|
onClick: onItemClick,
|
||||||
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
||||||
|
|
||||||
|
if (filteredProps.onOpen) {
|
||||||
|
filteredProps.onOpen(id, onItemClick, dropdownPlacement, keyboard);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onClose(id: number) {
|
onClose(id: number) {
|
||||||
dispatch(closeModal('ACTIONS'));
|
dispatch(closeModal('ACTIONS'));
|
||||||
dispatch(closeDropdownMenu(id));
|
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 React from 'react';
|
||||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||||
|
|
||||||
|
|
||||||
import { ChatContext } from 'soapbox/contexts/chat-context';
|
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 { IAccount } from 'soapbox/queries/accounts';
|
||||||
|
import { ChatMessage } from 'soapbox/types/entities';
|
||||||
|
|
||||||
import { __stub } from '../../../../api';
|
import { __stub } from '../../../../api';
|
||||||
import { queryClient, render, rootState, screen, waitFor } from '../../../../jest/test-helpers';
|
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';
|
import ChatMessageList from '../chat-message-list';
|
||||||
|
|
||||||
const chat: IChat = {
|
const chat: IChat = {
|
||||||
|
@ -22,6 +24,7 @@ const chat: IChat = {
|
||||||
avatar_static: 'avatar',
|
avatar_static: 'avatar',
|
||||||
display_name: 'my name',
|
display_name: 'my name',
|
||||||
} as IAccount,
|
} as IAccount,
|
||||||
|
chat_type: 'direct',
|
||||||
created_at: '2020-06-10T02:05:06.000Z',
|
created_at: '2020-06-10T02:05:06.000Z',
|
||||||
created_by_account: '2',
|
created_by_account: '2',
|
||||||
discarded_at: null,
|
discarded_at: null,
|
||||||
|
@ -33,25 +36,29 @@ const chat: IChat = {
|
||||||
unread: 5,
|
unread: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const chatMessages: IChatMessage[] = [
|
const chatMessages: ChatMessage[] = [
|
||||||
{
|
normalizeChatMessage({
|
||||||
account_id: '1',
|
account_id: '1',
|
||||||
chat_id: '14',
|
chat_id: '14',
|
||||||
content: 'this is the first chat',
|
content: 'this is the first chat',
|
||||||
created_at: '2022-09-09T16:02:26.186Z',
|
created_at: '2022-09-09T16:02:26.186Z',
|
||||||
|
emoji_reactions: null,
|
||||||
|
expiration: 1209600,
|
||||||
id: '1',
|
id: '1',
|
||||||
unread: false,
|
unread: false,
|
||||||
pending: false,
|
pending: false,
|
||||||
},
|
}),
|
||||||
{
|
normalizeChatMessage({
|
||||||
account_id: '2',
|
account_id: '2',
|
||||||
chat_id: '14',
|
chat_id: '14',
|
||||||
content: 'this is the second chat',
|
content: 'this is the second chat',
|
||||||
created_at: '2022-09-09T16:04:26.186Z',
|
created_at: '2022-09-09T16:04:26.186Z',
|
||||||
|
emoji_reactions: null,
|
||||||
|
expiration: 1209600,
|
||||||
id: '2',
|
id: '2',
|
||||||
unread: true,
|
unread: true,
|
||||||
pending: false,
|
pending: false,
|
||||||
},
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock scrollIntoView function.
|
// 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 React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { Avatar, Button, Divider, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||||
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 PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message';
|
import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message';
|
||||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
||||||
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
import { IChat, useChatActions, useChatMessages } from 'soapbox/queries/chats';
|
||||||
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 ChatMessage from './chat-message';
|
||||||
import ChatMessageListIntro from './chat-message-list-intro';
|
import ChatMessageListIntro from './chat-message-list-intro';
|
||||||
|
|
||||||
import type { Menu } from 'soapbox/components/dropdown-menu';
|
|
||||||
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
|
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const BIG_EMOJI_LIMIT = 3;
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
today: { id: 'chats.dividers.today', defaultMessage: 'Today' },
|
today: { id: 'chats.dividers.today', defaultMessage: 'Today' },
|
||||||
more: { id: 'chats.actions.more', defaultMessage: 'More' },
|
more: { id: 'chats.actions.more', defaultMessage: 'More' },
|
||||||
|
@ -43,7 +27,7 @@ const messages = defineMessages({
|
||||||
|
|
||||||
type TimeFormat = 'today' | 'date';
|
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 prevDate = new Date(prev.created_at).getDate();
|
||||||
const currDate = new Date(curr.created_at).getDate();
|
const currDate = new Date(curr.created_at).getDate();
|
||||||
const nowDate = new Date().getDate();
|
const nowDate = new Date().getDate();
|
||||||
|
@ -55,10 +39,6 @@ const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null =
|
||||||
return 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 START_INDEX = 10000;
|
||||||
|
|
||||||
const List: Components['List'] = React.forwardRef((props, ref) => {
|
const List: Components['List'] = React.forwardRef((props, ref) => {
|
||||||
|
@ -89,19 +69,15 @@ interface IChatMessageList {
|
||||||
/** Scrollable list of chat messages. */
|
/** Scrollable list of chat messages. */
|
||||||
const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const account = useOwnAccount();
|
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 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 myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null;
|
||||||
|
|
||||||
const node = useRef<VirtuosoHandle>(null);
|
const node = useRef<VirtuosoHandle>(null);
|
||||||
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
|
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
|
||||||
|
|
||||||
const { deleteChatMessage, markChatAsRead } = useChatActions(chat.id);
|
const { markChatAsRead } = useChatActions(chat.id);
|
||||||
const {
|
const {
|
||||||
data: chatMessages,
|
data: chatMessages,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
|
@ -115,24 +91,24 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
|
|
||||||
const formattedChatMessages = chatMessages || [];
|
const formattedChatMessages = chatMessages || [];
|
||||||
|
|
||||||
const me = useAppSelector((state) => state.me);
|
|
||||||
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by']));
|
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 lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null;
|
||||||
|
|
||||||
const cachedChatMessages = useMemo(() => {
|
useEffect(() => {
|
||||||
if (!chatMessages) {
|
if (!chatMessages) {
|
||||||
return [];
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextFirstItemIndex = START_INDEX - chatMessages.length;
|
const nextFirstItemIndex = START_INDEX - chatMessages.length;
|
||||||
setFirstItemIndex(nextFirstItemIndex);
|
setFirstItemIndex(nextFirstItemIndex);
|
||||||
|
}, [lastChatMessage]);
|
||||||
|
|
||||||
|
const buildCachedMessages = () => {
|
||||||
|
if (!chatMessages) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return chatMessages.reduce((acc: any, curr: any, idx: number) => {
|
return chatMessages.reduce((acc: any, curr: any, idx: number) => {
|
||||||
const lastMessage = formattedChatMessages[idx - 1];
|
const lastMessage = formattedChatMessages[idx - 1];
|
||||||
|
|
||||||
|
@ -156,32 +132,19 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
acc.push(curr);
|
acc.push(curr);
|
||||||
return acc;
|
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) => {
|
const initialScrollPositionProps = useMemo(() => {
|
||||||
if (!c) return;
|
if (process.env.NODE_ENV === 'test') {
|
||||||
const links = c.querySelectorAll('a[rel="ugc"]');
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
links.forEach(link => {
|
return {
|
||||||
link.classList.add('chat-link');
|
initialTopMostItemIndex: cachedChatMessages.length - 1,
|
||||||
link.setAttribute('rel', 'ugc nofollow noopener');
|
firstItemIndex: Math.max(0, firstItemIndex),
|
||||||
link.setAttribute('target', '_blank');
|
};
|
||||||
});
|
}, [cachedChatMessages.length, firstItemIndex]);
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartReached = useCallback(() => {
|
const handleStartReached = useCallback(() => {
|
||||||
if (hasNextPage && !isFetching) {
|
if (hasNextPage && !isFetching) {
|
||||||
|
@ -190,212 +153,8 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
return false;
|
return false;
|
||||||
}, [firstItemIndex, hasNextPage, isFetching]);
|
}, [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 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(() => {
|
useEffect(() => {
|
||||||
const lastMessage = formattedChatMessages[formattedChatMessages.length - 1];
|
const lastMessage = formattedChatMessages[formattedChatMessages.length - 1];
|
||||||
if (!lastMessage) {
|
if (!lastMessage) {
|
||||||
|
@ -476,8 +235,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
ref={node}
|
ref={node}
|
||||||
alignToBottom
|
alignToBottom
|
||||||
firstItemIndex={Math.max(0, firstItemIndex)}
|
{...initialScrollPositionProps}
|
||||||
initialTopMostItemIndex={initialTopMostItemIndex}
|
|
||||||
data={cachedChatMessages}
|
data={cachedChatMessages}
|
||||||
startReached={handleStartReached}
|
startReached={handleStartReached}
|
||||||
followOutput='auto'
|
followOutput='auto'
|
||||||
|
@ -485,11 +243,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
if (chatMessage.type === 'divider') {
|
if (chatMessage.type === 'divider') {
|
||||||
return renderDivider(index, chatMessage.text);
|
return renderDivider(index, chatMessage.text);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return <ChatMessage chat={chat} chatMessage={chatMessage} />;
|
||||||
<div className='px-4 py-2'>
|
|
||||||
{renderMessage(chatMessage)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
components={{
|
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 { 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({
|
export const ChatMessageRecord = ImmutableRecord({
|
||||||
account_id: '',
|
account_id: '',
|
||||||
|
@ -17,6 +19,8 @@ export const ChatMessageRecord = ImmutableRecord({
|
||||||
content: '',
|
content: '',
|
||||||
created_at: '',
|
created_at: '',
|
||||||
emojis: ImmutableList<Emoji>(),
|
emojis: ImmutableList<Emoji>(),
|
||||||
|
expiration: null as number | null,
|
||||||
|
emoji_reactions: ImmutableList<EmojiReaction>(),
|
||||||
id: '',
|
id: '',
|
||||||
unread: false,
|
unread: false,
|
||||||
deleting: 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>) => {
|
export const normalizeChatMessage = (chatMessage: Record<string, any>) => {
|
||||||
return ChatMessageRecord(
|
return ChatMessageRecord(
|
||||||
ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => {
|
ImmutableMap(fromJS(chatMessage)).withMutations(chatMessage => {
|
||||||
normalizeMedia(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 { ChatRecord, normalizeChat } from './chat';
|
||||||
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
|
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
|
||||||
export { EmojiRecord, normalizeEmoji } from './emoji';
|
export { EmojiRecord, normalizeEmoji } from './emoji';
|
||||||
|
export { EmojiReactionRecord } from './emoji-reaction';
|
||||||
export { FilterRecord, normalizeFilter } from './filter';
|
export { FilterRecord, normalizeFilter } from './filter';
|
||||||
export { GroupRecord, normalizeGroup } from './group';
|
export { GroupRecord, normalizeGroup } from './group';
|
||||||
export { GroupRelationshipRecord, normalizeGroupRelationship } from './group-relationship';
|
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 sumBy from 'lodash/sumBy';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { __stub } from 'soapbox/api';
|
import { __stub } from 'soapbox/api';
|
||||||
import { createTestStore, mockStore, queryClient, renderHook, rootState, waitFor } from 'soapbox/jest/test-helpers';
|
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 { Store } from 'soapbox/store';
|
||||||
|
import { ChatMessage } from 'soapbox/types/entities';
|
||||||
import { flattenPages } from 'soapbox/utils/queries';
|
import { flattenPages } from 'soapbox/utils/queries';
|
||||||
|
|
||||||
import { IAccount } from '../accounts';
|
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 = {
|
const chat: IChat = {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
@ -22,6 +24,7 @@ const chat: IChat = {
|
||||||
avatar_static: 'avatar',
|
avatar_static: 'avatar',
|
||||||
display_name: 'my name',
|
display_name: 'my name',
|
||||||
} as IAccount,
|
} as IAccount,
|
||||||
|
chat_type: 'direct',
|
||||||
created_at: '2020-06-10T02:05:06.000Z',
|
created_at: '2020-06-10T02:05:06.000Z',
|
||||||
created_by_account: '1',
|
created_by_account: '1',
|
||||||
discarded_at: null,
|
discarded_at: null,
|
||||||
|
@ -33,12 +36,14 @@ const chat: IChat = {
|
||||||
unread: 0,
|
unread: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildChatMessage = (id: string): IChatMessage => ({
|
const buildChatMessage = (id: string) => normalizeChatMessage({
|
||||||
id,
|
id,
|
||||||
chat_id: '1',
|
chat_id: '1',
|
||||||
account_id: '1',
|
account_id: '1',
|
||||||
content: `chat message #${id}`,
|
content: `chat message #${id}`,
|
||||||
created_at: '2020-06-10T02:05:06.000Z',
|
created_at: '2020-06-10T02:05:06.000Z',
|
||||||
|
emoji_reactions: null,
|
||||||
|
expiration: 1209600,
|
||||||
unread: true,
|
unread: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -365,7 +370,7 @@ describe('useChatActions', () => {
|
||||||
const { updateChat } = useChatActions(chat.id);
|
const { updateChat } = useChatActions(chat.id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateChat.mutate({ message_expiration: 1200 });
|
updateChat.mutate({ message_expiration: 1200 });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return updateChat;
|
return updateChat;
|
||||||
|
@ -379,4 +384,52 @@ describe('useChatActions', () => {
|
||||||
expect((nextQueryData as any).message_expiration).toBe(1200);
|
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 { useApi, useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||||
import { normalizeChatMessage } from 'soapbox/normalizers';
|
import { normalizeChatMessage } from 'soapbox/normalizers';
|
||||||
import toast from 'soapbox/toast';
|
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 { flattenPages, PaginatedResult, updatePageItem } from 'soapbox/utils/queries';
|
||||||
|
|
||||||
import { queryClient } from './client';
|
import { queryClient } from './client';
|
||||||
|
@ -28,6 +29,7 @@ export enum MessageExpirationValues {
|
||||||
export interface IChat {
|
export interface IChat {
|
||||||
accepted: boolean
|
accepted: boolean
|
||||||
account: IAccount
|
account: IAccount
|
||||||
|
chat_type: 'channel' | 'direct'
|
||||||
created_at: string
|
created_at: string
|
||||||
created_by_account: string
|
created_by_account: string
|
||||||
discarded_at: null | string
|
discarded_at: null | string
|
||||||
|
@ -50,20 +52,16 @@ export interface IChat {
|
||||||
unread: number
|
unread: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IChatMessage {
|
|
||||||
account_id: string
|
|
||||||
chat_id: string
|
|
||||||
content: string
|
|
||||||
created_at: string
|
|
||||||
id: string
|
|
||||||
unread: boolean
|
|
||||||
pending?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateChatVariables = {
|
type UpdateChatVariables = {
|
||||||
message_expiration: MessageExpirationValues
|
message_expiration: MessageExpirationValues
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateReactionVariables = {
|
||||||
|
messageId: string
|
||||||
|
emoji: string
|
||||||
|
chatMessage?: ChatMessage
|
||||||
|
}
|
||||||
|
|
||||||
const ChatKeys = {
|
const ChatKeys = {
|
||||||
chat: (chatId?: string) => ['chats', 'chat', chatId] as const,
|
chat: (chatId?: string) => ['chats', 'chat', chatId] as const,
|
||||||
chatMessages: (chatId: string) => ['chats', 'messages', chatId] as const,
|
chatMessages: (chatId: string) => ['chats', 'messages', chatId] as const,
|
||||||
|
@ -83,7 +81,7 @@ const useChatMessages = (chat: IChat) => {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by']));
|
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 nextPageLink = pageParam?.link;
|
||||||
const uri = nextPageLink || `/api/v1/pleroma/chats/${chatId}/messages`;
|
const uri = nextPageLink || `/api/v1/pleroma/chats/${chatId}/messages`;
|
||||||
const response = await api.get<any[]>(uri);
|
const response = await api.get<any[]>(uri);
|
||||||
|
@ -235,7 +233,7 @@ const useChatActions = (chatId: string) => {
|
||||||
const createChatMessage = useMutation(
|
const createChatMessage = useMutation(
|
||||||
(
|
(
|
||||||
{ chatId, content, mediaId }: { chatId: string, content: string, mediaId?: string },
|
{ 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,
|
retry: false,
|
||||||
onMutate: async (variables) => {
|
onMutate: async (variables) => {
|
||||||
|
@ -245,6 +243,7 @@ const useChatActions = (chatId: string) => {
|
||||||
// Snapshot the previous value
|
// Snapshot the previous value
|
||||||
const prevContent = variables.content;
|
const prevContent = variables.content;
|
||||||
const prevChatMessages = queryClient.getQueryData(['chats', 'messages', variables.chatId]);
|
const prevChatMessages = queryClient.getQueryData(['chats', 'messages', variables.chatId]);
|
||||||
|
const pendingId = String(Number(new Date()));
|
||||||
|
|
||||||
// Optimistically update to the new value
|
// Optimistically update to the new value
|
||||||
queryClient.setQueryData(ChatKeys.chatMessages(variables.chatId), (prevResult: any) => {
|
queryClient.setQueryData(ChatKeys.chatMessages(variables.chatId), (prevResult: any) => {
|
||||||
|
@ -256,7 +255,7 @@ const useChatActions = (chatId: string) => {
|
||||||
result: [
|
result: [
|
||||||
normalizeChatMessage({
|
normalizeChatMessage({
|
||||||
content: variables.content,
|
content: variables.content,
|
||||||
id: String(Number(new Date())),
|
id: pendingId,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
account_id: account?.id,
|
account_id: account?.id,
|
||||||
pending: true,
|
pending: true,
|
||||||
|
@ -273,18 +272,21 @@ const useChatActions = (chatId: string) => {
|
||||||
return newResult;
|
return newResult;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { prevChatMessages, prevContent };
|
return { prevChatMessages, prevContent, pendingId };
|
||||||
},
|
},
|
||||||
// If the mutation fails, use the context returned from onMutate to roll back
|
// If the mutation fails, use the context returned from onMutate to roll back
|
||||||
onError: (_error: any, variables, context: any) => {
|
onError: (_error: any, variables, context: any) => {
|
||||||
queryClient.setQueryData(['chats', 'messages', variables.chatId], context.prevChatMessages);
|
queryClient.setQueryData(['chats', 'messages', variables.chatId], context.prevChatMessages);
|
||||||
},
|
},
|
||||||
onSuccess: (response, variables) => {
|
onSuccess: (response: any, variables, context) => {
|
||||||
const nextChat = { ...chat, last_message: response.data };
|
const nextChat = { ...chat, last_message: response.data };
|
||||||
updatePageItem(ChatKeys.chatSearch(), nextChat, (o, n) => o.id === n.id);
|
updatePageItem(ChatKeys.chatSearch(), nextChat, (o, n) => o.id === n.id);
|
||||||
|
updatePageItem(
|
||||||
|
ChatKeys.chatMessages(variables.chatId),
|
||||||
|
normalizeChatMessage(response.data),
|
||||||
|
(o) => o.id === context.pendingId,
|
||||||
|
);
|
||||||
reOrderChatListItems();
|
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 };
|
export { ChatKeys, useChat, useChatActions, useChats, useChatMessages, isLastMessage };
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
ChatRecord,
|
ChatRecord,
|
||||||
ChatMessageRecord,
|
ChatMessageRecord,
|
||||||
EmojiRecord,
|
EmojiRecord,
|
||||||
|
EmojiReactionRecord,
|
||||||
FieldRecord,
|
FieldRecord,
|
||||||
FilterRecord,
|
FilterRecord,
|
||||||
GroupRecord,
|
GroupRecord,
|
||||||
|
@ -40,6 +41,7 @@ type Card = ReturnType<typeof CardRecord>;
|
||||||
type Chat = ReturnType<typeof ChatRecord>;
|
type Chat = ReturnType<typeof ChatRecord>;
|
||||||
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
|
||||||
type Emoji = ReturnType<typeof EmojiRecord>;
|
type Emoji = ReturnType<typeof EmojiRecord>;
|
||||||
|
type EmojiReaction = ReturnType<typeof EmojiReactionRecord>;
|
||||||
type Field = ReturnType<typeof FieldRecord>;
|
type Field = ReturnType<typeof FieldRecord>;
|
||||||
type Filter = ReturnType<typeof FilterRecord>;
|
type Filter = ReturnType<typeof FilterRecord>;
|
||||||
type Group = ReturnType<typeof GroupRecord>;
|
type Group = ReturnType<typeof GroupRecord>;
|
||||||
|
@ -84,6 +86,7 @@ export {
|
||||||
Chat,
|
Chat,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Emoji,
|
Emoji,
|
||||||
|
EmojiReaction,
|
||||||
Field,
|
Field,
|
||||||
Filter,
|
Filter,
|
||||||
Group,
|
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);
|
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,
|
chatAcceptance: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ability to add reactions to chat messages.
|
||||||
|
*/
|
||||||
|
chatEmojiReactions: false, // v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pleroma chats API.
|
* Pleroma chats API.
|
||||||
* @see {@link https://docs.pleroma.social/backend/development/API/chats/}
|
* @see {@link https://docs.pleroma.social/backend/development/API/chats/}
|
||||||
|
|
Loading…
Reference in New Issue