Merge branch 'chats' into 'develop'

Chats redesign

Closes #1227, #360, #1196, #495, and #380

See merge request soapbox-pub/soapbox!1795
This commit is contained in:
Alex Gleason 2022-12-07 15:12:31 +00:00
commit ebd126ac3c
139 changed files with 6388 additions and 1619 deletions

View File

@ -204,9 +204,7 @@ export const rememberAuthAccount = (accountUrl: string) =>
export const loadCredentials = (token: string, accountUrl: string) => export const loadCredentials = (token: string, accountUrl: string) =>
(dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl)) (dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl))
.then(() => { .then(() => dispatch(verifyCredentials(token, accountUrl)))
dispatch(verifyCredentials(token, accountUrl));
})
.catch(() => dispatch(verifyCredentials(token, accountUrl))); .catch(() => dispatch(verifyCredentials(token, accountUrl)));
export const logIn = (username: string, password: string) => export const logIn = (username: string, password: string) =>

View File

@ -89,6 +89,7 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!notification.type) return; // drop invalid notifications if (!notification.type) return; // drop invalid notifications
if (notification.type === 'pleroma:chat_mention') return; // Drop chat notifications, handle them per-chat if (notification.type === 'pleroma:chat_mention') return; // Drop chat notifications, handle them per-chat
if (notification.type === 'chat') return; // Drop Truth Social chat notifications.
const showAlert = getSettings(getState()).getIn(['notifications', 'alerts', notification.type]); const showAlert = getSettings(getState()).getIn(['notifications', 'alerts', notification.type]);
const filters = getFilters(getState(), { contextType: 'notifications' }); const filters = getFilters(getState(), { contextType: 'notifications' });

View File

@ -4,7 +4,7 @@ import { openModal } from './modals';
import type { AxiosError } from 'axios'; import type { AxiosError } from 'axios';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { Account, Status } from 'soapbox/types/entities'; import type { Account, ChatMessage, Status } from 'soapbox/types/entities';
const REPORT_INIT = 'REPORT_INIT'; const REPORT_INIT = 'REPORT_INIT';
const REPORT_CANCEL = 'REPORT_CANCEL'; const REPORT_CANCEL = 'REPORT_CANCEL';
@ -20,26 +20,23 @@ const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE'; const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE';
const initReport = (account: Account, status?: Status) => type ReportedEntity = {
(dispatch: AppDispatch) => { status?: Status,
dispatch({ chatMessage?: ChatMessage
type: REPORT_INIT, }
account,
status,
});
return dispatch(openModal('REPORT')); const initReport = (account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => {
}; const { status, chatMessage } = entities || {};
const initReportById = (accountId: string) => dispatch({
(dispatch: AppDispatch, getState: () => RootState) => { type: REPORT_INIT,
dispatch({ account,
type: REPORT_INIT, status,
account: getState().accounts.get(accountId), chatMessage,
}); });
dispatch(openModal('REPORT')); return dispatch(openModal('REPORT'));
}; };
const cancelReport = () => ({ const cancelReport = () => ({
type: REPORT_CANCEL, type: REPORT_CANCEL,
@ -59,6 +56,7 @@ const submitReport = () =>
return api(getState).post('/api/v1/reports', { return api(getState).post('/api/v1/reports', {
account_id: reports.getIn(['new', 'account_id']), account_id: reports.getIn(['new', 'account_id']),
status_ids: reports.getIn(['new', 'status_ids']), status_ids: reports.getIn(['new', 'status_ids']),
message_ids: [reports.getIn(['new', 'chat_message', 'id'])],
rule_ids: reports.getIn(['new', 'rule_ids']), rule_ids: reports.getIn(['new', 'rule_ids']),
comment: reports.getIn(['new', 'comment']), comment: reports.getIn(['new', 'comment']),
forward: reports.getIn(['new', 'forward']), forward: reports.getIn(['new', 'forward']),
@ -110,7 +108,6 @@ export {
REPORT_BLOCK_CHANGE, REPORT_BLOCK_CHANGE,
REPORT_RULE_CHANGE, REPORT_RULE_CHANGE,
initReport, initReport,
initReportById,
cancelReport, cancelReport,
toggleStatusReport, toggleStatusReport,
submitReport, submitReport,

View File

@ -1,5 +1,10 @@
import { getSettings } from 'soapbox/actions/settings'; 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 { queryClient } from 'soapbox/queries/client';
import { getUnreadChatsCount, updateChatListItem } from 'soapbox/utils/chats';
import { removePageItem } from 'soapbox/utils/queries';
import { play, soundCache } from 'soapbox/utils/sounds';
import { connectStream } from '../stream'; import { connectStream } from '../stream';
@ -22,8 +27,9 @@ import {
processTimelineUpdate, processTimelineUpdate,
} from './timelines'; } from './timelines';
import type { IStatContext } from 'soapbox/contexts/stat-context';
import type { AppDispatch, RootState } from 'soapbox/store'; import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity } from 'soapbox/types/entities'; import type { APIEntity, Chat } from 'soapbox/types/entities';
const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE'; const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE';
const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE'; const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE';
@ -45,11 +51,45 @@ const updateFollowRelationships = (relationships: APIEntity) =>
}); });
}; };
const removeChatMessage = (payload: string) => {
const data = JSON.parse(payload);
const chatId = data.chat_id;
const chatMessageId = data.deleted_message_id;
// If the user just deleted the "last_message", then let's invalidate
// the Chat Search query so the Chat List will show the new "last_message".
if (isLastMessage(chatMessageId)) {
queryClient.invalidateQueries(ChatKeys.chatSearch());
}
removePageItem(ChatKeys.chatMessages(chatId), chatMessageId, (o: any, n: any) => String(o.id) === String(n));
};
// Update the specific Chat query data.
const updateChatQuery = (chat: IChat) => {
const cachedChat = queryClient.getQueryData<IChat>(ChatKeys.chat(chat.id));
if (!cachedChat) {
return;
}
const newChat = {
...cachedChat,
latest_read_message_by_account: chat.latest_read_message_by_account,
latest_read_message_created_at: chat.latest_read_message_created_at,
};
queryClient.setQueryData<Chat>(ChatKeys.chat(chat.id), newChat as any);
};
interface StreamOpts {
statContext?: IStatContext,
}
const connectTimelineStream = ( const connectTimelineStream = (
timelineId: string, timelineId: string,
path: string, path: string,
pollingRefresh: ((dispatch: AppDispatch, done?: () => void) => void) | null = null, pollingRefresh: ((dispatch: AppDispatch, done?: () => void) => void) | null = null,
accept: ((status: APIEntity) => boolean) | null = null, accept: ((status: APIEntity) => boolean) | null = null,
opts?: StreamOpts,
) => connectStream(path, pollingRefresh, (dispatch: AppDispatch, getState: () => RootState) => { ) => connectStream(path, pollingRefresh, (dispatch: AppDispatch, getState: () => RootState) => {
const locale = getLocale(getState()); const locale = getLocale(getState());
@ -78,7 +118,14 @@ const connectTimelineStream = (
// break; // break;
case 'notification': case 'notification':
messages[locale]().then(messages => { messages[locale]().then(messages => {
dispatch(updateNotificationsQueue(JSON.parse(data.payload), messages, locale, window.location.pathname)); dispatch(
updateNotificationsQueue(
JSON.parse(data.payload),
messages,
locale,
window.location.pathname,
),
);
}).catch(error => { }).catch(error => {
console.error(error); console.error(error);
}); });
@ -90,18 +137,37 @@ const connectTimelineStream = (
dispatch(fetchFilters()); dispatch(fetchFilters());
break; break;
case 'pleroma:chat_update': case 'pleroma:chat_update':
dispatch((dispatch: AppDispatch, getState: () => RootState) => { case 'chat_message.created': // TruthSocial
dispatch((_dispatch: AppDispatch, getState: () => RootState) => {
const chat = JSON.parse(data.payload); const chat = JSON.parse(data.payload);
const me = getState().me; const me = getState().me;
const messageOwned = !(chat.last_message && chat.last_message.account_id !== me); const messageOwned = chat.last_message?.account_id === me;
const settings = getSettings(getState());
dispatch({ // Don't update own messages from streaming
type: STREAMING_CHAT_UPDATE, if (!messageOwned) {
chat, updateChatListItem(chat);
me,
// Only play sounds for recipient messages if (settings.getIn(['chats', 'sound'])) {
meta: !messageOwned && getSettings(getState()).getIn(['chats', 'sound']) && { sound: 'chat' }, play(soundCache.chat);
}); }
// Increment unread counter
opts?.statContext?.setUnreadChatsCount(getUnreadChatsCount());
}
});
break;
case 'chat_message.deleted': // TruthSocial
removeChatMessage(data.payload);
break;
case 'chat_message.read': // TruthSocial
dispatch((_dispatch: AppDispatch, getState: () => RootState) => {
const chat = JSON.parse(data.payload);
const me = getState().me;
const isFromOtherUser = chat.account.id !== me;
if (isFromOtherUser) {
updateChatQuery(JSON.parse(data.payload));
}
}); });
break; break;
case 'pleroma:follow_relationships_update': case 'pleroma:follow_relationships_update':
@ -129,8 +195,8 @@ const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () =>
dispatch(expandNotifications({}, () => dispatch(expandNotifications({}, () =>
dispatch(fetchAnnouncements(done)))))); dispatch(fetchAnnouncements(done))))));
const connectUserStream = () => const connectUserStream = (opts?: StreamOpts) =>
connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification, null, opts);
const connectCommunityStream = ({ onlyMedia }: Record<string, any> = {}) => const connectCommunityStream = ({ onlyMedia }: Record<string, any> = {}) =>
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);

View File

@ -21,6 +21,11 @@ export const getLinks = (response: AxiosResponse): LinkHeader => {
return new LinkHeader(response.headers?.link); return new LinkHeader(response.headers?.link);
}; };
export const getNextLink = (response: AxiosResponse) => {
const nextLink = new LinkHeader(response.headers?.link);
return nextLink.refs.find((ref) => ref.uri)?.uri;
};
export const baseClient = (...params: any[]) => { export const baseClient = (...params: any[]) => {
const axios = api.baseClient(...params); const axios = api.baseClient(...params);
setupMock(axios); setupMock(axios);

View File

@ -3,7 +3,9 @@ import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input'; import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input';
import Icon from 'soapbox/components/icon';
import SvgIcon from './ui/icon/svg-icon';
import { InputThemes } from './ui/input/input';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'account_search.placeholder', defaultMessage: 'Search for an account' }, placeholder: { id: 'account_search.placeholder', defaultMessage: 'Search for an account' },
@ -16,10 +18,18 @@ interface IAccountSearch {
placeholder?: string, placeholder?: string,
/** Position of results relative to the input. */ /** Position of results relative to the input. */
resultsPosition?: 'above' | 'below', resultsPosition?: 'above' | 'below',
/** Optional class for the input */
className?: string,
autoFocus?: boolean,
hidePortal?: boolean,
theme?: InputThemes,
showButtons?: boolean,
/** Search only among people who follow you (TruthSocial). */
followers?: boolean,
} }
/** Input to search for accounts. */ /** Input to search for accounts. */
const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => { const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, className, showButtons = true, ...rest }) => {
const intl = useIntl(); const intl = useIntl();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
@ -56,11 +66,12 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
}; };
return ( return (
<div className='search search--account'> <div className='w-full'>
<label> <label className='sr-only'>{intl.formatMessage(messages.placeholder)}</label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
<div className='relative'>
<AutosuggestAccountInput <AutosuggestAccountInput
className='rounded-full' className={classNames('rounded-full', className)}
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(messages.placeholder)}
value={value} value={value}
onChange={handleChange} onChange={handleChange}
@ -68,10 +79,26 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
{...rest} {...rest}
/> />
</label>
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}> {showButtons && (
<Icon src={require('@tabler/icons/search.svg')} className={classNames('svg-icon--search', { active: isEmpty() })} /> <div
<Icon src={require('@tabler/icons/backspace.svg')} className={classNames('svg-icon--backspace', { active: !isEmpty() })} aria-label={intl.formatMessage(messages.placeholder)} /> role='button'
tabIndex={0}
className='absolute inset-y-0 right-0 px-3 flex items-center cursor-pointer'
onClick={handleClear}
>
<SvgIcon
src={require('@tabler/icons/search.svg')}
className={classNames('h-4 w-4 text-gray-400', { hidden: !isEmpty() })}
/>
<SvgIcon
src={require('@tabler/icons/x.svg')}
className={classNames('h-4 w-4 text-gray-400', { hidden: isEmpty() })}
aria-label={intl.formatMessage(messages.placeholder)}
/>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -14,6 +14,7 @@ const noOp = () => { };
interface IAutosuggestAccountInput { interface IAutosuggestAccountInput {
onChange: React.ChangeEventHandler<HTMLInputElement>, onChange: React.ChangeEventHandler<HTMLInputElement>,
onSelected: (accountId: string) => void, onSelected: (accountId: string) => void,
autoFocus?: boolean,
value: string, value: string,
limit?: number, limit?: number,
className?: string, className?: string,
@ -21,6 +22,8 @@ interface IAutosuggestAccountInput {
menu?: Menu, menu?: Menu,
onKeyDown?: React.KeyboardEventHandler, onKeyDown?: React.KeyboardEventHandler,
theme?: InputThemes, theme?: InputThemes,
/** Search only among people who follow you (TruthSocial). */
followers?: boolean,
} }
const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
@ -28,6 +31,7 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
onSelected, onSelected,
value = '', value = '',
limit = 4, limit = 4,
followers = false,
...rest ...rest
}) => { }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -44,7 +48,7 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
}; };
const handleAccountSearch = useCallback(throttle(q => { const handleAccountSearch = useCallback(throttle(q => {
const params = { q, limit, resolve: false }; const params = { q, limit, followers, resolve: false };
dispatch(accountSearch(params, controller.current.signal)) dispatch(accountSearch(params, controller.current.signal))
.then((accounts: { id: string }[]) => { .then((accounts: { id: string }[]) => {
@ -67,6 +71,12 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
} }
}; };
useEffect(() => {
if (rest.autoFocus) {
handleAccountSearch('');
}
}, []);
useEffect(() => { useEffect(() => {
if (value === '') { if (value === '') {
clearResults(); clearResults();

View File

@ -9,42 +9,13 @@ import Icon from 'soapbox/components/icon';
import { Input } from 'soapbox/components/ui'; import { Input } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account'; import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
import { isRtl } from 'soapbox/rtl'; import { isRtl } from 'soapbox/rtl';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu'; import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
import type { InputThemes } from 'soapbox/components/ui/input/input'; import type { InputThemes } from 'soapbox/components/ui/input/input';
type CursorMatch = [
tokenStart: number | null,
token: string | null,
];
export type AutoSuggestion = string | Emoji; export type AutoSuggestion = string | Emoji;
const textAtCursorMatchesToken = (str: string, caretPosition: number, searchTokens: string[]): CursorMatch => {
let word: string;
const left: number = str.slice(0, caretPosition).search(/\S+$/);
const right: number = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || !searchTokens.includes(word[0])) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> { export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'onKeyUp' | 'onKeyDown'> {
value: string, value: string,
suggestions: ImmutableList<any>, suggestions: ImmutableList<any>,
@ -62,6 +33,7 @@ export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputEl
menu?: Menu, menu?: Menu,
resultsPosition: string, resultsPosition: string,
renderSuggestion?: React.FC<{ id: string }>, renderSuggestion?: React.FC<{ id: string }>,
hidePortal?: boolean,
theme?: InputThemes, theme?: InputThemes,
} }
@ -89,7 +61,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
input: HTMLInputElement | null = null; input: HTMLInputElement | null = null;
onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart || 0, this.props.searchTokens); const [tokenStart, token] = textAtCursorMatchesToken(
e.target.value,
e.target.selectionStart || 0,
this.props.searchTokens,
);
if (token !== null && this.state.lastToken !== token) { if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
@ -292,11 +268,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
} }
render() { render() {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, theme } = this.props; const { hidePortal, value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, theme } = this.props;
const { suggestionsHidden } = this.state; const { suggestionsHidden } = this.state;
const style: React.CSSProperties = { direction: 'ltr' }; const style: React.CSSProperties = { direction: 'ltr' };
const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value)); const visible = !hidePortal && !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
if (isRtl(value)) { if (isRtl(value)) {
style.direction = 'rtl'; style.direction = 'rtl';

View File

@ -4,6 +4,8 @@ import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestAccount from '../features/compose/components/autosuggest-account'; import AutosuggestAccount from '../features/compose/components/autosuggest-account';
import { isRtl } from '../rtl'; import { isRtl } from '../rtl';
@ -11,31 +13,6 @@ import AutosuggestEmoji, { Emoji } from './autosuggest-emoji';
import type { List as ImmutableList } from 'immutable'; import type { List as ImmutableList } from 'immutable';
const textAtCursorMatchesToken = (str: string, caretPosition: number) => {
let word;
const left = str.slice(0, caretPosition).search(/\S+$/);
const right = str.slice(caretPosition).search(/\s/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || !['@', ':', '#'].includes(word[0])) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
interface IAutosuggesteTextarea { interface IAutosuggesteTextarea {
id?: string, id?: string,
value: string, value: string,
@ -72,7 +49,11 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
}; };
onChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => { onChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); const [tokenStart, token] = textAtCursorMatchesToken(
e.target.value,
e.target.selectionStart,
['@', ':', '#'],
);
if (token !== null && this.state.lastToken !== token) { if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });

View File

@ -219,7 +219,12 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
// It should not be transformed when mounting because the resulting // It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by // size will be used to determine the coordinate of the menu by
// react-overlays // react-overlays
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }} ref={this.setRef}> <div
className={`dropdown-menu ${placement}`}
style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }}
ref={this.setRef}
data-testid='dropdown-menu'
>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<ul> <ul>
{items.map((option, i) => this.renderItem(option, i))} {items.map((option, i) => this.renderItem(option, i))}

View File

@ -5,18 +5,19 @@ import { Counter } from 'soapbox/components/ui';
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> { interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
count: number, count: number,
countMax?: number
icon?: string; icon?: string;
src?: string; src?: string;
} }
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) => { const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, countMax, ...rest }) => {
return ( return (
<div className='relative'> <div className='relative'>
<Icon id={icon} {...rest as IIcon} /> <Icon id={icon} {...rest as IIcon} />
{count > 0 && ( {count > 0 && (
<span className='absolute -top-2 -right-2'> <span className='absolute -top-2 -right-3'>
<Counter count={count} /> <Counter count={count} countMax={countMax} />
</span> </span>
)} )}
</div> </div>

View File

@ -0,0 +1,11 @@
import React from 'react';
import { Link as Comp, LinkProps } from 'react-router-dom';
const Link = (props: LinkProps) => (
<Comp
{...props}
className='text-primary-600 dark:text-accent-blue hover:underline'
/>
);
export default Link;

View File

@ -14,10 +14,12 @@ const List: React.FC = ({ children }) => (
interface IListItem { interface IListItem {
label: React.ReactNode, label: React.ReactNode,
hint?: React.ReactNode, hint?: React.ReactNode,
onClick?: () => void, onClick?(): void,
onSelect?(): void
isSelected?: boolean
} }
const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => { const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick, onSelect, isSelected }) => {
const id = uuidv4(); const id = uuidv4();
const domId = `list-group-${id}`; const domId = `list-group-${id}`;
@ -28,8 +30,8 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
}; };
const Comp = onClick ? 'a' : 'div'; const Comp = onClick ? 'a' : 'div';
const LabelComp = onClick ? 'span' : 'label'; const LabelComp = onClick || onSelect ? 'span' : 'label';
const linkProps = onClick ? { onClick, onKeyDown, tabIndex: 0, role: 'link' } : {}; const linkProps = onClick || onSelect ? { onClick: onClick || onSelect, onKeyDown, tabIndex: 0, role: 'link' } : {};
const renderChildren = React.useCallback(() => { const renderChildren = React.useCallback(() => {
return React.Children.map(children, (child) => { return React.Children.map(children, (child) => {
@ -52,7 +54,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
<Comp <Comp
className={classNames({ className={classNames({
'flex items-center justify-between px-3 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/10 to-gradient-end/10': true, 'flex items-center justify-between px-3 py-2 first:rounded-t-lg last:rounded-b-lg bg-gradient-to-r from-gradient-start/10 to-gradient-end/10': true,
'cursor-pointer hover:from-gradient-start/20 hover:to-gradient-end/20 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined', 'cursor-pointer hover:from-gradient-start/20 hover:to-gradient-end/20 dark:hover:from-gradient-start/5 dark:hover:to-gradient-end/5': typeof onClick !== 'undefined' || typeof onSelect !== 'undefined',
})} })}
{...linkProps} {...linkProps}
> >
@ -70,7 +72,19 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1' /> <Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1' />
</HStack> </HStack>
) : renderChildren()} ) : null}
{onSelect ? (
<div className='flex flex-row items-center text-gray-700 dark:text-gray-600'>
{children}
{isSelected ? (
<Icon src={require('@tabler/icons/check.svg')} className='ml-1 text-primary-500 dark:text-primary-400' />
) : null}
</div>
) : null}
{typeof onClick === 'undefined' && typeof onSelect === 'undefined' ? renderChildren() : null}
</Comp> </Comp>
); );
}; };

View File

@ -4,7 +4,7 @@ import React, { useState, useRef, useEffect } from 'react';
import Blurhash from 'soapbox/components/blurhash'; import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import StillImage from 'soapbox/components/still-image'; import StillImage from 'soapbox/components/still-image';
import { MIMETYPE_ICONS } from 'soapbox/features/compose/components/upload'; import { MIMETYPE_ICONS } from 'soapbox/components/upload';
import { useSettings } from 'soapbox/hooks'; import { useSettings } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities'; import { Attachment } from 'soapbox/types/entities';
import { truncateFilename } from 'soapbox/utils/media'; import { truncateFilename } from 'soapbox/utils/media';
@ -262,7 +262,7 @@ const Item: React.FC<IItem> = ({
interface IMediaGallery { interface IMediaGallery {
sensitive?: boolean, sensitive?: boolean,
media: ImmutableList<Attachment>, media: ImmutableList<Attachment>,
height: number, height?: number,
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void, onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
defaultWidth?: number, defaultWidth?: number,
cacheWidth?: (width: number) => void, cacheWidth?: (width: number) => void,

View File

@ -8,6 +8,8 @@ import { cancelReplyCompose } from 'soapbox/actions/compose';
import { cancelEventCompose } from 'soapbox/actions/events'; import { cancelEventCompose } from 'soapbox/actions/events';
import { openModal, closeModal } from 'soapbox/actions/modals'; import { openModal, closeModal } from 'soapbox/actions/modals';
import { useAppDispatch, usePrevious } from 'soapbox/hooks'; import { useAppDispatch, usePrevious } from 'soapbox/hooks';
import { queryClient } from 'soapbox/queries/client';
import { IPolicy, PolicyKeys } from 'soapbox/queries/policies';
import type { UnregisterCallback } from 'history'; import type { UnregisterCallback } from 'history';
import type { ModalType } from 'soapbox/features/ui/components/modal-root'; import type { ModalType } from 'soapbox/features/ui/components/modal-root';
@ -112,6 +114,15 @@ const ModalRoot: React.FC<IModalRoot> = ({ children, onCancel, onClose, type })
})); }));
} else if ((hasComposeContent || hasEventComposeContent) && type === 'CONFIRM') { } else if ((hasComposeContent || hasEventComposeContent) && type === 'CONFIRM') {
dispatch(closeModal('CONFIRM')); dispatch(closeModal('CONFIRM'));
} else if (type === 'POLICY') {
// If the user has not accepted the Policy, prevent them
// from closing the Modal.
const pendingPolicy = queryClient.getQueryData(PolicyKeys.policy) as IPolicy;
if (pendingPolicy?.pending_policy_id) {
return;
}
onClose();
} else { } else {
onClose(); onClose();
} }

View File

@ -7,6 +7,8 @@ import { Icon, Text } from './ui';
interface ISidebarNavigationLink { interface ISidebarNavigationLink {
/** Notification count, if any. */ /** Notification count, if any. */
count?: number, count?: number,
/** Optional max to cap count (ie: N+) */
countMax?: number
/** URL to an SVG icon. */ /** URL to an SVG icon. */
icon: string, icon: string,
/** Link label. */ /** Link label. */
@ -19,7 +21,7 @@ interface ISidebarNavigationLink {
/** Desktop sidebar navigation link. */ /** Desktop sidebar navigation link. */
const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, ref: React.ForwardedRef<HTMLAnchorElement>): JSX.Element => { const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, ref: React.ForwardedRef<HTMLAnchorElement>): JSX.Element => {
const { icon, text, to = '', count, onClick } = props; const { icon, text, to = '', count, countMax, onClick } = props;
const isActive = location.pathname === to; const isActive = location.pathname === to;
const handleClick: React.EventHandler<React.MouseEvent> = (e) => { const handleClick: React.EventHandler<React.MouseEvent> = (e) => {
@ -45,6 +47,7 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
<Icon <Icon
src={icon} src={icon}
count={count} count={count}
countMax={countMax}
className={classNames('h-5 w-5', { className={classNames('h-5 w-5', {
'text-gray-600 dark:text-gray-500 group-hover:text-primary-500 dark:group-hover:text-primary-400': !isActive, 'text-gray-600 dark:text-gray-500 group-hover:text-primary-500 dark:group-hover:text-primary-400': !isActive,
'text-primary-500 dark:text-primary-400': isActive, 'text-primary-500 dark:text-primary-400': isActive,

View File

@ -2,6 +2,7 @@ import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import DropdownMenu from 'soapbox/containers/dropdown-menu-container'; import DropdownMenu from 'soapbox/containers/dropdown-menu-container';
import { useStatContext } from 'soapbox/contexts/stat-context';
import ComposeButton from 'soapbox/features/ui/components/compose-button'; import ComposeButton from 'soapbox/features/ui/components/compose-button';
import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks'; import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
@ -20,12 +21,12 @@ const messages = defineMessages({
/** Desktop sidebar with links to different views in the app. */ /** Desktop sidebar with links to different views in the app. */
const SidebarNavigation = () => { const SidebarNavigation = () => {
const intl = useIntl(); const intl = useIntl();
const { unreadChatsCount } = useStatContext();
const features = useFeatures(); const features = useFeatures();
const settings = useSettings(); const settings = useSettings();
const account = useOwnAccount(); const account = useOwnAccount();
const notificationCount = useAppSelector((state) => state.notifications.unread); const notificationCount = useAppSelector((state) => state.notifications.unread);
const chatsCount = useAppSelector((state) => state.chats.items.reduce((acc, curr) => acc + Math.min(curr.unread || 0, 1), 0));
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
@ -87,8 +88,9 @@ const SidebarNavigation = () => {
<SidebarNavigationLink <SidebarNavigationLink
to='/chats' to='/chats'
icon={require('@tabler/icons/messages.svg')} icon={require('@tabler/icons/messages.svg')}
count={chatsCount} count={unreadChatsCount}
text={<FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' />} countMax={9}
text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />}
/> />
); );
} }

View File

@ -241,7 +241,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
secondary: intl.formatMessage(messages.blockAndReport), secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => { onSecondary: () => {
dispatch(blockAccount(account.id)); dispatch(blockAccount(account.id));
dispatch(initReport(account, status)); dispatch(initReport(account, { status }));
}, },
})); }));
}; };
@ -258,7 +258,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}; };
const handleReport: React.EventHandler<React.MouseEvent> = (e) => { const handleReport: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(initReport(status.account as Account, status)); dispatch(initReport(status.account as Account, { status }));
}; };
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => { const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {

View File

@ -7,6 +7,7 @@ import { Icon, Text } from 'soapbox/components/ui';
interface IThumbNavigationLink { interface IThumbNavigationLink {
count?: number, count?: number,
countMax?: number,
src: string, src: string,
text: string | React.ReactElement, text: string | React.ReactElement,
to: string, to: string,
@ -14,7 +15,7 @@ interface IThumbNavigationLink {
paths?: Array<string>, paths?: Array<string>,
} }
const ThumbNavigationLink: React.FC<IThumbNavigationLink> = ({ count, src, text, to, exact, paths }): JSX.Element => { const ThumbNavigationLink: React.FC<IThumbNavigationLink> = ({ count, countMax, src, text, to, exact, paths }): JSX.Element => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const isActive = (): boolean => { const isActive = (): boolean => {
@ -38,6 +39,7 @@ const ThumbNavigationLink: React.FC<IThumbNavigationLink> = ({ count, src, text,
'text-primary-500': active, 'text-primary-500': active,
})} })}
count={count} count={count}
countMax={countMax}
/> />
) : ( ) : (
<Icon <Icon

View File

@ -2,12 +2,14 @@ import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ThumbNavigationLink from 'soapbox/components/thumb-navigation-link'; import ThumbNavigationLink from 'soapbox/components/thumb-navigation-link';
import { useStatContext } from 'soapbox/contexts/stat-context';
import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
const ThumbNavigation: React.FC = (): JSX.Element => { const ThumbNavigation: React.FC = (): JSX.Element => {
const account = useOwnAccount(); const account = useOwnAccount();
const { unreadChatsCount } = useStatContext();
const notificationCount = useAppSelector((state) => state.notifications.unread); const notificationCount = useAppSelector((state) => state.notifications.unread);
const chatsCount = useAppSelector((state) => state.chats.items.reduce((acc, curr) => acc + Math.min(curr.unread || 0, 1), 0));
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
const features = useFeatures(); const features = useFeatures();
@ -20,7 +22,8 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />} text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />}
to='/chats' to='/chats'
exact exact
count={chatsCount} count={unreadChatsCount}
countMax={9}
/> />
); );
} }

View File

@ -0,0 +1,31 @@
:root {
--reach-combobox: 1;
}
[data-reach-combobox-popover] {
@apply rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 z-[100];
}
[data-reach-combobox-list] {
@apply list-none m-0 py-1 px-0 select-none;
}
[data-reach-combobox-option] {
@apply block px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 cursor-pointer;
}
[data-reach-combobox-option][aria-selected="true"] {
@apply bg-gray-100 dark:bg-gray-800;
}
[data-reach-combobox-option]:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
[data-reach-combobox-option][aria-selected="true"]:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
[data-suggested-value] {
@apply font-bold;
}

View File

@ -0,0 +1,10 @@
import './combobox.css';
export {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption,
ComboboxOptionText,
} from '@reach/combobox';

View File

@ -5,13 +5,15 @@ import { shortNumberFormat } from 'soapbox/utils/numbers';
interface ICounter { interface ICounter {
/** Number this counter should display. */ /** Number this counter should display. */
count: number, count: number,
/** Optional max number (ie: N+) */
countMax?: number
} }
/** A simple counter for notifications, etc. */ /** A simple counter for notifications, etc. */
const Counter: React.FC<ICounter> = ({ count }) => { const Counter: React.FC<ICounter> = ({ count, countMax }) => {
return ( return (
<span className='block px-1.5 py-0.5 bg-secondary-500 text-xs text-white rounded-full ring-2 ring-white dark:ring-gray-800'> <span className='h-5 min-w-[20px] max-w-[26px] flex items-center justify-center bg-secondary-500 text-xs font-medium text-white rounded-full ring-2 ring-white dark:ring-gray-800'>
{shortNumberFormat(count)} {shortNumberFormat(count, countMax)}
</span> </span>
); );
}; };

View File

@ -18,7 +18,7 @@ const Divider = ({ text, textSize = 'md' }: IDivider) => (
{text && ( {text && (
<div className='relative flex justify-center'> <div className='relative flex justify-center'>
<span className='px-2 bg-white dark:bg-gray-900 text-gray-400' data-testid='divider-text'> <span className='px-2 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-600' data-testid='divider-text'>
<Text size={textSize} tag='span' theme='inherit'>{text}</Text> <Text size={textSize} tag='span' theme='inherit'>{text}</Text>
</span> </span>
</div> </div>

View File

@ -14,6 +14,7 @@ const alignItemsOptions = {
bottom: 'items-end', bottom: 'items-end',
center: 'items-center', center: 'items-center',
start: 'items-start', start: 'items-start',
stretch: 'items-stretch',
}; };
const spaces = { const spaces = {

View File

@ -9,6 +9,8 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
className?: string, className?: string,
/** Number to display a counter over the icon. */ /** Number to display a counter over the icon. */
count?: number, count?: number,
/** Optional max to cap count (ie: N+) */
countMax?: number,
/** Tooltip text for the icon. */ /** Tooltip text for the icon. */
alt?: string, alt?: string,
/** URL to the svg icon. */ /** URL to the svg icon. */
@ -18,11 +20,11 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
} }
/** Renders and SVG icon with optional counter. */ /** Renders and SVG icon with optional counter. */
const Icon: React.FC<IIcon> = ({ src, alt, count, size, ...filteredProps }): JSX.Element => ( const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => (
<div className='relative' data-testid='icon'> <div className='flex flex-col flex-shrink-0 relative' data-testid='icon'>
{count ? ( {count ? (
<span className='absolute -top-2 -right-3'> <span className='absolute -top-2 -right-3 min-w-[20px] h-5 flex-shrink-0 whitespace-nowrap flex items-center justify-center break-words'>
<Counter count={count} /> <Counter count={count} countMax={countMax} />
</span> </span>
) : null} ) : null}

View File

@ -5,6 +5,14 @@ export { default as Button } from './button/button';
export { Card, CardBody, CardHeader, CardTitle } from './card/card'; export { Card, CardBody, CardHeader, CardTitle } from './card/card';
export { default as Checkbox } from './checkbox/checkbox'; export { default as Checkbox } from './checkbox/checkbox';
export { Column, ColumnHeader } from './column/column'; export { Column, ColumnHeader } from './column/column';
export {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption,
ComboboxOptionText,
} from './combobox/combobox';
export { default as Counter } from './counter/counter'; export { default as Counter } from './counter/counter';
export { default as Datepicker } from './datepicker/datepicker'; export { default as Datepicker } from './datepicker/datepicker';
export { default as Divider } from './divider/divider'; export { default as Divider } from './divider/divider';

View File

@ -12,7 +12,7 @@ const messages = defineMessages({
}); });
/** Possible theme names for an Input. */ /** Possible theme names for an Input. */
type InputThemes = 'normal' | 'search' | 'transparent'; type InputThemes = 'normal' | 'search'
interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern' | 'onKeyDown' | 'onKeyUp' | 'onFocus' | 'style' | 'id'> { interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxLength' | 'onChange' | 'onBlur' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required' | 'disabled' | 'onClick' | 'readOnly' | 'min' | 'pattern' | 'onKeyDown' | 'onKeyUp' | 'onFocus' | 'style' | 'id'> {
/** Put the cursor into the input on mount. */ /** Put the cursor into the input on mount. */
@ -61,9 +61,11 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
return ( return (
<div <div
className={ className={
classNames('mt-1 relative shadow-sm', outerClassName, { classNames('relative', {
'rounded-md': theme !== 'search', 'rounded-md': theme !== 'search',
'rounded-full': theme === 'search', 'rounded-full': theme === 'search',
'mt-1': !String(outerClassName).includes('mt-'),
[String(outerClassName)]: typeof outerClassName !== 'undefined',
}) })
} }
> >
@ -83,12 +85,11 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
{...filteredProps} {...filteredProps}
type={revealed ? 'text' : type} type={revealed ? 'text' : type}
ref={ref} ref={ref}
className={classNames({ className={classNames('text-base placeholder:text-gray-600 dark:placeholder:text-gray-600', {
'text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': 'text-gray-900 dark:text-gray-100 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
['normal', 'search'].includes(theme), ['normal', 'search'].includes(theme),
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal', 'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search', 'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
'bg-transparent border-none': theme === 'transparent',
'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append, 'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append,
'text-red-600 border-red-600': hasError, 'text-red-600 border-red-600': hasError,
'pl-8': typeof icon !== 'undefined', 'pl-8': typeof icon !== 'undefined',

View File

@ -1,7 +1,5 @@
[data-reach-menu-popover] { [data-reach-menu-popover] {
@apply ltr:origin-top-right ltr:right-0 rtl:origin-top-left rtl:left-0 absolute mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 focus:outline-none; @apply origin-top-right rtl:origin-top-left absolute mt-2 rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 focus:outline-none z-[1003];
z-index: 1003;
} }
[data-reach-menu-button] { [data-reach-menu-button] {

View File

@ -5,28 +5,36 @@ import {
MenuItems, MenuItems,
MenuPopover, MenuPopover,
MenuLink, MenuLink,
MenuPopoverProps, MenuListProps,
} from '@reach/menu-button'; } from '@reach/menu-button';
import { positionDefault, positionRight } from '@reach/popover'; import { positionDefault, positionRight } from '@reach/popover';
import classNames from 'clsx';
import React from 'react'; import React from 'react';
import './menu.css'; import './menu.css';
interface IMenuList extends Omit<MenuPopoverProps, 'position'> { interface IMenuList extends Omit<MenuListProps, 'position'> {
/** Position of the dropdown menu. */ /** Position of the dropdown menu. */
position?: 'left' | 'right' position?: 'left' | 'right'
className?: string
} }
/** Renders children as a dropdown menu. */ /** Renders children as a dropdown menu. */
const MenuList: React.FC<IMenuList> = (props) => ( const MenuList: React.FC<IMenuList> = (props) => {
<MenuPopover position={props.position === 'left' ? positionDefault : positionRight}> const { position, className, ...filteredProps } = props;
<MenuItems
onKeyDown={(event) => event.nativeEvent.stopImmediatePropagation()} return (
className='py-1 bg-white dark:bg-primary-900 rounded-lg shadow-menu' <MenuPopover position={props.position === 'left' ? positionDefault : positionRight}>
{...props} <MenuItems
/> onKeyDown={(event) => event.nativeEvent.stopImmediatePropagation()}
</MenuPopover> className={
); classNames(className, 'py-1 bg-white dark:bg-primary-900 rounded-lg shadow-menu')
}
{...filteredProps}
/>
</MenuPopover>
);
};
/** Divides menu items. */ /** Divides menu items. */
const MenuDivider = () => <hr />; const MenuDivider = () => <hr />;

View File

@ -3,6 +3,7 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import Button from '../button/button'; import Button from '../button/button';
import { ButtonThemes } from '../button/useButtonStyles';
import HStack from '../hstack/hstack'; import HStack from '../hstack/hstack';
import IconButton from '../icon-button/icon-button'; import IconButton from '../icon-button/icon-button';
@ -38,7 +39,7 @@ interface IModal {
/** Confirmation button text. */ /** Confirmation button text. */
confirmationText?: React.ReactNode, confirmationText?: React.ReactNode,
/** Confirmation button theme. */ /** Confirmation button theme. */
confirmationTheme?: 'danger', confirmationTheme?: ButtonThemes,
/** Callback when the modal is closed. */ /** Callback when the modal is closed. */
onClose?: () => void, onClose?: () => void,
/** Callback when the secondary action is chosen. */ /** Callback when the secondary action is chosen. */

View File

@ -1,9 +1,15 @@
import classNames from 'clsx'; import classNames from 'clsx';
import React from 'react'; import React, { useState } from 'react';
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> { interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
/** Put the cursor into the input on mount. */ /** Put the cursor into the input on mount. */
autoFocus?: boolean, autoFocus?: boolean,
/** Allows the textarea height to grow while typing */
autoGrow?: boolean,
/** Used with "autoGrow". Sets a max number of rows. */
maxRows?: number,
/** Used with "autoGrow". Sets a min number of rows. */
minRows?: number,
/** The initial text in the input. */ /** The initial text in the input. */
defaultValue?: string, defaultValue?: string,
/** Internal input name. */ /** Internal input name. */
@ -18,24 +24,64 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
autoComplete?: string, autoComplete?: string,
/** Whether to display the textarea in red. */ /** Whether to display the textarea in red. */
hasError?: boolean, hasError?: boolean,
/** Whether or not you can resize the teztarea */
isResizeable?: boolean,
} }
/** Textarea with custom styles. */ /** Textarea with custom styles. */
const Textarea = React.forwardRef( const Textarea = React.forwardRef(({
({ isCodeEditor = false, hasError = false, ...props }: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => { isCodeEditor = false,
return ( hasError = false,
<textarea isResizeable = true,
{...props} onChange,
ref={ref} autoGrow = false,
className={classNames({ maxRows = 10,
'bg-white dark:bg-transparent shadow-sm block w-full sm:text-sm rounded-md text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': minRows = 1,
true, ...props
'font-mono': isCodeEditor, }: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
'text-red-600 border-red-600': hasError, const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
})}
/> const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
); if (autoGrow) {
}, const textareaLineHeight = 20;
const previousRows = event.target.rows;
event.target.rows = minRows;
const currentRows = ~~(event.target.scrollHeight / textareaLineHeight);
if (currentRows === previousRows) {
event.target.rows = currentRows;
}
if (currentRows >= maxRows) {
event.target.rows = maxRows;
event.target.scrollTop = event.target.scrollHeight;
}
setRows(currentRows < maxRows ? currentRows : maxRows);
}
if (onChange) {
onChange(event);
}
};
return (
<textarea
{...props}
ref={ref}
rows={rows}
onChange={handleChange}
className={classNames({
'bg-white dark:bg-transparent shadow-sm block w-full sm:text-sm rounded-md text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500':
true,
'font-mono': isCodeEditor,
'text-red-600 border-red-600': hasError,
'resize-none': !isResizeable,
})}
/>
);
},
); );
export default Textarea; export default Textarea;

View File

@ -0,0 +1,213 @@
import classNames from 'clsx';
import { List as ImmutableList } from 'immutable';
import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { openModal } from 'soapbox/actions/modals';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
import IconButton from 'soapbox/components/icon-button';
import Motion from 'soapbox/features/ui/util/optional-motion';
import { useAppDispatch } from 'soapbox/hooks';
import { Attachment } from 'soapbox/types/entities';
const bookIcon = require('@tabler/icons/book.svg');
const fileCodeIcon = require('@tabler/icons/file-code.svg');
const fileSpreadsheetIcon = require('@tabler/icons/file-spreadsheet.svg');
const fileTextIcon = require('@tabler/icons/file-text.svg');
const fileZipIcon = require('@tabler/icons/file-zip.svg');
const defaultIcon = require('@tabler/icons/paperclip.svg');
const presentationIcon = require('@tabler/icons/presentation.svg');
export const MIMETYPE_ICONS: Record<string, string> = {
'application/x-freearc': fileZipIcon,
'application/x-bzip': fileZipIcon,
'application/x-bzip2': fileZipIcon,
'application/gzip': fileZipIcon,
'application/vnd.rar': fileZipIcon,
'application/x-tar': fileZipIcon,
'application/zip': fileZipIcon,
'application/x-7z-compressed': fileZipIcon,
'application/x-csh': fileCodeIcon,
'application/html': fileCodeIcon,
'text/javascript': fileCodeIcon,
'application/json': fileCodeIcon,
'application/ld+json': fileCodeIcon,
'application/x-httpd-php': fileCodeIcon,
'application/x-sh': fileCodeIcon,
'application/xhtml+xml': fileCodeIcon,
'application/xml': fileCodeIcon,
'application/epub+zip': bookIcon,
'application/vnd.oasis.opendocument.spreadsheet': fileSpreadsheetIcon,
'application/vnd.ms-excel': fileSpreadsheetIcon,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': fileSpreadsheetIcon,
'application/pdf': fileTextIcon,
'application/vnd.oasis.opendocument.presentation': presentationIcon,
'application/vnd.ms-powerpoint': presentationIcon,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': presentationIcon,
'text/plain': fileTextIcon,
'application/rtf': fileTextIcon,
'application/msword': fileTextIcon,
'application/x-abiword': fileTextIcon,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': fileTextIcon,
'application/vnd.oasis.opendocument.text': fileTextIcon,
};
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
});
interface IUpload {
media: Attachment,
onSubmit?(): void,
onDelete?(): void,
onDescriptionChange?(description: string): void,
descriptionLimit?: number,
withPreview?: boolean,
}
const Upload: React.FC<IUpload> = ({
media,
onSubmit,
onDelete,
onDescriptionChange,
descriptionLimit,
withPreview = true,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [dirtyDescription, setDirtyDescription] = useState<string | null>(null);
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleInputBlur();
onSubmit();
}
};
const handleUndoClick: React.MouseEventHandler = e => {
if (onDelete) {
e.stopPropagation();
onDelete();
}
};
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
setDirtyDescription(e.target.value);
};
const handleMouseEnter = () => {
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleInputFocus = () => {
setFocused(true);
};
const handleClick = () => {
setFocused(true);
};
const handleInputBlur = () => {
setFocused(false);
setDirtyDescription(null);
if (dirtyDescription !== null && onDescriptionChange) {
onDescriptionChange(dirtyDescription);
}
};
const handleOpenModal = () => {
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 }));
};
const active = hovered || focused;
const description = dirtyDescription || (dirtyDescription !== '' && media.description) || '';
const focusX = media.meta.getIn(['focus', 'x']) as number | undefined;
const focusY = media.meta.getIn(['focus', 'y']) as number | undefined;
const x = focusX ? ((focusX / 2) + .5) * 100 : undefined;
const y = focusY ? ((focusY / -2) + .5) * 100 : undefined;
const mediaType = media.type;
const mimeType = media.pleroma.get('mime_type') as string | undefined;
const uploadIcon = mediaType === 'unknown' && (
<Icon
className='h-16 w-16 mx-auto my-12 text-gray-800 dark:text-gray-200'
src={MIMETYPE_ICONS[mimeType || ''] || defaultIcon}
/>
);
return (
<div className='compose-form__upload' tabIndex={0} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'>
<Blurhash hash={media.blurhash} className='media-gallery__preview' />
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<div
className={classNames('compose-form__upload-thumbnail', mediaType)}
style={{
transform: `scale(${scale})`,
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }}
>
<div className={classNames('compose-form__upload__actions', { active })}>
{onDelete && (
<IconButton
onClick={handleUndoClick}
src={require('@tabler/icons/x.svg')}
text={<FormattedMessage id='upload_form.undo' defaultMessage='Delete' />}
/>
)}
{/* Only display the "Preview" button for a valid attachment with a URL */}
{(withPreview && mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton
onClick={handleOpenModal}
src={require('@tabler/icons/zoom-in.svg')}
text={<FormattedMessage id='upload_form.preview' defaultMessage='Preview' />}
/>
)}
</div>
{onDescriptionChange && (
<div className={classNames('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<textarea
placeholder={intl.formatMessage(messages.description)}
value={description}
maxLength={descriptionLimit}
onFocus={handleInputFocus}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
/>
</label>
</div>
)}
<div className='compose-form__upload-preview'>
{mediaType === 'video' && (
<video autoPlay playsInline muted loop>
<source src={media.preview_url} />
</video>
)}
{uploadIcon}
</div>
</div>
)}
</Motion>
</div>
);
};
export default Upload;

View File

@ -17,6 +17,7 @@ import * as BuildConfig from 'soapbox/build-config';
import GdprBanner from 'soapbox/components/gdpr-banner'; import GdprBanner from 'soapbox/components/gdpr-banner';
import Helmet from 'soapbox/components/helmet'; import Helmet from 'soapbox/components/helmet';
import LoadingScreen from 'soapbox/components/loading-screen'; import LoadingScreen from 'soapbox/components/loading-screen';
import { StatProvider } from 'soapbox/contexts/stat-context';
import AuthLayout from 'soapbox/features/auth-layout'; import AuthLayout from 'soapbox/features/auth-layout';
import EmbeddedStatus from 'soapbox/features/embedded-status'; import EmbeddedStatus from 'soapbox/features/embedded-status';
import PublicLayout from 'soapbox/features/public-layout'; import PublicLayout from 'soapbox/features/public-layout';
@ -85,6 +86,7 @@ const loadInitial = () => {
/** Highest level node with the Redux store. */ /** Highest level node with the Redux store. */
const SoapboxMount = () => { const SoapboxMount = () => {
useCachedLocationHandler(); useCachedLocationHandler();
const me = useAppSelector(state => state.me); const me = useAppSelector(state => state.me);
const instance = useInstance(); const instance = useInstance();
const account = useOwnAccount(); const account = useOwnAccount();
@ -295,11 +297,13 @@ const Soapbox: React.FC = () => {
return ( return (
<Provider store={store}> <Provider store={store}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<SoapboxHead> <StatProvider>
<SoapboxLoad> <SoapboxHead>
<SoapboxMount /> <SoapboxLoad>
</SoapboxLoad> <SoapboxMount />
</SoapboxHead> </SoapboxLoad>
</SoapboxHead>
</StatProvider>
</QueryClientProvider> </QueryClientProvider>
</Provider> </Provider>
); );

View File

@ -0,0 +1,88 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory, useParams } from 'react-router-dom';
import { toggleMainWindow } from 'soapbox/actions/chats';
import { useOwnAccount, useSettings } from 'soapbox/hooks';
import { IChat, useChat } from 'soapbox/queries/chats';
type WindowState = 'open' | 'minimized';
const ChatContext = createContext<any>({
isOpen: false,
needsAcceptance: false,
});
enum ChatWidgetScreens {
INBOX = 'INBOX',
SEARCH = 'SEARCH',
CHAT = 'CHAT',
CHAT_SETTINGS = 'CHAT_SETTINGS'
}
const ChatProvider: React.FC = ({ children }) => {
const history = useHistory();
const dispatch = useDispatch();
const settings = useSettings();
const account = useOwnAccount();
const path = history.location.pathname;
const isUsingMainChatPage = Boolean(path.match(/^\/chats/));
const { chatId } = useParams<{ chatId: string }>();
const [screen, setScreen] = useState<ChatWidgetScreens>(ChatWidgetScreens.INBOX);
const [currentChatId, setCurrentChatId] = useState<null | string>(chatId);
const { data: chat } = useChat(currentChatId as string);
const mainWindowState = settings.getIn(['chats', 'mainWindow']) as WindowState;
const needsAcceptance = !chat?.accepted && chat?.created_by_account !== account?.id;
const isOpen = mainWindowState === 'open';
const changeScreen = (screen: ChatWidgetScreens, currentChatId?: string | null) => {
setCurrentChatId(currentChatId || null);
setScreen(screen);
};
const toggleChatPane = () => dispatch(toggleMainWindow());
const value = useMemo(() => ({
chat,
needsAcceptance,
isOpen,
isUsingMainChatPage,
toggleChatPane,
screen,
changeScreen,
currentChatId,
}), [chat, currentChatId, needsAcceptance, isUsingMainChatPage, isOpen, screen, changeScreen]);
useEffect(() => {
if (chatId) {
setCurrentChatId(chatId);
} else {
setCurrentChatId(null);
}
}, [chatId]);
return (
<ChatContext.Provider value={value}>
{children}
</ChatContext.Provider>
);
};
interface IChatContext {
chat: IChat | null
isOpen: boolean
isUsingMainChatPage?: boolean
needsAcceptance: boolean
toggleChatPane(): void
screen: ChatWidgetScreens
currentChatId: string | null
changeScreen(screen: ChatWidgetScreens, currentChatId?: string | null): void
}
const useChatContext = (): IChatContext => useContext(ChatContext);
export { ChatContext, ChatProvider, useChatContext, ChatWidgetScreens };

View File

@ -0,0 +1,29 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
type IStatContext = {
unreadChatsCount: number,
setUnreadChatsCount: React.Dispatch<React.SetStateAction<number>>
}
const StatContext = createContext<any>({
unreadChatsCount: 0,
});
const StatProvider: React.FC = ({ children }) => {
const [unreadChatsCount, setUnreadChatsCount] = useState<number>(0);
const value = useMemo(() => ({
unreadChatsCount,
setUnreadChatsCount,
}), [unreadChatsCount]);
return (
<StatContext.Provider value={value}>
{children}
</StatContext.Provider>
);
};
const useStatContext = (): IStatContext => useContext(StatContext);
export { StatProvider, useStatContext, IStatContext };

View File

@ -1,12 +1,13 @@
'use strict'; 'use strict';
import { useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import React from 'react'; import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts'; import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts';
import { launchChat } from 'soapbox/actions/chats';
import { mentionCompose, directCompose } from 'soapbox/actions/compose'; import { mentionCompose, directCompose } from 'soapbox/actions/compose';
import { blockDomain, unblockDomain } from 'soapbox/actions/domain-blocks'; import { blockDomain, unblockDomain } from 'soapbox/actions/domain-blocks';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
@ -24,6 +25,8 @@ import ActionButton from 'soapbox/features/ui/components/action-button';
import SubscriptionButton from 'soapbox/features/ui/components/subscription-button'; import SubscriptionButton from 'soapbox/features/ui/components/subscription-button';
import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
import { normalizeAttachment } from 'soapbox/normalizers'; import { normalizeAttachment } from 'soapbox/normalizers';
import { ChatKeys, useChats } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import { Account } from 'soapbox/types/entities'; import { Account } from 'soapbox/types/entities';
import { isRemote } from 'soapbox/utils/accounts'; import { isRemote } from 'soapbox/utils/accounts';
@ -82,6 +85,21 @@ const Header: React.FC<IHeader> = ({ account }) => {
const features = useFeatures(); const features = useFeatures();
const ownAccount = useOwnAccount(); const ownAccount = useOwnAccount();
const { getOrCreateChatByAccountId } = useChats();
const createAndNavigateToChat = useMutation((accountId: string) => {
return getOrCreateChatByAccountId(accountId);
}, {
onError: (error: AxiosError) => {
const data = error.response?.data as any;
dispatch(snackbar.error(data?.error));
},
onSuccess: (response) => {
history.push(`/chats/${response.data.id}`);
queryClient.invalidateQueries(ChatKeys.chatSearch());
},
});
if (!account) { if (!account) {
return ( return (
<div className='-mt-4 -mx-4'> <div className='-mt-4 -mx-4'>
@ -141,11 +159,11 @@ const Header: React.FC<IHeader> = ({ account }) => {
if (account.relationship?.endorsed) { if (account.relationship?.endorsed) {
dispatch(unpinAccount(account.id)) dispatch(unpinAccount(account.id))
.then(() => dispatch(snackbar.success(intl.formatMessage(messages.userUnendorsed, { acct: account.acct })))) .then(() => dispatch(snackbar.success(intl.formatMessage(messages.userUnendorsed, { acct: account.acct }))))
.catch(() => {}); .catch(() => { });
} else { } else {
dispatch(pinAccount(account.id)) dispatch(pinAccount(account.id))
.then(() => dispatch(snackbar.success(intl.formatMessage(messages.userEndorsed, { acct: account.acct })))) .then(() => dispatch(snackbar.success(intl.formatMessage(messages.userEndorsed, { acct: account.acct }))))
.catch(() => {}); .catch(() => { });
} }
}; };
@ -185,10 +203,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
})); }));
}; };
const onChat = () => {
dispatch(launchChat(account.id, history));
};
const onModerate = () => { const onModerate = () => {
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id })); dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
}; };
@ -304,13 +318,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
icon: require('@tabler/icons/at.svg'), icon: require('@tabler/icons/at.svg'),
}); });
if (account.getIn(['pleroma', 'accepts_chat_messages']) === true) { if (features.privacyScopes) {
menu.push({
text: intl.formatMessage(messages.chat, { name: account.username }),
action: onChat,
icon: require('@tabler/icons/messages.svg'),
});
} else if (features.privacyScopes) {
menu.push({ menu.push({
text: intl.formatMessage(messages.direct, { name: account.username }), text: intl.formatMessage(messages.direct, { name: account.username }),
action: onDirect, action: onDirect,
@ -494,34 +502,43 @@ const Header: React.FC<IHeader> = ({ account }) => {
return info; return info;
}; };
// const renderMessageButton = () => { const renderMessageButton = () => {
// if (!ownAccount || !account || account.id === ownAccount?.id) { if (features.chatsWithFollowers) { // Truth Social
// return null; if (!ownAccount || !account || account.id === ownAccount?.id) {
// } return null;
}
// const canChat = account.getIn(['pleroma', 'accepts_chat_messages']) === true; const canChat = account.relationship?.followed_by;
if (!canChat) {
return null;
}
// if (canChat) { return (
// return ( <IconButton
// <IconButton src={require('@tabler/icons/messages.svg')}
// src={require('@tabler/icons/messages.svg')} onClick={() => createAndNavigateToChat.mutate(account.id)}
// onClick={onChat} title={intl.formatMessage(messages.chat, { name: account.username })}
// title={intl.formatMessage(messages.chat, { name: account.username })} theme='outlined'
// /> className='px-2'
// ); iconClassName='w-4 h-4'
// } else { disabled={createAndNavigateToChat.isLoading}
// return ( />
// <IconButton );
// src={require('@tabler/icons/mail.svg')} } else if (account.getIn(['pleroma', 'accepts_chat_messages']) === true) {
// onClick={onDirect} return (
// title={intl.formatMessage(messages.direct, { name: account.username })} <IconButton
// theme='outlined' src={require('@tabler/icons/messages.svg')}
// className='px-2' onClick={() => createAndNavigateToChat.mutate(account.id)}
// iconClassName='w-4 h-4' title={intl.formatMessage(messages.chat, { name: account.username })}
// /> theme='outlined'
// ); className='px-2'
// } iconClassName='w-4 h-4'
// }; />
);
} else {
return null;
}
};
const renderShareButton = () => { const renderShareButton = () => {
const canShare = 'share' in navigator; const canShare = 'share' in navigator;
@ -585,6 +602,8 @@ const Header: React.FC<IHeader> = ({ account }) => {
<div className='mt-6 flex justify-end w-full sm:pb-1'> <div className='mt-6 flex justify-end w-full sm:pb-1'>
<HStack space={2} className='mt-10'> <HStack space={2} className='mt-10'>
<SubscriptionButton account={account} /> <SubscriptionButton account={account} />
{renderMessageButton()}
{renderShareButton()}
{ownAccount && ( {ownAccount && (
<Menu> <Menu>
@ -597,7 +616,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
children={null} children={null}
/> />
<MenuList> <MenuList className='w-56'>
{menu.map((menuItem, idx) => { {menu.map((menuItem, idx) => {
if (typeof menuItem?.text === 'undefined') { if (typeof menuItem?.text === 'undefined') {
return <MenuDivider key={idx} />; return <MenuDivider key={idx} />;
@ -622,9 +641,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
</Menu> </Menu>
)} )}
{renderShareButton()}
{/* {renderMessageButton()} */}
<ActionButton account={account} /> <ActionButton account={account} />
</HStack> </HStack>
</div> </div>

View File

@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
import { Avatar, Card, HStack, Icon, IconButton, Stack, Text } from 'soapbox/components/ui'; import { Avatar, Card, HStack, Icon, IconButton, Stack, Text } from 'soapbox/components/ui';
import StatusCard from 'soapbox/features/status/components/card'; import StatusCard from 'soapbox/features/status/components/card';
import { useInstance } from 'soapbox/hooks'; import { useInstance } from 'soapbox/hooks';
import { AdKeys } from 'soapbox/queries/ads';
import type { Ad as AdEntity } from 'soapbox/types/soapbox'; import type { Ad as AdEntity } from 'soapbox/types/soapbox';
@ -31,7 +32,7 @@ const Ad: React.FC<IAd> = ({ ad }) => {
/** Invalidate query cache for ads. */ /** Invalidate query cache for ads. */
const bustCache = (): void => { const bustCache = (): void => {
queryClient.invalidateQueries(['ads']); queryClient.invalidateQueries(AdKeys.ads);
}; };
/** Toggle the info box on click. */ /** Toggle the info box on click. */
@ -106,7 +107,7 @@ const Ad: React.FC<IAd> = ({ ad }) => {
</Stack> </Stack>
</HStack> </HStack>
<StatusCard card={ad.card} onOpenMedia={() => {}} horizontal /> <StatusCard card={ad.card} onOpenMedia={() => { }} horizontal />
</Stack> </Stack>
</Card> </Card>

View File

@ -1,69 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import React, { useEffect, useRef } from 'react';
import { fetchChat, markChatRead } from 'soapbox/actions/chats';
import { Column } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetChat } from 'soapbox/selectors';
import { getAcct } from 'soapbox/utils/accounts';
import { displayFqn as getDisplayFqn } from 'soapbox/utils/state';
import ChatBox from './components/chat-box';
const getChat = makeGetChat();
interface IChatRoom {
params: {
chatId: string,
}
}
/** Fullscreen chat UI. */
const ChatRoom: React.FC<IChatRoom> = ({ params }) => {
const dispatch = useAppDispatch();
const displayFqn = useAppSelector(getDisplayFqn);
const inputElem = useRef<HTMLTextAreaElement | null>(null);
const chat = useAppSelector(state => {
const chat = state.chats.items.get(params.chatId, ImmutableMap()).toJS() as any;
return getChat(state, chat);
});
const focusInput = () => {
inputElem.current?.focus();
};
const handleInputRef = (el: HTMLTextAreaElement) => {
inputElem.current = el;
focusInput();
};
const markRead = () => {
if (!chat) return;
dispatch(markChatRead(chat.id));
};
useEffect(() => {
dispatch(fetchChat(params.chatId));
markRead();
}, [params.chatId]);
// If this component is loaded at all, we can instantly mark new messages as read.
useEffect(() => {
markRead();
}, [chat?.unread]);
if (!chat) return null;
return (
<Column label={`@${getAcct(chat.account as any, displayFqn)}`}>
<ChatBox
chatId={chat.id}
onSetInputRef={handleInputRef}
autosize
/>
</Column>
);
};
export default ChatRoom;

View File

@ -0,0 +1,68 @@
import React from 'react';
import { IChat } from 'soapbox/queries/chats';
import { render, screen } from '../../../../jest/test-helpers';
import ChatListItem from '../chat-list-item';
const chat: any = {
id: '1',
unread: 5,
created_by_account: '2',
last_message: {
account_id: '2',
chat_id: '1',
content: 'hello world',
created_at: '2022-09-09T16:02:26.186Z',
discarded_at: null,
id: '12332423234',
unread: true,
},
created_at: '2022-09-09T16:02:26.186Z',
updated_at: '2022-09-09T16:02:26.186Z',
accepted: true,
discarded_at: null,
account: {
acct: 'username',
display_name: 'johnnie',
},
};
describe('<ChatListItem />', () => {
it('renders correctly', () => {
render(<ChatListItem chat={chat as IChat} onClick={jest.fn()} />);
expect(screen.getByTestId('chat-list-item')).toBeInTheDocument();
expect(screen.getByTestId('chat-list-item')).toHaveTextContent(chat.account.display_name);
});
describe('last message content', () => {
it('renders the last message', () => {
render(<ChatListItem chat={chat as IChat} onClick={jest.fn()} />);
expect(screen.getByTestId('chat-last-message')).toBeInTheDocument();
});
it('does not render the last message', () => {
const changedChat = { ...chat, last_message: null };
render(<ChatListItem chat={changedChat as IChat} onClick={jest.fn()} />);
expect(screen.queryAllByTestId('chat-last-message')).toHaveLength(0);
});
describe('unread', () => {
it('renders the unread dot', () => {
render(<ChatListItem chat={chat as IChat} onClick={jest.fn()} />);
expect(screen.getByTestId('chat-unread-indicator')).toBeInTheDocument();
});
it('does not render the unread dot', () => {
const changedChat = { ...chat, last_message: { ...chat.last_message, unread: false } };
render(<ChatListItem chat={changedChat as IChat} onClick={jest.fn()} />);
expect(screen.queryAllByTestId('chat-unread-indicator')).toHaveLength(0);
});
});
});
});

View File

@ -0,0 +1,148 @@
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 { IAccount } from 'soapbox/queries/accounts';
import { __stub } from '../../../../api';
import { queryClient, render, rootState, screen, waitFor } from '../../../../jest/test-helpers';
import { IChat, IChatMessage } from '../../../../queries/chats';
import ChatMessageList from '../chat-message-list';
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,
created_at: '2020-06-10T02:05:06.000Z',
created_by_account: '2',
discarded_at: null,
id: '14',
last_message: null,
latest_read_message_by_account: [],
latest_read_message_created_at: null,
message_expiration: 1209600,
unread: 5,
};
const chatMessages: IChatMessage[] = [
{
account_id: '1',
chat_id: '14',
content: 'this is the first chat',
created_at: '2022-09-09T16:02:26.186Z',
id: '1',
unread: false,
pending: false,
},
{
account_id: '2',
chat_id: '14',
content: 'this is the second chat',
created_at: '2022-09-09T16:04:26.186Z',
id: '2',
unread: true,
pending: false,
},
];
// Mock scrollIntoView function.
window.HTMLElement.prototype.scrollIntoView = function () { };
Object.assign(navigator, {
clipboard: {
writeText: () => { },
},
});
const store = rootState
.set('me', '1')
.set('instance', normalizeInstance({ version: '3.4.1 (compatible; TruthSocial 1.0.0)' }));
const renderComponentWithChatContext = () => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<ChatContext.Provider value={{ chat }}>
<ChatMessageList chat={chat} />
</ChatContext.Provider>
</VirtuosoMockContext.Provider>,
undefined,
store,
);
beforeEach(() => {
queryClient.clear();
});
describe('<ChatMessageList />', () => {
describe('when the query is loading', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/chats/${chat.id}/messages`).reply(200, chatMessages, {
link: null,
});
});
});
it('displays the skeleton loader', async () => {
renderComponentWithChatContext();
expect(screen.queryAllByTestId('placeholder-chat-message')).toHaveLength(5);
await waitFor(() => {
expect(screen.getByTestId('chat-message-list-intro')).toBeInTheDocument();
expect(screen.queryAllByTestId('placeholder-chat-message')).toHaveLength(0);
});
});
});
describe('when the query is finished loading', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet(`/api/v1/pleroma/chats/${chat.id}/messages`).reply(200, chatMessages, {
link: null,
});
});
});
it('displays the intro', async () => {
renderComponentWithChatContext();
expect(screen.queryAllByTestId('chat-message-list-intro')).toHaveLength(0);
await waitFor(() => {
expect(screen.getByTestId('chat-message-list-intro')).toBeInTheDocument();
});
});
it('displays the messages', async () => {
renderComponentWithChatContext();
expect(screen.queryAllByTestId('chat-message')).toHaveLength(0);
await waitFor(() => {
expect(screen.queryAllByTestId('chat-message')).toHaveLength(chatMessages.length);
});
});
it('displays the correct menu options depending on the owner of the message', async () => {
renderComponentWithChatContext();
await waitFor(() => {
expect(screen.queryAllByTestId('chat-message-menu')).toHaveLength(2);
});
// my message
await userEvent.click(screen.queryAllByTestId('chat-message-menu')[0].querySelector('button') as any);
// other user message
await userEvent.click(screen.queryAllByTestId('chat-message-menu')[1].querySelector('button') as any);
});
});
});

View File

@ -0,0 +1,83 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import ChatPaneHeader from '../chat-widget/chat-pane-header';
describe('<ChatPaneHeader />', () => {
it('handles the onToggle prop', async () => {
const mockFn = jest.fn();
render(<ChatPaneHeader title='title' onToggle={mockFn} isOpen />);
await userEvent.click(screen.getByTestId('icon-button'));
expect(mockFn).toHaveBeenCalled();
});
describe('the "title" prop', () => {
describe('when it is a string', () => {
it('renders the title', () => {
const title = 'Messages';
render(<ChatPaneHeader title={title} onToggle={jest.fn()} isOpen />);
expect(screen.getByTestId('title')).toHaveTextContent(title);
});
});
describe('when it is a node', () => {
it('renders the title', () => {
const title = (
<div><p>hello world</p></div>
);
render(<ChatPaneHeader title={title} onToggle={jest.fn()} isOpen />);
expect(screen.getByTestId('title')).toHaveTextContent('hello world');
});
});
});
describe('the "unreadCount" prop', () => {
describe('when present', () => {
it('renders the unread count', () => {
const count = 14;
render(<ChatPaneHeader title='title' onToggle={jest.fn()} isOpen unreadCount={count} />);
expect(screen.getByTestId('unread-count')).toHaveTextContent(String(count));
});
});
describe('when 0', () => {
it('does not render the unread count', () => {
const count = 0;
render(<ChatPaneHeader title='title' onToggle={jest.fn()} isOpen unreadCount={count} />);
expect(screen.queryAllByTestId('unread-count')).toHaveLength(0);
});
});
describe('when unprovided', () => {
it('does not render the unread count', () => {
render(<ChatPaneHeader title='title' onToggle={jest.fn()} isOpen />);
expect(screen.queryAllByTestId('unread-count')).toHaveLength(0);
});
});
});
describe('secondaryAction prop', () => {
it('handles the secondaryAction callback', async () => {
const mockFn = jest.fn();
render(
<ChatPaneHeader
title='title'
onToggle={jest.fn()}
isOpen
secondaryAction={mockFn}
secondaryActionIcon='icon.svg'
/>,
);
await userEvent.click(screen.queryAllByTestId('icon-button')[0]);
expect(mockFn).toBeCalled();
});
});
});

View File

@ -0,0 +1,88 @@
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { normalizeAccount } from 'soapbox/normalizers';
import { render, rootState } from '../../../../jest/test-helpers';
import ChatWidget from '../chat-widget/chat-widget';
const id = '1';
const account = normalizeAccount({
id,
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
chats_onboarded: true,
});
const store = rootState
.set('me', id)
.set('accounts', ImmutableMap({
[id]: account,
}) as any);
describe('<ChatWidget />', () => {
describe('when on the /chats endpoint', () => {
it('hides the widget', async () => {
const App = () => (
<Switch>
<Route path='/chats' exact><span>Chats page <ChatWidget /></span></Route>
<Route path='/' exact><span data-testid='home'>Homepage <ChatWidget /></span></Route>
</Switch>
);
const screen = render(
<App />,
{},
store,
{ initialEntries: ['/chats'] },
);
expect(screen.queryAllByTestId('pane')).toHaveLength(0);
});
});
describe('when the user has not onboarded chats', () => {
it('hides the widget', async () => {
const accountWithoutChats = normalizeAccount({
id,
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
chats_onboarded: false,
});
const newStore = store.set('accounts', ImmutableMap({
[id]: accountWithoutChats,
}) as any);
const screen = render(
<ChatWidget />,
{},
newStore,
);
expect(screen.queryAllByTestId('pane')).toHaveLength(0);
});
});
describe('when the user is onboarded and the endpoint is not /chats', () => {
it('shows the widget', async () => {
const App = () => (
<Switch>
<Route path='/chats' exact><span>Chats page <ChatWidget /></span></Route>
<Route path='/' exact><span data-testid='home'>Homepage <ChatWidget /></span></Route>
</Switch>
);
const screen = render(
<App />,
{},
store,
{ initialEntries: ['/'] },
);
expect(screen.queryAllByTestId('pane')).toHaveLength(1);
});
});
});

View File

@ -1,41 +0,0 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import Toggle from 'react-toggle';
import { changeSetting, getSettings } from 'soapbox/actions/settings';
import { useAppSelector } from 'soapbox/hooks';
const messages = defineMessages({
switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' },
switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' },
});
const AudioToggle: React.FC = () => {
const dispatch = useDispatch();
const intl = useIntl();
const checked = useAppSelector(state => !!getSettings(state).getIn(['chats', 'sound']));
const handleToggleAudio = () => {
dispatch(changeSetting(['chats', 'sound'], !checked));
};
const id = 'chats-audio-toggle';
const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn);
return (
<div className='audio-toggle react-toggle--mini'>
<div className='setting-toggle' aria-label={label}>
<Toggle
id={id}
checked={checked}
onChange={handleToggleAudio}
// onKeyDown={this.onKeyDown}
/>
</div>
</div>
);
};
export default AudioToggle;

View File

@ -1,201 +0,0 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import React, { useRef, useState } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import {
sendChatMessage,
markChatRead,
} from 'soapbox/actions/chats';
import { uploadMedia } from 'soapbox/actions/media';
import IconButton from 'soapbox/components/icon-button';
import { Textarea } from 'soapbox/components/ui';
import UploadProgress from 'soapbox/components/upload-progress';
import UploadButton from 'soapbox/features/compose/components/upload-button';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { truncateFilename } from 'soapbox/utils/media';
import ChatMessageList from './chat-message-list';
const messages = defineMessages({
placeholder: { id: 'chat_box.input.placeholder', defaultMessage: 'Send a message…' },
send: { id: 'chat_box.actions.send', defaultMessage: 'Send' },
});
const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000));
interface IChatBox {
chatId: string,
onSetInputRef: (el: HTMLTextAreaElement) => void,
autosize?: boolean,
}
/**
* Chat UI with just the messages and textarea.
* Reused between floating desktop chats and fullscreen/mobile chats.
*/
const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef, autosize }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const chatMessageIds = useAppSelector(state => state.chat_message_lists.get(chatId, ImmutableOrderedSet<string>()));
const [content, setContent] = useState('');
const [attachment, setAttachment] = useState<any>(undefined);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
const inputElem = useRef<HTMLTextAreaElement | null>(null);
const clearState = () => {
setContent('');
setAttachment(undefined);
setIsUploading(false);
setUploadProgress(0);
setResetFileKey(fileKeyGen());
};
const getParams = () => {
return {
content,
media_id: attachment && attachment.id,
};
};
const canSubmit = () => {
const conds = [
content.length > 0,
attachment,
];
return conds.some(c => c);
};
const sendMessage = () => {
if (canSubmit() && !isUploading) {
const params = getParams();
dispatch(sendChatMessage(chatId, params));
clearState();
}
};
const insertLine = () => {
setContent(content + '\n');
};
const handleKeyDown: React.KeyboardEventHandler = (e) => {
markRead();
if (e.key === 'Enter' && e.shiftKey) {
insertLine();
e.preventDefault();
} else if (e.key === 'Enter') {
sendMessage();
e.preventDefault();
}
};
const handleContentChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
setContent(e.target.value);
};
const handlePaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
if (!canSubmit() && e.clipboardData && e.clipboardData.files.length === 1) {
handleFiles(e.clipboardData.files);
}
};
const markRead = () => {
dispatch(markChatRead(chatId));
};
const handleHover = () => {
markRead();
};
const setInputRef = (el: HTMLTextAreaElement) => {
inputElem.current = el;
onSetInputRef(el);
};
const handleRemoveFile = () => {
setAttachment(undefined);
setResetFileKey(fileKeyGen());
};
const onUploadProgress = (e: ProgressEvent) => {
const { loaded, total } = e;
setUploadProgress(loaded / total);
};
const handleFiles = (files: FileList) => {
setIsUploading(true);
const data = new FormData();
data.append('file', files[0]);
dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => {
setAttachment(response.data);
setIsUploading(false);
}).catch(() => {
setIsUploading(false);
});
};
const renderAttachment = () => {
if (!attachment) return null;
return (
<div className='chat-box__attachment'>
<div className='chat-box__filename'>
{truncateFilename(attachment.preview_url, 20)}
</div>
<div className='chat-box__remove-attachment'>
<IconButton
src={require('@tabler/icons/x.svg')}
onClick={handleRemoveFile}
/>
</div>
</div>
);
};
const renderActionButton = () => {
return canSubmit() ? (
<IconButton
src={require('@tabler/icons/send.svg')}
title={intl.formatMessage(messages.send)}
onClick={sendMessage}
/>
) : (
<UploadButton onSelectFile={handleFiles} resetFileKey={resetFileKey} />
);
};
if (!chatMessageIds) return null;
return (
<div className='chat-box' onMouseOver={handleHover}>
<ChatMessageList chatMessageIds={chatMessageIds} chatId={chatId} autosize />
{renderAttachment()}
{isUploading && (
<UploadProgress progress={uploadProgress * 100} />
)}
<div className='chat-box__actions'>
<div className='chat-box__send'>
{renderActionButton()}
</div>
<Textarea
rows={1}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={handleKeyDown}
onChange={handleContentChange}
onPaste={handlePaste}
value={content}
ref={setInputRef}
/>
</div>
</div>
);
};
export default ChatBox;

View File

@ -0,0 +1,239 @@
import React, { useState } from 'react';
import { defineMessages, IntlShape, useIntl } from 'react-intl';
import { unblockAccount } from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals';
import { Button, Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover, HStack, IconButton, Stack, Text, Textarea } from 'soapbox/components/ui';
import { useChatContext } from 'soapbox/contexts/chat-context';
import UploadButton from 'soapbox/features/compose/components/upload-button';
import { search as emojiSearch } from 'soapbox/features/emoji/emoji-mart-search-light';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
const messages = defineMessages({
placeholder: { id: 'chat.input.placeholder', defaultMessage: 'Type a message' },
send: { id: 'chat.actions.send', defaultMessage: 'Send' },
retry: { id: 'chat.retry', defaultMessage: 'Retry?' },
blocked: { id: 'chat_message_list.blocked', defaultMessage: 'You blocked this user' },
unblock: { id: 'chat_composer.unblock', defaultMessage: 'Unblock' },
unblockMessage: { id: 'chat_settings.unblock.message', defaultMessage: 'Unblocking will allow this profile to direct message you and view your content.' },
unblockHeading: { id: 'chat_settings.unblock.heading', defaultMessage: 'Unblock @{acct}' },
unblockConfirm: { id: 'chat_settings.unblock.confirm', defaultMessage: 'Unblock' },
});
const initialSuggestionState = {
list: [],
tokenStart: 0,
token: '',
};
interface Suggestion {
list: { native: string, colons: string }[],
tokenStart: number,
token: string,
}
interface IChatComposer extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onKeyDown' | 'onChange' | 'onPaste' | 'disabled'> {
value: string
onSubmit: () => void
errorMessage: string | undefined
onSelectFile: (files: FileList, intl: IntlShape) => void
resetFileKey: number | null
hasAttachment?: boolean
}
/** Textarea input for chats. */
const ChatComposer = React.forwardRef<HTMLTextAreaElement | null, IChatComposer>(({
onKeyDown,
onChange,
value,
onSubmit,
errorMessage = false,
disabled = false,
onSelectFile,
resetFileKey,
onPaste,
hasAttachment,
}, ref) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const features = useFeatures();
const { chat } = useChatContext();
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocked_by']));
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
const maxCharacterCount = useAppSelector((state) => state.instance.getIn(['configuration', 'chats', 'max_characters']) as number);
const [suggestions, setSuggestions] = useState<Suggestion>(initialSuggestionState);
const isSuggestionsAvailable = suggestions.list.length > 0;
const isOverCharacterLimit = maxCharacterCount && value?.length > maxCharacterCount;
const isSubmitDisabled = disabled || isOverCharacterLimit || (value.length === 0 && !hasAttachment);
const overLimitText = maxCharacterCount ? maxCharacterCount - value?.length : '';
const renderSuggestionValue = (emoji: any) => {
return `${(value).slice(0, suggestions.tokenStart)}${emoji.native} ${(value as string).slice(suggestions.tokenStart + suggestions.token.length)}`;
};
const onSelectComboboxOption = (selection: string) => {
const event = { target: { value: selection } } as React.ChangeEvent<HTMLTextAreaElement>;
if (onChange) {
onChange(event);
setSuggestions(initialSuggestionState);
}
};
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const [tokenStart, token] = textAtCursorMatchesToken(
event.target.value,
event.target.selectionStart,
[':'],
);
if (token && tokenStart) {
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any);
setSuggestions({
list: results,
token,
tokenStart: tokenStart - 1,
});
} else {
setSuggestions(initialSuggestionState);
}
if (onChange) {
onChange(event);
}
};
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
if (event.key === 'Enter' && !event.shiftKey && isSuggestionsAvailable) {
return;
}
if (onKeyDown) {
onKeyDown(event);
}
};
const handleUnblockUser = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.unblockHeading, { acct: chat?.account.acct }),
message: intl.formatMessage(messages.unblockMessage),
confirm: intl.formatMessage(messages.unblockConfirm),
confirmationTheme: 'primary',
onConfirm: () => dispatch(unblockAccount(chat?.account.id as string)),
}));
};
if (isBlocking) {
return (
<div className='mt-auto p-6 shadow-3xl dark:border-t-2 dark:border-solid dark:border-gray-800'>
<Stack space={3} alignItems='center'>
<Text align='center' theme='muted'>
{intl.formatMessage(messages.blocked)}
</Text>
<Button theme='secondary' onClick={handleUnblockUser}>
{intl.formatMessage(messages.unblock)}
</Button>
</Stack>
</div>
);
}
if (isBlocked) {
return null;
}
return (
<div className='mt-auto px-4 shadow-3xl'>
<HStack alignItems='stretch' justifyContent='between' space={4}>
{features.chatsMedia && (
<Stack justifyContent='end' alignItems='center' className='w-10 mb-1.5'>
<UploadButton
onSelectFile={onSelectFile}
resetFileKey={resetFileKey}
iconClassName='w-5 h-5'
className='text-primary-500'
/>
</Stack>
)}
<Stack grow>
<Combobox
aria-labelledby='demo'
onSelect={onSelectComboboxOption}
>
<ComboboxInput
as={Textarea}
autoFocus
ref={ref}
placeholder={intl.formatMessage(messages.placeholder)}
onKeyDown={handleKeyDown}
value={value}
onChange={handleChange}
onPaste={onPaste}
isResizeable={false}
autoGrow
maxRows={5}
disabled={disabled}
/>
{isSuggestionsAvailable ? (
<ComboboxPopover>
<ComboboxList>
{suggestions.list.map((emojiSuggestion) => (
<ComboboxOption
key={emojiSuggestion.colons}
value={renderSuggestionValue(emojiSuggestion)}
>
<span>{emojiSuggestion.native}</span>
<span className='ml-1'>
{emojiSuggestion.colons}
</span>
</ComboboxOption>
))}
</ComboboxList>
</ComboboxPopover>
) : null}
</Combobox>
</Stack>
<Stack space={2} justifyContent='end' alignItems='center' className='w-10 mb-1.5'>
{isOverCharacterLimit ? (
<Text size='sm' theme='danger'>{overLimitText}</Text>
) : null}
<IconButton
src={require('@tabler/icons/send.svg')}
iconClassName='w-5 h-5'
className='text-primary-500'
disabled={isSubmitDisabled}
onClick={onSubmit}
/>
</Stack>
</HStack>
<HStack alignItems='center' className='h-5' space={1}>
{errorMessage && (
<>
<Text theme='danger' size='xs'>
{errorMessage}
</Text>
<button onClick={onSubmit} className='flex hover:underline'>
<Text theme='primary' size='xs' tag='span'>
{intl.formatMessage(messages.retry)}
</Text>
</button>
</>
)}
</HStack>
</div>
);
});
export default ChatComposer;

View File

@ -0,0 +1,151 @@
import React, { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals';
import RelativeTimestamp from 'soapbox/components/relative-timestamp';
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { IChat, useChatActions } from 'soapbox/queries/chats';
import type { Menu } from 'soapbox/components/dropdown-menu';
const messages = defineMessages({
blockedYou: { id: 'chat_list_item.blocked_you', defaultMessage: 'This user has blocked you' },
blocking: { id: 'chat_list_item.blocking', defaultMessage: 'You have blocked this user' },
leaveMessage: { id: 'chat_settings.leave.message', defaultMessage: 'Are you sure you want to leave this chat? Messages will be deleted for you and this chat will be removed from your inbox.' },
leaveHeading: { id: 'chat_settings.leave.heading', defaultMessage: 'Leave Chat' },
leaveConfirm: { id: 'chat_settings.leave.confirm', defaultMessage: 'Leave Chat' },
leaveChat: { id: 'chat_settings.options.leave_chat', defaultMessage: 'Leave Chat' },
});
interface IChatListItemInterface {
chat: IChat,
onClick: (chat: any) => void,
}
const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, onClick }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const history = useHistory();
const { isUsingMainChatPage } = useChatContext();
const { deleteChat } = useChatActions(chat?.id as string);
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by']));
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
const menu = useMemo((): Menu => [{
text: intl.formatMessage(messages.leaveChat),
action: (event) => {
event.stopPropagation();
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.leaveHeading),
message: intl.formatMessage(messages.leaveMessage),
confirm: intl.formatMessage(messages.leaveConfirm),
confirmationTheme: 'primary',
onConfirm: () => {
deleteChat.mutate(undefined, {
onSuccess() {
if (isUsingMainChatPage) {
history.push('/chats');
}
},
});
},
}));
},
icon: require('@tabler/icons/logout.svg'),
}], []);
return (
// eslint-disable-next-line jsx-a11y/interactive-supports-focus
<div
role='button'
key={chat.id}
onClick={() => onClick(chat)}
className='group px-2 py-3 w-full flex flex-col rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 focus:shadow-inset-ring'
data-testid='chat-list-item'
>
<HStack alignItems='center' justifyContent='between' space={2} className='w-full'>
<HStack alignItems='center' space={2} className='overflow-hidden'>
<Avatar src={chat.account?.avatar} size={40} className='flex-none' />
<Stack alignItems='start' className='overflow-hidden'>
<div className='flex items-center space-x-1 flex-grow w-full'>
<Text weight='bold' size='sm' align='left' truncate>{chat.account?.display_name || `@${chat.account.username}`}</Text>
{chat.account?.verified && <VerificationBadge />}
</div>
{(isBlocked || isBlocking) ? (
<Text
align='left'
size='sm'
weight='medium'
theme='muted'
truncate
className='w-full h-5 pointer-events-none italic'
data-testid='chat-last-message'
>
{intl.formatMessage(isBlocked ? messages.blockedYou : messages.blocking)}
</Text>
) : (
<>
{chat.last_message?.content && (
<Text
align='left'
size='sm'
weight='medium'
theme={chat.last_message.unread ? 'default' : 'muted'}
truncate
className='w-full h-5 truncate-child pointer-events-none'
data-testid='chat-last-message'
dangerouslySetInnerHTML={{ __html: chat.last_message?.content }}
/>
)}
</>
)}
</Stack>
</HStack>
<HStack alignItems='center' space={2}>
{features.chatsDelete && (
<div className='text-gray-600 hidden group-hover:block hover:text-gray-100'>
{/* TODO: fix nested buttons here */}
<DropdownMenuContainer
items={menu}
src={require('@tabler/icons/dots.svg')}
title='Settings'
/>
</div>
)}
{chat.last_message && (
<>
{chat.last_message.unread && (
<div
className='w-2 h-2 rounded-full bg-secondary-500'
data-testid='chat-unread-indicator'
/>
)}
<RelativeTimestamp
timestamp={chat.last_message.created_at}
align='right'
size='xs'
theme={chat.last_message.unread ? 'default' : 'muted'}
truncate
/>
</>
)}
</HStack>
</HStack>
</div>
);
};
export default ChatListItem;

View File

@ -1,95 +1,93 @@
import { Map as ImmutableMap } from 'immutable'; import classNames from 'clsx';
import React, { useCallback } from 'react'; import React, { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { createSelector } from 'reselect';
import { fetchChats, expandChats } from 'soapbox/actions/chats'; import { fetchChats } from 'soapbox/actions/chats';
import PullToRefresh from 'soapbox/components/pull-to-refresh'; import PullToRefresh from 'soapbox/components/pull-to-refresh';
import { Card, Text } from 'soapbox/components/ui'; import { Spinner, Stack } from 'soapbox/components/ui';
import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder-chat'; import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder-chat';
import { useAppSelector } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { useChats } from 'soapbox/queries/chats';
import Chat from './chat'; import ChatListItem from './chat-list-item';
const messages = defineMessages({
emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' },
});
const getSortedChatIds = (chats: ImmutableMap<string, any>) => (
chats
.toList()
.sort(chatDateComparator)
.map(chat => chat.id)
);
const chatDateComparator = (chatA: { updated_at: string }, chatB: { updated_at: string }) => {
// Sort most recently updated chats at the top
const a = new Date(chatA.updated_at);
const b = new Date(chatB.updated_at);
if (a === b) return 0;
if (a > b) return -1;
if (a < b) return 1;
return 0;
};
const sortedChatIdsSelector = createSelector(
[getSortedChatIds],
chats => chats,
);
interface IChatList { interface IChatList {
onClickChat: (chat: any) => void, onClickChat: (chat: any) => void,
useWindowScroll?: boolean, useWindowScroll?: boolean,
searchValue?: string
} }
const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false }) => { const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false, searchValue }) => {
const dispatch = useDispatch(); const dispatch = useAppDispatch();
const intl = useIntl();
const chatIds = useAppSelector(state => sortedChatIdsSelector(state.chats.items)); const chatListRef = useRef(null);
const hasMore = useAppSelector(state => !!state.chats.next);
const isLoading = useAppSelector(state => state.chats.isLoading);
const isEmpty = chatIds.size === 0; const { chatsQuery: { data: chats, isFetching, hasNextPage, fetchNextPage } } = useChats(searchValue);
const handleLoadMore = useCallback(() => { const [isNearBottom, setNearBottom] = useState<boolean>(false);
if (hasMore && !isLoading) { const [isNearTop, setNearTop] = useState<boolean>(true);
dispatch(expandChats());
const handleLoadMore = () => {
if (hasNextPage && !isFetching) {
fetchNextPage();
} }
}, [dispatch, hasMore, isLoading]);
const handleRefresh = () => {
return dispatch(fetchChats()) as any;
}; };
const renderEmpty = () => isLoading ? <PlaceholderChat /> : ( const handleRefresh = () => dispatch(fetchChats());
<Card className='mt-2' variant='rounded' size='lg'>
<Text>{intl.formatMessage(messages.emptyMessage)}</Text> const renderEmpty = () => {
</Card> if (isFetching) {
); return (
<Stack space={2}>
<PlaceholderChat />
<PlaceholderChat />
<PlaceholderChat />
</Stack>
);
}
return null;
};
return ( return (
<PullToRefresh onRefresh={handleRefresh}> <div className='relative h-full'>
{isEmpty ? renderEmpty() : ( <PullToRefresh onRefresh={handleRefresh}>
<Virtuoso <Virtuoso
className='chat-list' ref={chatListRef}
atTopStateChange={(atTop) => setNearTop(atTop)}
atBottomStateChange={(atBottom) => setNearBottom(atBottom)}
useWindowScroll={useWindowScroll} useWindowScroll={useWindowScroll}
data={chatIds.toArray()} data={chats}
endReached={handleLoadMore} endReached={handleLoadMore}
itemContent={(_index, chatId) => ( itemContent={(_index, chat) => (
<Chat chatId={chatId} onClick={onClickChat} /> <div className='px-2'>
)} <ChatListItem chat={chat} onClick={onClickChat} />
</div>
)
}
components={{ components={{
ScrollSeekPlaceholder: () => <PlaceholderChat />, ScrollSeekPlaceholder: () => <PlaceholderChat />,
Footer: () => hasMore ? <PlaceholderChat /> : null, Footer: () => hasNextPage ? <Spinner withText={false} /> : null,
EmptyPlaceholder: renderEmpty, EmptyPlaceholder: renderEmpty,
}} }}
/> />
)} </PullToRefresh>
</PullToRefresh>
<>
<div
className={classNames('inset-x-0 top-0 flex rounded-t-lg justify-center bg-gradient-to-b from-white to-transparent pb-12 pt-8 pointer-events-none dark:from-gray-900 absolute transition-opacity duration-500', {
'opacity-0': isNearTop,
'opacity-100': !isNearTop,
})}
/>
<div
className={classNames('inset-x-0 bottom-0 flex rounded-b-lg justify-center bg-gradient-to-t from-white to-transparent pt-12 pb-8 pointer-events-none dark:from-gray-900 absolute transition-opacity duration-500', {
'opacity-0': isNearBottom,
'opacity-100': !isNearBottom,
})}
/>
</>
</div>
); );
}; };

View File

@ -0,0 +1,122 @@
import classNames from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals';
import Link from 'soapbox/components/link';
import { Avatar, Button, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useFeatures } from 'soapbox/hooks';
import { useChatActions } from 'soapbox/queries/chats';
import { secondsToDays } from 'soapbox/utils/numbers';
const messages = defineMessages({
leaveChatHeading: { id: 'chat_message_list_intro.leave_chat.heading', defaultMessage: 'Leave Chat' },
leaveChatMessage: { id: 'chat_message_list_intro.leave_chat.message', defaultMessage: 'Are you sure you want to leave this chat? Messages will be deleted for you and this chat will be removed from your inbox.' },
leaveChatConfirm: { id: 'chat_message_list_intro.leave_chat.confirm', defaultMessage: 'Leave Chat' },
intro: { id: 'chat_message_list_intro.intro', defaultMessage: 'wants to start a chat with you' },
accept: { id: 'chat_message_list_intro.actions.accept', defaultMessage: 'Accept' },
leaveChat: { id: 'chat_message_list_intro.actions.leave_chat', defaultMessage: 'Leave chat' },
report: { id: 'chat_message_list_intro.actions.report', defaultMessage: 'Report' },
messageLifespan: { id: 'chat_message_list_intro.actions.message_lifespan', defaultMessage: 'Messages older than {day} days are deleted.' },
});
const ChatMessageListIntro = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const history = useHistory();
const { chat, isUsingMainChatPage, needsAcceptance } = useChatContext();
const { acceptChat, deleteChat } = useChatActions(chat?.id as string);
const handleLeaveChat = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.leaveChatHeading),
message: intl.formatMessage(messages.leaveChatMessage),
confirm: intl.formatMessage(messages.leaveChatConfirm),
confirmationTheme: 'primary',
onConfirm: () => {
deleteChat.mutate(undefined, {
onSuccess() {
if (isUsingMainChatPage) {
history.push('/chats');
}
},
});
},
}));
};
if (!chat || !features.chatAcceptance) {
return null;
}
return (
<Stack
data-testid='chat-message-list-intro'
justifyContent='center'
alignItems='center'
space={4}
className={
classNames({
'w-3/4 mx-auto': needsAcceptance,
'py-6': true, // needs to be padding to prevent Virtuoso bugs
})
}
>
<Stack alignItems='center' space={2}>
<Link to={`/@${chat.account.acct}`}>
<Avatar src={chat.account.avatar_static} size={75} />
</Link>
<Text size='lg' align='center'>
{needsAcceptance ? (
<>
<Text tag='span' weight='semibold'>@{chat.account.acct}</Text>
{' '}
<Text tag='span'>{intl.formatMessage(messages.intro)}</Text>
</>
) : (
<Link to={`/@${chat.account.acct}`}>
<Text tag='span' theme='inherit' weight='semibold'>@{chat.account.acct}</Text>
</Link>
)}
</Text>
</Stack>
{needsAcceptance ? (
<HStack alignItems='center' space={2} className='w-full'>
<Button
theme='primary'
block
onClick={() => acceptChat.mutate()}
disabled={acceptChat.isLoading}
>
{intl.formatMessage(messages.accept)}
</Button>
<Button
theme='danger'
block
onClick={handleLeaveChat}
>
{intl.formatMessage(messages.leaveChat)}
</Button>
</HStack>
) : (
<HStack justifyContent='center' alignItems='center' space={1} className='flex-shrink-0'>
<Icon src={require('@tabler/icons/clock.svg')} className='text-gray-600 w-4 h-4' />
{chat.message_expiration && (
<Text size='sm' theme='muted'>
{intl.formatMessage(messages.messageLifespan, { day: secondsToDays(chat.message_expiration) })}
</Text>
)}
</HStack>
)}
</Stack>
);
};
export default ChatMessageListIntro;

View File

@ -1,44 +1,52 @@
import { useMutation } from '@tanstack/react-query';
import classNames from 'clsx'; import classNames from 'clsx';
import { import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
Map as ImmutableMap,
List as ImmutableList,
OrderedSet as ImmutableOrderedSet,
} from 'immutable';
import escape from 'lodash/escape'; import escape from 'lodash/escape';
import throttle from 'lodash/throttle'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useRef, useLayoutEffect, useMemo } from 'react';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import { createSelector } from 'reselect'; import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { fetchChatMessages, deleteChatMessage } from 'soapbox/actions/chats';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { initReportById } from 'soapbox/actions/reports'; import { initReport } from 'soapbox/actions/reports';
import { Text } from 'soapbox/components/ui'; import { Avatar, Button, Divider, HStack, Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
import emojify from 'soapbox/features/emoji/emoji'; import emojify from 'soapbox/features/emoji/emoji';
import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message';
import Bundle from 'soapbox/features/ui/components/bundle'; import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components'; import { MediaGallery } from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useAppDispatch, useRefEventHandler } from 'soapbox/hooks'; 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 { onlyEmoji } from 'soapbox/utils/rich-content';
import ChatMessageListIntro from './chat-message-list-intro';
import type { Menu } from 'soapbox/components/dropdown-menu'; 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 = 1; 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' },
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete message' }, delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' },
report: { id: 'chats.actions.report', defaultMessage: 'Report user' }, copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
report: { id: 'chats.actions.report', defaultMessage: 'Report' },
deleteForMe: { id: 'chats.actions.deleteForMe', defaultMessage: 'Delete for me' },
blockedBy: { id: 'chat_message_list.blockedBy', defaultMessage: 'You are blocked by' },
networkFailureTitle: { id: 'chat_message_list.network_failure.title', defaultMessage: 'Whoops!' },
networkFailureSubtitle: { id: 'chat_message_list.network_failure.subtitle', defaultMessage: 'We encountered a network failure.' },
networkFailureAction: { id: 'chat_message_list.network_failure.action', defaultMessage: 'Try again' },
}); });
type TimeFormat = 'today' | 'date'; type TimeFormat = 'today' | 'date';
const timeChange = (prev: ChatMessageEntity, curr: ChatMessageEntity): TimeFormat | null => { const timeChange = (prev: IChatMessage, curr: IChatMessage): 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();
if (prevDate !== currDate) { if (prevDate !== currDate) {
return currDate === nowDate ? 'today' : 'date'; return currDate === nowDate ? 'today' : 'date';
@ -51,58 +59,102 @@ const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).redu
return map.set(`:${emoji.get('shortcode')}:`, emoji); return map.set(`:${emoji.get('shortcode')}:`, emoji);
}, ImmutableMap()); }, ImmutableMap());
const getChatMessages = createSelector( const START_INDEX = 10000;
[(chatMessages: ImmutableMap<string, ChatMessageEntity>, chatMessageIds: ImmutableOrderedSet<string>) => (
chatMessageIds.reduce((acc, curr) => { const List: Components['List'] = React.forwardRef((props, ref) => {
const chatMessage = chatMessages.get(curr); const { context, ...rest } = props;
return chatMessage ? acc.push(chatMessage) : acc; return <div ref={ref} {...rest} className='mb-2' />;
}, ImmutableList<ChatMessageEntity>()) });
)],
chatMessages => chatMessages,
);
interface IChatMessageList { interface IChatMessageList {
/** Chat the messages are being rendered from. */ /** Chat the messages are being rendered from. */
chatId: string, chat: IChat,
/** Message IDs to render. */
chatMessageIds: ImmutableOrderedSet<string>,
/** Whether to make the chatbox fill the height of the screen. */
autosize?: boolean,
} }
/** Scrollable list of chat messages. */ /** Scrollable list of chat messages. */
const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, autosize }) => { const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const account = useOwnAccount();
const features = useFeatures();
const me = useAppSelector(state => state.me); const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date;
const chatMessages = useAppSelector(state => getChatMessages(state.chat_messages, chatMessageIds)); 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 [initialLoad, setInitialLoad] = useState(true); const node = useRef<VirtuosoHandle>(null);
const [isLoading, setIsLoading] = useState(false); const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
const node = useRef<HTMLDivElement>(null); const { deleteChatMessage, markChatAsRead } = useChatActions(chat.id);
const messagesEnd = useRef<HTMLDivElement>(null); const {
const lastComputedScroll = useRef<number | undefined>(undefined); data: chatMessages,
const scrollBottom = useRef<number | undefined>(undefined); fetchNextPage,
hasNextPage,
isError,
isFetching,
isFetchingNextPage,
isLoading,
refetch,
} = useChatMessages(chat);
const initialCount = useMemo(() => chatMessages.count(), []); const formattedChatMessages = chatMessages || [];
const scrollToBottom = () => { const me = useAppSelector((state) => state.me);
messagesEnd.current?.scrollIntoView(false); 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(() => {
if (!chatMessages) {
return [];
}
const nextFirstItemIndex = START_INDEX - chatMessages.length;
setFirstItemIndex(nextFirstItemIndex);
return chatMessages.reduce((acc: any, curr: any, idx: number) => {
const lastMessage = formattedChatMessages[idx - 1];
if (lastMessage) {
switch (timeChange(lastMessage, curr)) {
case 'today':
acc.push({
type: 'divider',
text: intl.formatMessage(messages.today),
});
break;
case 'date':
acc.push({
type: 'divider',
text: intl.formatDate(new Date(curr.created_at), { weekday: 'short', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' }),
});
break;
}
}
acc.push(curr);
return acc;
}, []);
}, [chatMessages?.length, lastChatMessage]);
const initialTopMostItemIndex = process.env.NODE_ENV === 'test' ? 0 : cachedChatMessages.length - 1;
const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => { const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
return intl.formatDate( return intl.formatDate(new Date(chatMessage.created_at), {
new Date(chatMessage.created_at), { hour12: false,
hour12: false, year: 'numeric',
year: 'numeric', month: 'short',
month: 'short', day: '2-digit',
day: '2-digit', hour: '2-digit',
hour: '2-digit', minute: '2-digit',
minute: '2-digit', });
},
);
}; };
const setBubbleRef = (c: HTMLDivElement) => { const setBubbleRef = (c: HTMLDivElement) => {
@ -114,54 +166,14 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
link.setAttribute('rel', 'ugc nofollow noopener'); link.setAttribute('rel', 'ugc nofollow noopener');
link.setAttribute('target', '_blank'); link.setAttribute('target', '_blank');
}); });
if (onlyEmoji(c, BIG_EMOJI_LIMIT, false)) {
c.classList.add('chat-message__bubble--onlyEmoji');
} else {
c.classList.remove('chat-message__bubble--onlyEmoji');
}
}; };
const isNearBottom = (): boolean => { const handleStartReached = useCallback(() => {
const elem = node.current; if (hasNextPage && !isFetching) {
if (!elem) return false; fetchNextPage();
const scrollBottom = elem.scrollHeight - elem.offsetHeight - elem.scrollTop;
return scrollBottom < elem.offsetHeight * 1.5;
};
const handleResize = throttle(() => {
if (isNearBottom()) {
scrollToBottom();
} }
}, 150); return false;
}, [firstItemIndex, hasNextPage, isFetching]);
const restoreScrollPosition = () => {
if (node.current && scrollBottom.current) {
lastComputedScroll.current = node.current.scrollHeight - scrollBottom.current;
node.current.scrollTop = lastComputedScroll.current;
}
};
const handleLoadMore = () => {
const maxId = chatMessages.getIn([0, 'id']) as string;
dispatch(fetchChatMessages(chatId, maxId as any));
setIsLoading(true);
};
const handleScroll = useRefEventHandler(throttle(() => {
if (node.current) {
const { scrollTop, offsetHeight } = node.current;
const computedScroll = lastComputedScroll.current === scrollTop;
const nearTop = scrollTop < offsetHeight * 2;
if (nearTop && !isLoading && !initialLoad && !computedScroll) {
handleLoadMore();
}
}
}, 150, {
trailing: true,
}));
const onOpenMedia = (media: any, index: number) => { const onOpenMedia = (media: any, index: number) => {
dispatch(openModal('MEDIA', { media, index })); dispatch(openModal('MEDIA', { media, index }));
@ -171,18 +183,15 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
const { attachment } = chatMessage; const { attachment } = chatMessage;
if (!attachment) return null; if (!attachment) return null;
return ( return (
<div className='chat-message__media'> <Bundle fetchComponent={MediaGallery}>
<Bundle fetchComponent={MediaGallery}> {(Component: any) => (
{(Component: any) => ( <Component
<Component media={ImmutableList([attachment])}
media={ImmutableList([attachment])} onOpenMedia={onOpenMedia}
height={120} visible
onOpenMedia={onOpenMedia} />
visible )}
/> </Bundle>
)}
</Bundle>
</div>
); );
}; };
@ -199,137 +208,279 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
return emojify(formatted, emojiMap.toJS()); return emojify(formatted, emojiMap.toJS());
}; };
const renderDivider = (key: React.Key, text: string) => ( const renderDivider = (key: React.Key, text: string) => <Divider key={key} text={text} textSize='sm' />;
<div className='chat-messages__divider' key={key}>{text}</div>
);
const handleDeleteMessage = (chatId: string, messageId: string) => { const handleCopyText = (chatMessage: ChatMessageEntity) => {
return () => { if (navigator.clipboard) {
dispatch(deleteChatMessage(chatId, messageId)); const text = stripHTML(chatMessage.content);
}; navigator.clipboard.writeText(text);
}; }
const handleReportUser = (userId: string) => {
return () => {
dispatch(initReportById(userId));
};
}; };
const renderMessage = (chatMessage: ChatMessageEntity) => { const renderMessage = (chatMessage: ChatMessageEntity) => {
const menu: Menu = [ 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) {
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), text: intl.formatMessage(messages.delete),
action: handleDeleteMessage(chatMessage.chat_id, chatMessage.id), action: () => handleDeleteMessage.mutate(chatMessage.id),
icon: require('@tabler/icons/trash.svg'), icon: require('@tabler/icons/trash.svg'),
destructive: true, destructive: true,
}, });
]; } else {
if (features.reportChats) {
if (chatMessage.account_id !== me) { 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({ menu.push({
text: intl.formatMessage(messages.report), text: intl.formatMessage(messages.deleteForMe),
action: handleReportUser(chatMessage.account_id), action: () => handleDeleteMessage.mutate(chatMessage.id),
icon: require('@tabler/icons/flag.svg'), icon: require('@tabler/icons/trash.svg'),
destructive: true,
}); });
} }
return ( return (
<div <div key={chatMessage.id} className='group' data-testid='chat-message'>
className={classNames('chat-message', { <Stack
'chat-message--me': chatMessage.account_id === me, space={1.5}
'chat-message--pending': chatMessage.pending, className={classNames({
})} 'ml-auto': isMyMessage,
key={chatMessage.id} })}
>
<div
title={getFormattedTimestamp(chatMessage)}
className='chat-message__bubble'
ref={setBubbleRef}
tabIndex={0}
> >
{maybeRenderMedia(chatMessage)} <HStack
<Text size='sm' dangerouslySetInnerHTML={{ __html: parseContent(chatMessage) }} /> alignItems='center'
<div className='chat-message__menu'> justifyContent={isMyMessage ? 'end' : 'start'}
<DropdownMenuContainer className={classNames({
items={menu} 'opacity-50': chatMessage.pending,
src={require('@tabler/icons/dots.svg')} })}
title={intl.formatMessage(messages.more)} >
dropdownMenuStyle={{ zIndex: 1000 }} {menu.length > 0 && (
/> <div
</div> className={classNames({
</div> '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}
src={require('@tabler/icons/dots.svg')}
title={intl.formatMessage(messages.more)}
/>
</div>
)}
<HStack
alignItems='bottom'
className={classNames({
'max-w-[85%]': true,
'order-2': isMyMessage,
'order-1': !isMyMessage,
})}
justifyContent={isMyMessage ? 'end' : 'start'}
>
<div
title={getFormattedTimestamp(chatMessage)}
className={
classNames({
'text-ellipsis break-words relative rounded-md py-2 px-3 max-w-full space-y-2': true,
'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}
>
{maybeRenderMedia(chatMessage)}
<Text
size='sm'
theme='inherit'
className='break-word-nested'
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</HStack>
</HStack>
<HStack
alignItems='center'
space={2}
className={classNames({
'ml-auto': isMyMessage,
})}
>
<div
className={classNames({
'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='rounded-full flex flex-col items-center justify-center p-0.5 bg-primary-500 text-white dark:bg-primary-400 dark:text-primary-900 border border-solid border-primary-500 dark:border-primary-400'>
<Icon src={require('@tabler/icons/check.svg')} strokeWidth={3} className='w-2.5 h-2.5' />
</span>
) : (
<span className='rounded-full flex flex-col items-center justify-center p-0.5 bg-transparent text-primary-500 dark:text-primary-400 border border-solid border-primary-500 dark:border-primary-400'>
<Icon src={require('@tabler/icons/check.svg')} strokeWidth={3} className='w-2.5 h-2.5' />
</span>
)}
</>
) : null}
</span>
</div>
</HStack>
</Stack>
</div> </div>
); );
}; };
useEffect(() => { useEffect(() => {
dispatch(fetchChatMessages(chatId)); const lastMessage = formattedChatMessages[formattedChatMessages.length - 1];
if (!lastMessage) {
node.current?.addEventListener('scroll', e => handleScroll.current(e)); return;
window.addEventListener('resize', handleResize);
scrollToBottom();
return () => {
node.current?.removeEventListener('scroll', e => handleScroll.current(e));
window.removeEventListener('resize', handleResize);
};
}, []);
// Store the scroll position.
useLayoutEffect(() => {
if (node.current) {
const { scrollHeight, scrollTop } = node.current;
scrollBottom.current = scrollHeight - scrollTop;
}
});
// Stick scrollbar to bottom.
useEffect(() => {
if (isNearBottom()) {
scrollToBottom();
} }
// First load. const lastMessageId = lastMessage.id;
if (chatMessages.count() !== initialCount) { const isMessagePending = lastMessage.pending;
setInitialLoad(false); const isAlreadyRead = myLastReadMessageTimestamp ? myLastReadMessageTimestamp >= new Date(lastMessage.created_at) : false;
setIsLoading(false);
scrollToBottom();
}
}, [chatMessages.count()]);
useEffect(() => { /**
scrollToBottom(); * Only "mark the message as read" if..
}, [messagesEnd.current]); * 1) it is not pending and
* 2) it has not already been read
// History added. */
useEffect(() => { if (!isMessagePending && !isAlreadyRead) {
// Restore scroll bar position when loading old messages. markChatAsRead(lastMessageId);
if (!initialLoad) {
restoreScrollPosition();
} }
}, [chatMessageIds.first()]); }, [formattedChatMessages.length]);
if (isBlocked) {
return (
<Stack alignItems='center' justifyContent='center' className='h-full flex-grow'>
<Stack alignItems='center' space={2}>
<Avatar src={chat.account.avatar} size={75} />
<Text align='center'>
<>
<Text tag='span'>{intl.formatMessage(messages.blockedBy)}</Text>
{' '}
<Text tag='span' theme='primary'>@{chat.account.acct}</Text>
</>
</Text>
</Stack>
</Stack>
);
}
if (isError) {
return (
<Stack alignItems='center' justifyContent='center' className='h-full flex-grow'>
<Stack space={4}>
<Stack space={1}>
<Text size='lg' weight='bold' align='center'>
{intl.formatMessage(messages.networkFailureTitle)}
</Text>
<Text theme='muted' align='center'>
{intl.formatMessage(messages.networkFailureSubtitle)}
</Text>
</Stack>
<div className='mx-auto'>
<Button theme='primary' onClick={() => refetch()}>
{intl.formatMessage(messages.networkFailureAction)}
</Button>
</div>
</Stack>
</Stack>
);
}
if (isLoading) {
return (
<div className='flex-grow flex flex-col justify-end pb-4'>
<div className='px-4'>
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage />
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage isMyMessage />
<PlaceholderChatMessage />
</div>
</div>
);
}
return ( return (
<div className='chat-messages' style={{ height: autosize ? 'calc(100vh - 16rem)' : undefined }} ref={node}> <div className='h-full flex flex-col flex-grow space-y-6'>
{chatMessages.reduce((acc, curr, idx) => { <div className='flex-grow flex flex-col justify-end pb-2'>
const lastMessage = chatMessages.get(idx - 1); <Virtuoso
ref={node}
alignToBottom
firstItemIndex={Math.max(0, firstItemIndex)}
initialTopMostItemIndex={initialTopMostItemIndex}
data={cachedChatMessages}
startReached={handleStartReached}
followOutput='auto'
itemContent={(index, chatMessage) => {
if (chatMessage.type === 'divider') {
return renderDivider(index, chatMessage.text);
} else {
return (
<div className='px-4 py-2'>
{renderMessage(chatMessage)}
</div>
);
}
}}
components={{
List,
Header: () => {
if (hasNextPage || isFetchingNextPage) {
return <Spinner withText={false} />;
}
if (lastMessage) { if (!hasNextPage && !isLoading) {
const key = `${curr.id}_divider`; return <ChatMessageListIntro />;
switch (timeChange(lastMessage, curr)) { }
case 'today':
acc.push(renderDivider(key, intl.formatMessage(messages.today)));
break;
case 'date':
acc.push(renderDivider(key, new Date(curr.created_at).toDateString()));
break;
}
}
acc.push(renderMessage(curr)); return null;
return acc; },
}, [] as React.ReactNode[])} }}
<div style={{ float: 'left', clear: 'both' }} ref={messagesEnd} /> />
</div>
</div> </div>
); );
}; };

View File

@ -0,0 +1,84 @@
import userEvent from '@testing-library/user-event';
import { Map as ImmutableMap } from 'immutable';
import React from 'react';
import { __stub } from 'soapbox/api';
import { normalizeAccount } from 'soapbox/normalizers';
import { ReducerAccount } from 'soapbox/reducers/accounts';
import { render, screen } from '../../../../../jest/test-helpers';
import ChatPage from '../chat-page';
describe('<ChatPage />', () => {
let store: any;
describe('before you finish onboarding', () => {
it('renders the Welcome component', () => {
render(<ChatPage />);
expect(screen.getByTestId('chats-welcome')).toBeInTheDocument();
});
describe('when you complete onboarding', () => {
const id = '1';
beforeEach(() => {
store = {
me: id,
accounts: ImmutableMap({
[id]: normalizeAccount({
id,
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
chats_onboarded: false,
}) as ReducerAccount,
}),
};
__stub((mock) => {
mock
.onPatch('/api/v1/accounts/update_credentials')
.reply(200, { chats_onboarded: true, id });
});
});
it('renders the Chats', async () => {
render(<ChatPage />, undefined, store);
await userEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('chat-page')).toBeInTheDocument();
expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings updated successfully');
});
});
describe('when the API returns an error', () => {
beforeEach(() => {
store = {
me: '1',
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'justin-username',
display_name: 'Justin L',
avatar: 'test.jpg',
chats_onboarded: false,
}) as ReducerAccount,
}),
};
__stub((mock) => {
mock
.onPatch('/api/v1/accounts/update_credentials')
.networkError();
});
});
it('renders the Chats', async () => {
render(<ChatPage />, undefined, store);
await userEvent.click(screen.getByTestId('button'));
expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings failed to update.');
});
});
});
});

View File

@ -0,0 +1,103 @@
import classNames from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { matchPath, Route, Switch, useHistory } from 'react-router-dom';
import { Stack } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import ChatPageMain from './components/chat-page-main';
import ChatPageNew from './components/chat-page-new';
import ChatPageSettings from './components/chat-page-settings';
import ChatPageSidebar from './components/chat-page-sidebar';
import Welcome from './components/welcome';
interface IChatPage {
chatId?: string,
}
const ChatPage: React.FC<IChatPage> = ({ chatId }) => {
const account = useOwnAccount();
const history = useHistory();
const isOnboarded = account?.chats_onboarded;
const path = history.location.pathname;
const isSidebarHidden = matchPath(path, {
path: ['/chats/settings', '/chats/new', '/chats/:chatId'],
exact: true,
});
const containerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<string | number>('100%');
const calculateHeight = () => {
if (!containerRef.current) {
return null;
}
const { top } = containerRef.current.getBoundingClientRect();
const fullHeight = document.body.offsetHeight;
// On mobile, account for bottom navigation.
const offset = document.body.clientWidth < 976 ? -61 : 0;
setHeight(fullHeight - top + offset);
};
useEffect(() => {
calculateHeight();
}, [containerRef.current]);
useEffect(() => {
window.addEventListener('resize', calculateHeight);
return () => {
window.removeEventListener('resize', calculateHeight);
};
}, []);
return (
<div
ref={containerRef}
style={{ height }}
className='h-screen bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden sm:rounded-t-xl'
>
{isOnboarded ? (
<div
className='grid grid-cols-9 overflow-hidden h-full dark:divide-x-2 dark:divide-solid dark:divide-gray-800'
data-testid='chat-page'
>
<Stack
className={classNames('col-span-9 sm:col-span-3 bg-gradient-to-r from-white to-gray-100 dark:bg-gray-900 dark:bg-none overflow-hidden dark:inset', {
'hidden sm:block': isSidebarHidden,
})}
>
<ChatPageSidebar />
</Stack>
<Stack
className={classNames('col-span-9 sm:col-span-6 h-full overflow-hidden', {
'hidden sm:block': !isSidebarHidden,
})}
>
<Switch>
<Route path='/chats/new'>
<ChatPageNew />
</Route>
<Route path='/chats/settings'>
<ChatPageSettings />
</Route>
<Route>
<ChatPageMain />
</Route>
</Switch>
</Stack>
</div>
) : (
<Welcome />
)}
</div>
);
};
export default ChatPage;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { Button, Stack, Text } from 'soapbox/components/ui';
interface IBlankslate {
}
/** To display on the chats main page when no message is selected. */
const BlankslateEmpty: React.FC<IBlankslate> = () => {
const history = useHistory();
const handleNewChat = () => {
history.push('/chats/new');
};
return (
<Stack space={6} alignItems='center' justifyContent='center' className='p-6 h-full'>
<Stack space={2} className='max-w-sm'>
<Text size='2xl' weight='bold' tag='h2' align='center'>
<FormattedMessage
id='chats.main.blankslate.title'
defaultMessage='No messages yet'
/>
</Text>
<Text size='sm' theme='muted' align='center'>
<FormattedMessage
id='chats.main.blankslate.subtitle'
defaultMessage='You can start a chat with anyone that follows you'
/>
</Text>
</Stack>
<Button theme='primary' onClick={handleNewChat}>
<FormattedMessage
id='chats.main.blankslate.new_chat'
defaultMessage='Message someone'
/>
</Button>
</Stack>
);
};
export default BlankslateEmpty;

View File

@ -0,0 +1,43 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { Button, Stack, Text } from 'soapbox/components/ui';
/** To display on the chats main page when no message is selected, but chats are present. */
const BlankslateWithChats = () => {
const history = useHistory();
const handleNewChat = () => {
history.push('/chats/new');
};
return (
<Stack space={6} alignItems='center' justifyContent='center' className='p-6 h-full'>
<Stack space={2} className='max-w-sm'>
<Text size='2xl' weight='bold' tag='h2' align='center'>
<FormattedMessage
id='chats.main.blankslate_with_chats.title'
defaultMessage='Select a chat'
/>
</Text>
<Text size='sm' theme='muted' align='center'>
<FormattedMessage
id='chats.main.blankslate_with_chats.subtitle'
defaultMessage='Select from one of your open chats or create a new message.'
/>
</Text>
</Stack>
<Button theme='primary' onClick={handleNewChat}>
<FormattedMessage
id='chats.main.blankslate.new_chat'
defaultMessage='Message someone'
/>
</Button>
</Stack>
);
};
export default BlankslateWithChats;

View File

@ -0,0 +1,255 @@
import React, { useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link, useHistory, useParams } from 'react-router-dom';
import { blockAccount, unblockAccount } from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals';
import List, { ListItem } from 'soapbox/components/list';
import { Avatar, HStack, Icon, IconButton, Menu, MenuButton, MenuItem, MenuList, Stack, Text, Tooltip } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { MessageExpirationValues, useChat, useChatActions, useChats } from 'soapbox/queries/chats';
import { secondsToDays } from 'soapbox/utils/numbers';
import Chat from '../../chat';
import BlankslateEmpty from './blankslate-empty';
import BlankslateWithChats from './blankslate-with-chats';
const messages = defineMessages({
blockMessage: { id: 'chat_settings.block.message', defaultMessage: 'Blocking will prevent this profile from direct messaging you and viewing your content. You can unblock later.' },
blockHeading: { id: 'chat_settings.block.heading', defaultMessage: 'Block @{acct}' },
blockConfirm: { id: 'chat_settings.block.confirm', defaultMessage: 'Block' },
unblockMessage: { id: 'chat_settings.unblock.message', defaultMessage: 'Unblocking will allow this profile to direct message you and view your content.' },
unblockHeading: { id: 'chat_settings.unblock.heading', defaultMessage: 'Unblock @{acct}' },
unblockConfirm: { id: 'chat_settings.unblock.confirm', defaultMessage: 'Unblock' },
leaveMessage: { id: 'chat_settings.leave.message', defaultMessage: 'Are you sure you want to leave this chat? Messages will be deleted for you and this chat will be removed from your inbox.' },
leaveHeading: { id: 'chat_settings.leave.heading', defaultMessage: 'Leave Chat' },
leaveConfirm: { id: 'chat_settings.leave.confirm', defaultMessage: 'Leave Chat' },
blockUser: { id: 'chat_settings.options.block_user', defaultMessage: 'Block @{acct}' },
unblockUser: { id: 'chat_settings.options.unblock_user', defaultMessage: 'Unblock @{acct}' },
reportUser: { id: 'chat_settings.options.report_user', defaultMessage: 'Report @{acct}' },
leaveChat: { id: 'chat_settings.options.leave_chat', defaultMessage: 'Leave Chat' },
autoDeleteLabel: { id: 'chat_settings.auto_delete.label', defaultMessage: 'Auto-delete messages' },
autoDeleteHint: { id: 'chat_settings.auto_delete.hint', defaultMessage: 'Sent messages will auto-delete after the time period selected' },
autoDelete2Minutes: { id: 'chat_settings.auto_delete.2minutes', defaultMessage: '2 minutes' },
autoDelete7Days: { id: 'chat_settings.auto_delete.7days', defaultMessage: '7 days' },
autoDelete14Days: { id: 'chat_settings.auto_delete.14days', defaultMessage: '14 days' },
autoDelete30Days: { id: 'chat_settings.auto_delete.30days', defaultMessage: '30 days' },
autoDelete90Days: { id: 'chat_settings.auto_delete.90days', defaultMessage: '90 days' },
autoDeleteMessage: { id: 'chat_window.auto_delete_label', defaultMessage: 'Auto-delete after {day} days' },
autoDeleteMessageTooltip: { id: 'chat_window.auto_delete_tooltip', defaultMessage: 'Chat messages are set to auto-delete after {day} days upon sending.' },
});
const ChatPageMain = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const history = useHistory();
const { chatId } = useParams<{ chatId: string }>();
const { data: chat } = useChat(chatId);
const { currentChatId } = useChatContext();
const { chatsQuery: { data: chats, isLoading } } = useChats();
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const { deleteChat, updateChat } = useChatActions(chat?.id as string);
const handleUpdateChat = (value: MessageExpirationValues) => updateChat.mutate({ message_expiration: value });
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
const handleBlockUser = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.blockHeading, { acct: chat?.account.acct }),
message: intl.formatMessage(messages.blockMessage),
confirm: intl.formatMessage(messages.blockConfirm),
confirmationTheme: 'primary',
onConfirm: () => dispatch(blockAccount(chat?.account.id as string)),
}));
};
const handleUnblockUser = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.unblockHeading, { acct: chat?.account.acct }),
message: intl.formatMessage(messages.unblockMessage),
confirm: intl.formatMessage(messages.unblockConfirm),
confirmationTheme: 'primary',
onConfirm: () => dispatch(unblockAccount(chat?.account.id as string)),
}));
};
const handleLeaveChat = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.leaveHeading),
message: intl.formatMessage(messages.leaveMessage),
confirm: intl.formatMessage(messages.leaveConfirm),
confirmationTheme: 'primary',
onConfirm: () => {
deleteChat.mutate(undefined, {
onSuccess() {
history.push('/chats');
},
});
},
}));
};
if (isLoading) {
return null;
}
if (!currentChatId && chats && chats.length > 0) {
return <BlankslateWithChats />;
}
if (!currentChatId) {
return <BlankslateEmpty />;
}
if (!chat) {
return null;
}
return (
<Stack className='h-full overflow-hidden'>
<HStack alignItems='center' justifyContent='between' space={2} className='px-4 py-4 w-full'>
<HStack alignItems='center' space={2} className='overflow-hidden'>
<HStack alignItems='center'>
<IconButton
src={require('@tabler/icons/arrow-left.svg')}
className='sm:hidden h-7 w-7 mr-2 sm:mr-0'
onClick={() => history.push('/chats')}
/>
<Link to={`/@${chat.account.acct}`}>
<Avatar src={chat.account.avatar} size={40} className='flex-none' />
</Link>
</HStack>
<Stack alignItems='start' className='overflow-hidden h-11'>
<div className='flex items-center space-x-1 flex-grow w-full'>
<Link to={`/@${chat.account.acct}`}>
<Text weight='bold' size='sm' align='left' truncate>
{chat.account.display_name || `@${chat.account.username}`}
</Text>
</Link>
{chat.account.verified && <VerificationBadge />}
</div>
{chat.message_expiration && (
<Tooltip
text={intl.formatMessage(messages.autoDeleteMessageTooltip, { day: secondsToDays(chat.message_expiration) })}
>
<Text
align='left'
size='sm'
weight='medium'
theme='primary'
truncate
className='w-full cursor-help'
>
{intl.formatMessage(messages.autoDeleteMessage, { day: secondsToDays(chat.message_expiration) })}
</Text>
</Tooltip>
)}
</Stack>
</HStack>
<Menu>
<MenuButton
as={IconButton}
src={require('@tabler/icons/info-circle.svg')}
iconClassName='w-5 h-5 text-gray-600'
children={null}
/>
<MenuList className='w-80'>
<Stack space={4} className='px-6 py-5'>
<HStack alignItems='center' space={3}>
<Avatar src={chat.account.avatar_static} size={50} />
<Stack>
<Text weight='semibold'>{chat.account.display_name}</Text>
<Text size='sm' theme='primary'>@{chat.account.acct}</Text>
</Stack>
</HStack>
{features.chatsExpiration && (
<List>
<ListItem
label={intl.formatMessage(messages.autoDeleteLabel)}
hint={intl.formatMessage(messages.autoDeleteHint)}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete2Minutes)}
onSelect={() => handleUpdateChat(MessageExpirationValues.TWO_MINUTES)}
isSelected={chat.message_expiration === MessageExpirationValues.TWO_MINUTES}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete7Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.SEVEN)}
isSelected={chat.message_expiration === MessageExpirationValues.SEVEN}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete14Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.FOURTEEN)}
isSelected={chat.message_expiration === MessageExpirationValues.FOURTEEN}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete30Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.THIRTY)}
isSelected={chat.message_expiration === MessageExpirationValues.THIRTY}
/>
<ListItem
label={intl.formatMessage(messages.autoDelete90Days)}
onSelect={() => handleUpdateChat(MessageExpirationValues.NINETY)}
isSelected={chat.message_expiration === MessageExpirationValues.NINETY}
/>
</List>
)}
<Stack space={2}>
<MenuItem
as='button'
onSelect={isBlocking ? handleUnblockUser : handleBlockUser}
className='!px-0 hover:!bg-transparent'
>
<div className='w-full flex items-center space-x-2 font-bold text-sm text-primary-500 dark:text-accent-blue'>
<Icon src={require('@tabler/icons/ban.svg')} className='w-5 h-5' />
<span>{intl.formatMessage(isBlocking ? messages.unblockUser : messages.blockUser, { acct: chat.account.acct })}</span>
</div>
</MenuItem>
{features.chatsDelete && (
<MenuItem
as='button'
onSelect={handleLeaveChat}
className='!px-0 hover:!bg-transparent'
>
<div className='w-full flex items-center space-x-2 font-bold text-sm text-danger-600 dark:text-danger-500'>
<Icon src={require('@tabler/icons/logout.svg')} className='w-5 h-5' />
<span>{intl.formatMessage(messages.leaveChat)}</span>
</div>
</MenuItem>
)}
</Stack>
</Stack>
</MenuList>
</Menu>
</HStack>
<div className='h-full overflow-hidden'>
<Chat
className='h-full overflow-hidden'
chat={chat}
inputRef={inputRef}
/>
</div>
</Stack>
);
};
export default ChatPageMain;

View File

@ -0,0 +1,60 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import AccountSearch from 'soapbox/components/account-search';
import { CardTitle, HStack, IconButton, Stack, Text } from 'soapbox/components/ui';
import { ChatKeys, useChats } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
interface IChatPageNew {
}
/** New message form to create a chat. */
const ChatPageNew: React.FC<IChatPageNew> = () => {
const history = useHistory();
const { getOrCreateChatByAccountId } = useChats();
const handleAccountSelected = async (accountId: string) => {
const { data } = await getOrCreateChatByAccountId(accountId);
history.push(`/chats/${data.id}`);
queryClient.invalidateQueries(ChatKeys.chatSearch());
};
return (
<Stack className='h-full'>
<Stack className='flex-grow py-6 px-4 sm:p-6 space-y-4'>
<HStack alignItems='center'>
<IconButton
src={require('@tabler/icons/arrow-left.svg')}
className='sm:hidden h-7 w-7 mr-2 sm:mr-0'
onClick={() => history.push('/chats')}
/>
<CardTitle title='New Message' />
</HStack>
<HStack space={2} alignItems='center'>
<Text>
<FormattedMessage
id='chats.new.to'
defaultMessage='To:'
/>
</Text>
<AccountSearch
onSelected={handleAccountSelected}
placeholder='Type a name'
theme='search'
showButtons={false}
autoFocus
className='mb-0.5'
followers
/>
</HStack>
</Stack>
</Stack>
);
};
export default ChatPageNew;

View File

@ -0,0 +1,95 @@
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { changeSetting } from 'soapbox/actions/settings';
import List, { ListItem } from 'soapbox/components/list';
import { Button, CardBody, CardTitle, Form, HStack, IconButton, Stack, Toggle } from 'soapbox/components/ui';
import SettingToggle from 'soapbox/features/notifications/components/setting-toggle';
import { useAppDispatch, useOwnAccount, useSettings } from 'soapbox/hooks';
import { useUpdateCredentials } from 'soapbox/queries/accounts';
type FormData = {
accepts_chat_messages?: boolean
chats_onboarded: boolean
}
const messages = defineMessages({
title: { id: 'chat.page_settings.title', defaultMessage: 'Message Settings' },
preferences: { id: 'chat.page_settings.preferences', defaultMessage: 'Preferences' },
privacy: { id: 'chat.page_settings.privacy', defaultMessage: 'Privacy' },
acceptingMessageLabel: { id: 'chat.page_settings.accepting_messages.label', defaultMessage: 'Allow users to start a new chat with you' },
playSoundsLabel: { id: 'chat.page_settings.play_sounds.label', defaultMessage: 'Play a sound when you receive a message' },
submit: { id: 'chat.page_settings.submit', defaultMessage: 'Save' },
});
const ChatPageSettings = () => {
const account = useOwnAccount();
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const settings = useSettings();
const updateCredentials = useUpdateCredentials();
const [data, setData] = useState<FormData>({
chats_onboarded: true,
accepts_chat_messages: account?.accepts_chat_messages,
});
const onToggleChange = (key: string[], checked: boolean) => {
dispatch(changeSetting(key, checked, { showAlert: true }));
};
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
updateCredentials.mutate(data);
};
return (
<Stack className='h-full py-6 px-4 sm:p-6 space-y-8'>
<HStack alignItems='center'>
<IconButton
src={require('@tabler/icons/arrow-left.svg')}
className='sm:hidden h-7 w-7 mr-2 sm:mr-0'
onClick={() => history.push('/chats')}
/>
<CardTitle title={intl.formatMessage(messages.title)} />
</HStack>
<Form onSubmit={handleSubmit}>
<CardTitle title={intl.formatMessage(messages.preferences)} />
<List>
<ListItem
label={intl.formatMessage(messages.playSoundsLabel)}
>
<SettingToggle settings={settings} settingPath={['chats', 'sound']} onChange={onToggleChange} />
</ListItem>
</List>
<CardTitle title={intl.formatMessage(messages.privacy)} />
<CardBody>
<List>
<ListItem
label={intl.formatMessage(messages.acceptingMessageLabel)}
>
<Toggle
checked={data.accepts_chat_messages}
onChange={(event) => setData((prevData) => ({ ...prevData, accepts_chat_messages: event.target.checked }))}
/>
</ListItem>
</List>
</CardBody>
<Button type='submit' theme='primary' disabled={updateCredentials.isLoading}>
{intl.formatMessage(messages.submit)}
</Button>
</Form>
</Stack>
);
};
export default ChatPageSettings;

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { CardTitle, HStack, IconButton, Stack } from 'soapbox/components/ui';
import { useDebounce, useFeatures } from 'soapbox/hooks';
import { IChat } from 'soapbox/queries/chats';
import ChatList from '../../chat-list';
import ChatSearchInput from '../../chat-search-input';
const messages = defineMessages({
title: { id: 'column.chats', defaultMessage: 'Messages' },
});
const ChatPageSidebar = () => {
const intl = useIntl();
const history = useHistory();
const features = useFeatures();
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const handleClickChat = (chat: IChat) => {
history.push(`/chats/${chat.id}`);
};
const handleChatCreate = () => {
history.push('/chats/new');
};
const handleSettingsClick = () => {
history.push('/chats/settings');
};
return (
<Stack space={4} className='h-full'>
<Stack space={4} className='px-4 pt-6'>
<HStack alignItems='center' justifyContent='between'>
<CardTitle title={intl.formatMessage(messages.title)} />
<HStack space={1}>
<IconButton
src={require('@tabler/icons/settings.svg')}
iconClassName='w-5 h-5 text-gray-600'
onClick={handleSettingsClick}
/>
<IconButton
src={require('@tabler/icons/edit.svg')}
iconClassName='w-5 h-5 text-gray-600'
onClick={handleChatCreate}
/>
</HStack>
</HStack>
{features.chatsSearch && (
<ChatSearchInput
value={search}
onChange={e => setSearch(e.target.value)}
onClear={() => setSearch('')}
/>
)}
</Stack>
<Stack className='flex-grow h-full'>
<ChatList
onClickChat={handleClickChat}
searchValue={debouncedSearch}
/>
</Stack>
</Stack>
);
};
export default ChatPageSidebar;

View File

@ -0,0 +1,80 @@
import React, { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import List, { ListItem } from 'soapbox/components/list';
import { Button, CardBody, CardTitle, Form, Stack, Text, Toggle } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import { useUpdateCredentials } from 'soapbox/queries/accounts';
type FormData = {
accepts_chat_messages?: boolean
chats_onboarded: boolean
}
const messages = defineMessages({
title: { id: 'chat.welcome.title', defaultMessage: 'Welcome to {br} Chats!' },
subtitle: { id: 'chat.welcome.subtitle', defaultMessage: 'Exchange private messages with other users.' },
acceptingMessageLabel: { id: 'chat.welcome.accepting_messages.label', defaultMessage: 'Allow users to start a new chat with you' },
notice: { id: 'chat.welcome.notice', defaultMessage: 'You can change these settings later.' },
submit: { id: 'chat.welcome.submit', defaultMessage: 'Save & Continue' },
});
const Welcome = () => {
const account = useOwnAccount();
const intl = useIntl();
const updateCredentials = useUpdateCredentials();
const [data, setData] = useState<FormData>({
chats_onboarded: true,
accepts_chat_messages: account?.accepts_chat_messages,
});
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
updateCredentials.mutate(data);
};
return (
<Stack className='py-20 px-4 sm:px-0' data-testid='chats-welcome'>
<div className='w-full sm:w-3/5 xl:w-2/5 mx-auto mb-10'>
<Text align='center' weight='bold' className='mb-6 text-2xl md:text-3xl leading-8'>
{intl.formatMessage(messages.title, { br: <br /> })}
</Text>
<Text align='center' theme='muted'>
{intl.formatMessage(messages.subtitle)}
</Text>
</div>
<Form onSubmit={handleSubmit} className='space-y-8 w-full sm:w-2/3 lg:w-3/5 sm:mx-auto'>
<Stack space={2}>
<CardTitle title={<FormattedMessage id='chat.page_settings.privacy' defaultMessage='Privacy' />} />
<CardBody>
<List>
<ListItem
label={intl.formatMessage(messages.acceptingMessageLabel)}
>
<Toggle
checked={data.accepts_chat_messages}
onChange={(event) => setData((prevData) => ({ ...prevData, accepts_chat_messages: event.target.checked }))}
/>
</ListItem>
</List>
</CardBody>
</Stack>
<Text align='center' theme='muted'>
{intl.formatMessage(messages.notice)}
</Text>
<Button type='submit' theme='primary' block size='lg' disabled={updateCredentials.isLoading}>
{intl.formatMessage(messages.submit)}
</Button>
</Form>
</Stack>
);
};
export default Welcome;

View File

@ -0,0 +1,60 @@
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { __stub } from 'soapbox/api';
import { ChatContext } from 'soapbox/contexts/chat-context';
import { StatProvider } from 'soapbox/contexts/stat-context';
import chats from 'soapbox/jest/fixtures/chats.json';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import ChatPane from '../chat-pane';
const renderComponentWithChatContext = (store = {}) => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<StatProvider>
<ChatContext.Provider value={{ isOpen: true }}>
<ChatPane />
</ChatContext.Provider>
</StatProvider>
</VirtuosoMockContext.Provider>,
undefined,
store,
);
describe('<ChatPane />', () => {
describe('when there are no chats', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/pleroma/chats').reply(200, [], {
link: null,
});
});
});
it('renders the blankslate', async () => {
renderComponentWithChatContext();
await waitFor(() => {
expect(screen.getByTestId('chat-pane-blankslate')).toBeInTheDocument();
});
});
});
describe('when the software is not Truth Social', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/pleroma/chats').reply(200, chats, {
link: '<https://example.com/api/v1/pleroma/chats?since_id=2>; rel=\'prev\'',
});
});
});
it('does not render the search input', async () => {
renderComponentWithChatContext();
await waitFor(() => {
expect(screen.queryAllByTestId('chat-search-input')).toHaveLength(0);
});
});
});
});

View File

@ -0,0 +1,47 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Button, Stack, Text } from 'soapbox/components/ui';
const messages = defineMessages({
title: { id: 'chat_search.empty_results_blankslate.title', defaultMessage: 'No messages yet' },
body: { id: 'chat_search.empty_results_blankslate.body', defaultMessage: 'You can start a chat with anyone that follows you.' },
action: { id: 'chat_search.empty_results_blankslate.action', defaultMessage: 'Message someone' },
});
interface IBlankslate {
onSearch(): void
}
const Blankslate = ({ onSearch }: IBlankslate) => {
const intl = useIntl();
return (
<Stack
alignItems='center'
justifyContent='center'
className='h-full flex-grow'
data-testid='chat-pane-blankslate'
>
<Stack space={4}>
<Stack space={1} className='max-w-[80%] mx-auto'>
<Text size='lg' weight='bold' align='center'>
{intl.formatMessage(messages.title)}
</Text>
<Text theme='muted' align='center'>
{intl.formatMessage(messages.body)}
</Text>
</Stack>
<div className='mx-auto'>
<Button theme='primary' onClick={onSearch}>
{intl.formatMessage(messages.action)}
</Button>
</div>
</Stack>
</Stack>
);
};
export default Blankslate;

View File

@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Stack } from 'soapbox/components/ui';
import { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context';
import { useStatContext } from 'soapbox/contexts/stat-context';
import { useDebounce, useFeatures } from 'soapbox/hooks';
import { IChat, useChats } from 'soapbox/queries/chats';
import ChatList from '../chat-list';
import ChatSearchInput from '../chat-search-input';
import ChatSearch from '../chat-search/chat-search';
import EmptyResultsBlankslate from '../chat-search/empty-results-blankslate';
import ChatPaneHeader from '../chat-widget/chat-pane-header';
import ChatWindow from '../chat-widget/chat-window';
import { Pane } from '../ui';
import Blankslate from './blankslate';
const ChatPane = () => {
const features = useFeatures();
const debounce = useDebounce;
const { unreadChatsCount } = useStatContext();
const [value, setValue] = useState<string>();
const debouncedValue = debounce(value as string, 300);
const { screen, changeScreen, isOpen, toggleChatPane } = useChatContext();
const { chatsQuery: { data: chats, isLoading } } = useChats(debouncedValue);
const hasSearchValue = Number(debouncedValue?.length) > 0;
const handleClickChat = (nextChat: IChat) => {
changeScreen(ChatWidgetScreens.CHAT, nextChat.id);
setValue(undefined);
};
const clearValue = () => {
if (hasSearchValue) {
setValue('');
}
};
const renderBody = () => {
if (hasSearchValue || Number(chats?.length) > 0 || isLoading) {
return (
<Stack space={4} className='flex-grow h-full'>
{features.chatsSearch && (
<div className='px-4'>
<ChatSearchInput
value={value || ''}
onChange={(event) => setValue(event.target.value)}
onClear={clearValue}
/>
</div>
)}
{(Number(chats?.length) > 0 || isLoading) ? (
<ChatList
searchValue={debouncedValue}
onClickChat={handleClickChat}
/>
) : (
<EmptyResultsBlankslate />
)}
</Stack>
);
} else if (chats?.length === 0) {
return (
<Blankslate
onSearch={() => {
changeScreen(ChatWidgetScreens.SEARCH);
}}
/>
);
}
};
// Active chat
if (screen === ChatWidgetScreens.CHAT || screen === ChatWidgetScreens.CHAT_SETTINGS) {
return (
<Pane isOpen={isOpen} index={0} main>
<ChatWindow />
</Pane>
);
}
if (screen === ChatWidgetScreens.SEARCH) {
return <ChatSearch />;
}
return (
<Pane isOpen={isOpen} index={0} main>
<ChatPaneHeader
title={<FormattedMessage id='column.chats' defaultMessage='Chats' />}
unreadCount={unreadChatsCount}
isOpen={isOpen}
onToggle={toggleChatPane}
secondaryAction={() => {
changeScreen(ChatWidgetScreens.SEARCH);
setValue(undefined);
if (!isOpen) {
toggleChatPane();
}
}}
secondaryActionIcon={require('@tabler/icons/edit.svg')}
/>
{isOpen ? renderBody() : null}
</Pane>
);
};
export default ChatPane;

View File

@ -1,108 +0,0 @@
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { createSelector } from 'reselect';
import { openChat, launchChat, toggleMainWindow } from 'soapbox/actions/chats';
import { getSettings } from 'soapbox/actions/settings';
import AccountSearch from 'soapbox/components/account-search';
import { Counter } from 'soapbox/components/ui';
import AudioToggle from 'soapbox/features/chats/components/audio-toggle';
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
import { RootState } from 'soapbox/store';
import { Chat } from 'soapbox/types/entities';
import ChatList from './chat-list';
import ChatWindow from './chat-window';
const messages = defineMessages({
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },
});
const getChatsUnreadCount = (state: RootState) => {
const chats = state.chats.items;
return chats.reduce((acc, curr) => acc + Math.min(curr.get('unread', 0), 1), 0);
};
// Filter out invalid chats
const normalizePanes = (chats: Immutable.Map<string, Chat>, panes = ImmutableList<ImmutableMap<string, any>>()) => (
panes.filter(pane => chats.get(pane.get('chat_id')))
);
const makeNormalizeChatPanes = () => createSelector([
(state: RootState) => state.chats.items,
(state: RootState) => getSettings(state).getIn(['chats', 'panes']) as any,
], normalizePanes);
const normalizeChatPanes = makeNormalizeChatPanes();
const ChatPanes = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const history = useHistory();
const panes = useAppSelector((state) => normalizeChatPanes(state));
const mainWindowState = useSettings().getIn(['chats', 'mainWindow']);
const unreadCount = useAppSelector((state) => getChatsUnreadCount(state));
const handleClickChat = ((chat: Chat) => {
dispatch(openChat(chat.id));
});
const handleSuggestion = (accountId: string) => {
dispatch(launchChat(accountId, history));
};
const handleMainWindowToggle = () => {
dispatch(toggleMainWindow());
};
const open = mainWindowState === 'open';
const mainWindowPane = (
<div className={`pane pane--main pane--${mainWindowState}`}>
<div className='pane__header'>
{unreadCount > 0 && (
<div className='mr-2 flex-none'>
<Counter count={unreadCount} />
</div>
)}
<button className='pane__title' onClick={handleMainWindowToggle}>
<FormattedMessage id='chat_panels.main_window.title' defaultMessage='Chats' />
</button>
<AudioToggle />
</div>
<div className='pane__content'>
{open && (
<>
<ChatList
onClickChat={handleClickChat}
/>
<AccountSearch
placeholder={intl.formatMessage(messages.searchPlaceholder)}
onSelected={handleSuggestion}
resultsPosition='above'
/>
</>
)}
</div>
</div>
);
return (
<div className='chat-panes'>
{mainWindowPane}
{panes.map((pane, i) => (
<ChatWindow
idx={i}
key={pane.get('chat_id')}
chatId={pane.get('chat_id')}
windowState={pane.get('state')}
/>
))}
</div>
);
};
export default ChatPanes;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Icon, Input } from 'soapbox/components/ui';
const messages = defineMessages({
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Search inbox' },
});
interface IChatSearchInput {
/** Search term. */
value: string,
/** Callback when the search value changes. */
onChange: React.ChangeEventHandler<HTMLInputElement>,
/** Callback when the input is cleared. */
onClear: React.MouseEventHandler<HTMLButtonElement>,
}
/** Search input for filtering chats. */
const ChatSearchInput: React.FC<IChatSearchInput> = ({ value, onChange, onClear }) => {
const intl = useIntl();
return (
<Input
data-testid='chat-search-input'
type='text'
autoFocus
placeholder={intl.formatMessage(messages.searchPlaceholder)}
className='rounded-full'
value={value}
onChange={onChange}
theme='search'
append={
<button onClick={onClear}>
<Icon
src={value.length ? require('@tabler/icons/x.svg') : require('@tabler/icons/search.svg')}
className='h-4 w-4 text-gray-700 dark:text-gray-600'
aria-hidden='true'
/>
</button>
}
/>
);
};
export default ChatSearchInput;

View File

@ -0,0 +1,65 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { __stub } from 'soapbox/api';
import { ChatProvider } from 'soapbox/contexts/chat-context';
import { render, screen, waitFor } from '../../../../../jest/test-helpers';
import ChatSearch from '../chat-search';
const renderComponent = () => render(
<ChatProvider>
<ChatSearch />
</ChatProvider>,
);
describe('<ChatSearch />', () => {
it('renders correctly', () => {
renderComponent();
expect(screen.getByTestId('pane-header')).toHaveTextContent('Messages');
});
describe('when the pane is closed', () => {
it('does not render the search input', () => {
renderComponent();
expect(screen.queryAllByTestId('search')).toHaveLength(0);
});
});
describe('when the pane is open', () => {
beforeEach(async() => {
renderComponent();
await userEvent.click(screen.getByTestId('icon-button'));
});
it('renders the search input', () => {
expect(screen.getByTestId('search')).toBeInTheDocument();
});
describe('when searching', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/accounts/search').reply(200, [{
id: '1',
avatar: 'url',
verified: false,
display_name: 'steve',
acct: 'sjobs',
}]);
});
});
it('renders accounts', async() => {
renderComponent();
const user = userEvent.setup();
await user.type(screen.getByTestId('search'), 'ste');
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
});
});
});
});

View File

@ -0,0 +1,26 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Stack, Text } from 'soapbox/components/ui';
const messages = defineMessages({
title: { id: 'chat_search.blankslate.title', defaultMessage: 'Search followers' },
body: { id: 'chat_search.blankslate.body', defaultMessage: 'You can start a chat with anyone that follows you.' },
});
const Blankslate = () => {
const intl = useIntl();
return (
<Stack justifyContent='center' alignItems='center' space={2} className='h-full w-2/3 mx-auto'>
<Text weight='bold' size='lg' align='center'>
{intl.formatMessage(messages.title)}
</Text>
<Text theme='muted' align='center'>
{intl.formatMessage(messages.body)}
</Text>
</Stack>
);
};
export default Blankslate;

View File

@ -0,0 +1,139 @@
import { useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import { HStack, Icon, Input, Stack, Text } from 'soapbox/components/ui';
import { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useDebounce } from 'soapbox/hooks';
import { useChats } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import useAccountSearch from 'soapbox/queries/search';
import { ChatKeys } from '../../../../queries/chats';
import ChatPaneHeader from '../chat-widget/chat-pane-header';
import { Pane } from '../ui';
import Blankslate from './blankslate';
import EmptyResultsBlankslate from './empty-results-blankslate';
import Results from './results';
const messages = defineMessages({
title: { id: 'chat_search.title', defaultMessage: 'Messages' },
});
const ChatSearch = () => {
const debounce = useDebounce;
const dispatch = useAppDispatch();
const intl = useIntl();
const { isOpen, changeScreen, toggleChatPane } = useChatContext();
const { getOrCreateChatByAccountId } = useChats();
const [value, setValue] = useState<string>('');
const debouncedValue = debounce(value as string, 300);
const { data: accounts, isFetching } = useAccountSearch(debouncedValue);
const hasSearchValue = debouncedValue && debouncedValue.length > 0;
const hasSearchResults = (accounts || []).length > 0;
const handleClickOnSearchResult = useMutation((accountId: string) => {
return getOrCreateChatByAccountId(accountId);
}, {
onError: (error: AxiosError) => {
const data = error.response?.data as any;
dispatch(snackbar.error(data?.error));
},
onSuccess: (response) => {
changeScreen(ChatWidgetScreens.CHAT, response.data.id);
queryClient.invalidateQueries(ChatKeys.chatSearch());
},
});
const renderBody = () => {
if (hasSearchResults) {
return (
<Results
accounts={accounts}
onSelect={(id) => {
handleClickOnSearchResult.mutate(id);
clearValue();
}}
/>
);
} else if (hasSearchValue && !hasSearchResults && !isFetching) {
return <EmptyResultsBlankslate />;
} else {
return <Blankslate />;
}
};
const clearValue = () => {
if (hasSearchValue) {
setValue('');
}
};
return (
<Pane isOpen={isOpen} index={0} main>
<ChatPaneHeader
data-testid='pane-header'
title={
<HStack alignItems='center' space={2}>
<button
onClick={() => {
changeScreen(ChatWidgetScreens.INBOX);
}}
>
<Icon
src={require('@tabler/icons/arrow-left.svg')}
className='h-6 w-6 text-gray-600 dark:text-gray-400'
/>
</button>
<Text size='sm' weight='bold' truncate>
{intl.formatMessage(messages.title)}
</Text>
</HStack>
}
isOpen={isOpen}
isToggleable={false}
onToggle={toggleChatPane}
/>
{isOpen ? (
<Stack space={4} className='flex-grow h-full'>
<div className='px-4'>
<Input
data-testid='search'
type='text'
autoFocus
placeholder='Type a name'
className='rounded-full'
value={value || ''}
onChange={(event) => setValue(event.target.value)}
theme='search'
append={
<button onClick={clearValue}>
<Icon
src={hasSearchValue ? require('@tabler/icons/x.svg') : require('@tabler/icons/search.svg')}
className='h-4 w-4 text-gray-700 dark:text-gray-600'
aria-hidden='true'
/>
</button>
}
/>
</div>
<Stack className='overflow-y-scroll flex-grow h-full' space={2}>
{renderBody()}
</Stack>
</Stack>
) : null}
</Pane>
);
};
export default ChatSearch;

View File

@ -0,0 +1,27 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Stack, Text } from 'soapbox/components/ui';
const messages = defineMessages({
title: { id: 'chat_search.empty_results_blankslate.title', defaultMessage: 'No matches found' },
body: { id: 'chat_search.empty_results_blankslate.body', defaultMessage: 'Try searching for another name.' },
});
const EmptyResultsBlankslate = () => {
const intl = useIntl();
return (
<Stack justifyContent='center' alignItems='center' space={2} className='h-full w-2/3 mx-auto'>
<Text weight='bold' size='lg' align='center'>
{intl.formatMessage(messages.title)}
</Text>
<Text theme='muted' align='center'>
{intl.formatMessage(messages.body)}
</Text>
</Stack>
);
};
export default EmptyResultsBlankslate;

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge';
interface IResults {
accounts: {
display_name: string
acct: string
id: string
avatar: string
verified: boolean
}[]
onSelect(id: string): void
}
const Results = ({ accounts, onSelect }: IResults) => (
<>
{(accounts || []).map((account: any) => (
<button
key={account.id}
type='button'
className='px-4 py-2 w-full flex flex-col hover:bg-gray-100 dark:hover:bg-gray-800'
onClick={() => onSelect(account.id)}
data-testid='account'
>
<HStack alignItems='center' space={2}>
<Avatar src={account.avatar} size={40} />
<Stack alignItems='start'>
<div className='flex items-center space-x-1 flex-grow'>
<Text weight='bold' size='sm' truncate>{account.display_name}</Text>
{account.verified && <VerificationBadge />}
</div>
<Text size='sm' weight='medium' theme='muted' truncate>@{account.acct}</Text>
</Stack>
</HStack>
</button>
))}
</>
);
export default Results;

View File

@ -0,0 +1,76 @@
import React, { HTMLAttributes } from 'react';
import { HStack, IconButton, Text } from 'soapbox/components/ui';
interface IChatPaneHeader {
isOpen: boolean
isToggleable?: boolean
onToggle(): void
title: string | React.ReactNode
unreadCount?: number
secondaryAction?(): void
secondaryActionIcon?: string
}
const ChatPaneHeader = (props: IChatPaneHeader) => {
const {
isOpen,
isToggleable = true,
onToggle,
secondaryAction,
secondaryActionIcon,
title,
unreadCount,
...rest
} = props;
const ButtonComp = isToggleable ? 'button' : 'div';
const buttonProps: HTMLAttributes<HTMLButtonElement | HTMLDivElement> = {};
if (isToggleable) {
buttonProps.onClick = onToggle;
}
return (
<HStack {...rest} alignItems='center' justifyContent='between' className='rounded-t-xl h-16 py-3 px-4'>
<ButtonComp
className='flex-grow flex items-center flex-row space-x-1 h-16'
data-testid='title'
{...buttonProps}
>
{typeof title === 'string' ? (
<Text weight='semibold'>
{title}
</Text>
) : (title)}
{(typeof unreadCount !== 'undefined' && unreadCount > 0) && (
<HStack alignItems='center' space={2}>
<Text weight='semibold' data-testid='unread-count'>
({unreadCount})
</Text>
<div className='bg-accent-300 w-2.5 h-2.5 rounded-full' />
</HStack>
)}
</ButtonComp>
<HStack space={2} alignItems='center'>
{secondaryAction ? (
<IconButton
onClick={secondaryAction}
src={secondaryActionIcon as string}
iconClassName='w-5 h-5 text-gray-600'
/>
) : null}
<IconButton
onClick={onToggle}
src={isOpen ? require('@tabler/icons/chevron-down.svg') : require('@tabler/icons/chevron-up.svg')}
iconClassName='w-5 h-5 text-gray-600'
/>
</HStack>
</HStack>
);
};
export default ChatPaneHeader;

View File

@ -0,0 +1,155 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { blockAccount, unblockAccount } from 'soapbox/actions/accounts';
import { openModal } from 'soapbox/actions/modals';
import List, { ListItem } from 'soapbox/components/list';
import { Avatar, HStack, Icon, Select, Stack, Text } from 'soapbox/components/ui';
import { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { messageExpirationOptions, MessageExpirationValues, useChatActions } from 'soapbox/queries/chats';
import { secondsToDays } from 'soapbox/utils/numbers';
import ChatPaneHeader from './chat-pane-header';
const messages = defineMessages({
blockMessage: { id: 'chat_settings.block.message', defaultMessage: 'Blocking will prevent this profile from direct messaging you and viewing your content. You can unblock later.' },
blockHeading: { id: 'chat_settings.block.heading', defaultMessage: 'Block @{acct}' },
blockConfirm: { id: 'chat_settings.block.confirm', defaultMessage: 'Block' },
unblockMessage: { id: 'chat_settings.unblock.message', defaultMessage: 'Unblocking will allow this profile to direct message you and view your content.' },
unblockHeading: { id: 'chat_settings.unblock.heading', defaultMessage: 'Unblock @{acct}' },
unblockConfirm: { id: 'chat_settings.unblock.confirm', defaultMessage: 'Unblock' },
leaveMessage: { id: 'chat_settings.leave.message', defaultMessage: 'Are you sure you want to leave this chat? Messages will be deleted for you and this chat will be removed from your inbox.' },
leaveHeading: { id: 'chat_settings.leave.heading', defaultMessage: 'Leave Chat' },
leaveConfirm: { id: 'chat_settings.leave.confirm', defaultMessage: 'Leave Chat' },
title: { id: 'chat_settings.title', defaultMessage: 'Chat Details' },
blockUser: { id: 'chat_settings.options.block_user', defaultMessage: 'Block @{acct}' },
unblockUser: { id: 'chat_settings.options.unblock_user', defaultMessage: 'Unblock @{acct}' },
leaveChat: { id: 'chat_settings.options.leave_chat', defaultMessage: 'Leave Chat' },
autoDeleteLabel: { id: 'chat_settings.auto_delete.label', defaultMessage: 'Auto-delete messages' },
autoDeleteDays: { id: 'chat_settings.auto_delete.days', defaultMessage: '{day} days' },
});
const ChatSettings = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const { chat, changeScreen, toggleChatPane } = useChatContext();
const { deleteChat, updateChat } = useChatActions(chat?.id as string);
const handleUpdateChat = (value: MessageExpirationValues) => updateChat.mutate({ message_expiration: value });
const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking']));
const closeSettings = () => {
changeScreen(ChatWidgetScreens.CHAT, chat?.id);
};
const minimizeChatPane = () => {
closeSettings();
toggleChatPane();
};
const handleBlockUser = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.blockHeading, { acct: chat?.account.acct }),
message: intl.formatMessage(messages.blockMessage),
confirm: intl.formatMessage(messages.blockConfirm),
confirmationTheme: 'primary',
onConfirm: () => dispatch(blockAccount(chat?.account.id as string)),
}));
};
const handleUnblockUser = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.unblockHeading, { acct: chat?.account.acct }),
message: intl.formatMessage(messages.unblockMessage),
confirm: intl.formatMessage(messages.unblockConfirm),
confirmationTheme: 'primary',
onConfirm: () => dispatch(unblockAccount(chat?.account.id as string)),
}));
};
const handleLeaveChat = () => {
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.leaveHeading),
message: intl.formatMessage(messages.leaveMessage),
confirm: intl.formatMessage(messages.leaveConfirm),
confirmationTheme: 'primary',
onConfirm: () => deleteChat.mutate(),
}));
};
if (!chat) {
return null;
}
return (
<>
<ChatPaneHeader
isOpen
isToggleable={false}
onToggle={minimizeChatPane}
title={
<HStack alignItems='center' space={2}>
<button onClick={closeSettings}>
<Icon
src={require('@tabler/icons/arrow-left.svg')}
className='h-6 w-6 text-gray-600 dark:text-gray-400'
/>
</button>
<Text weight='semibold'>
{intl.formatMessage(messages.title)}
</Text>
</HStack>
}
/>
<Stack space={4} className='w-5/6 mx-auto'>
<HStack alignItems='center' space={3}>
<Avatar src={chat.account.avatar_static} size={50} />
<Stack>
<Text weight='semibold'>{chat.account.display_name}</Text>
<Text size='sm' theme='primary'>@{chat.account.acct}</Text>
</Stack>
</HStack>
{features.chatsExpiration && (
<List>
<ListItem label={intl.formatMessage(messages.autoDeleteLabel)}>
<Select defaultValue={chat.message_expiration} onChange={(event) => handleUpdateChat(Number(event.target.value))}>
{messageExpirationOptions.map((duration) => {
const inDays = secondsToDays(duration);
return (
<option key={duration} value={duration}>
{intl.formatMessage(messages.autoDeleteDays, { day: inDays })}
</option>
);
})}
</Select>
</ListItem>
</List>
)}
<Stack space={5}>
<button onClick={isBlocking ? handleUnblockUser : handleBlockUser} className='w-full flex items-center space-x-2 font-bold text-sm text-primary-600 dark:text-accent-blue'>
<Icon src={require('@tabler/icons/ban.svg')} className='w-5 h-5' />
<span>{intl.formatMessage(isBlocking ? messages.unblockUser : messages.blockUser, { acct: chat.account.acct })}</span>
</button>
{features.chatsDelete && (
<button onClick={handleLeaveChat} className='w-full flex items-center space-x-2 font-bold text-sm text-danger-600'>
<Icon src={require('@tabler/icons/logout.svg')} className='w-5 h-5' />
<span>{intl.formatMessage(messages.leaveChat)}</span>
</button>
)}
</Stack>
</Stack>
</>
);
};
export default ChatSettings;

View File

@ -0,0 +1,27 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import { ChatProvider } from 'soapbox/contexts/chat-context';
import { useOwnAccount } from 'soapbox/hooks';
import ChatPane from '../chat-pane/chat-pane';
const ChatWidget = () => {
const account = useOwnAccount();
const history = useHistory();
const path = history.location.pathname;
const shouldHideWidget = Boolean(path.match(/^\/chats/));
if (!account?.chats_onboarded || shouldHideWidget) {
return null;
}
return (
<ChatProvider>
<ChatPane />
</ChatProvider>
);
};
export default ChatWidget;

View File

@ -0,0 +1,123 @@
import React, { useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Avatar, HStack, Icon, Stack, Text, Tooltip } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification-badge';
import { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context';
import { secondsToDays } from 'soapbox/utils/numbers';
import Chat from '../chat';
import ChatPaneHeader from './chat-pane-header';
import ChatSettings from './chat-settings';
const messages = defineMessages({
autoDeleteMessage: { id: 'chat_window.auto_delete_label', defaultMessage: 'Auto-delete after {day} days' },
autoDeleteMessageTooltip: { id: 'chat_window.auto_delete_tooltip', defaultMessage: 'Chat messages are set to auto-delete after {day} days upon sending.' },
});
const LinkWrapper = ({ enabled, to, children }: { enabled: boolean, to: string, children: React.ReactNode }): JSX.Element => {
if (!enabled) {
return <>{children}</>;
}
return (
<Link to={to}>
{children}
</Link>
);
};
/** Floating desktop chat window. */
const ChatWindow = () => {
const intl = useIntl();
const { chat, currentChatId, screen, changeScreen, isOpen, needsAcceptance, toggleChatPane } = useChatContext();
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const closeChat = () => {
changeScreen(ChatWidgetScreens.INBOX);
};
const openSearch = () => {
toggleChatPane();
changeScreen(ChatWidgetScreens.SEARCH);
};
const openChatSettings = () => {
changeScreen(ChatWidgetScreens.CHAT_SETTINGS, currentChatId);
};
const secondaryAction = () => {
if (needsAcceptance) {
return undefined;
}
return isOpen ? openChatSettings : openSearch;
};
if (!chat) return null;
if (screen === ChatWidgetScreens.CHAT_SETTINGS) {
return <ChatSettings />;
}
return (
<>
<ChatPaneHeader
title={
<HStack alignItems='center' space={2}>
{isOpen && (
<button onClick={closeChat}>
<Icon
src={require('@tabler/icons/arrow-left.svg')}
className='h-6 w-6 text-gray-600 dark:text-gray-400'
/>
</button>
)}
<HStack alignItems='center' space={3}>
{isOpen && (
<Link to={`/@${chat.account.acct}`}>
<Avatar src={chat.account.avatar} size={40} />
</Link>
)}
<Stack alignItems='start'>
<LinkWrapper enabled={isOpen} to={`/@${chat.account.acct}`}>
<div className='flex items-center space-x-1 flex-grow'>
<Text size='sm' weight='bold' truncate>{chat.account.display_name || `@${chat.account.acct}`}</Text>
{chat.account.verified && <VerificationBadge />}
</div>
</LinkWrapper>
{chat.message_expiration && (
<Tooltip
text={intl.formatMessage(messages.autoDeleteMessageTooltip, { day: secondsToDays(chat.message_expiration) })}
>
<Text size='sm' weight='medium' theme='primary' truncate className='cursor-help'>
{intl.formatMessage(messages.autoDeleteMessage, { day: secondsToDays(chat.message_expiration) })}
</Text>
</Tooltip>
)}
</Stack>
</HStack>
</HStack>
}
secondaryAction={secondaryAction()}
secondaryActionIcon={isOpen ? require('@tabler/icons/info-circle.svg') : require('@tabler/icons/edit.svg')}
isToggleable={!isOpen}
isOpen={isOpen}
onToggle={toggleChatPane}
/>
<Stack className='overflow-hidden flex-grow h-full' space={2}>
<Chat chat={chat} inputRef={inputRef} />
</Stack>
</>
);
};
export default ChatWindow;

View File

@ -1,120 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import {
closeChat,
toggleChat,
} from 'soapbox/actions/chats';
import Avatar from 'soapbox/components/avatar';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
import IconButton from 'soapbox/components/icon-button';
import { HStack, Counter } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetChat } from 'soapbox/selectors';
import { getAcct } from 'soapbox/utils/accounts';
import { displayFqn as getDisplayFqn } from 'soapbox/utils/state';
import ChatBox from './chat-box';
import type { Account as AccountEntity } from 'soapbox/types/entities';
const messages = defineMessages({
close: { id: 'chat_window.close', defaultMessage: 'Close chat' },
});
type WindowState = 'open' | 'minimized';
const getChat = makeGetChat();
interface IChatWindow {
/** Position of the chat window on the screen, where 0 is rightmost. */
idx: number,
/** ID of the chat entity. */
chatId: string,
/** Whether the window is open or minimized. */
windowState: WindowState,
}
/** Floating desktop chat window. */
const ChatWindow: React.FC<IChatWindow> = ({ idx, chatId, windowState }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const displayFqn = useAppSelector(getDisplayFqn);
const chat = useAppSelector(state => {
const chat = state.chats.items.get(chatId);
return chat ? getChat(state, chat.toJS() as any) : undefined;
});
const inputElem = useRef<HTMLTextAreaElement | null>(null);
const handleChatClose = (chatId: string) => {
return () => {
dispatch(closeChat(chatId));
};
};
const handleChatToggle = (chatId: string) => {
return () => {
dispatch(toggleChat(chatId));
};
};
const handleInputRef = (el: HTMLTextAreaElement) => {
inputElem.current = el;
};
const focusInput = () => {
inputElem.current?.focus();
};
useEffect(() => {
if (windowState === 'open') {
focusInput();
}
}, [windowState]);
if (!chat) return null;
const account = chat.account as unknown as AccountEntity;
const right = (285 * (idx + 1)) + 20;
const unreadCount = chat.unread;
const unreadIcon = (
<div className='mr-2 flex-none'>
<Counter count={unreadCount} />
</div>
);
const avatar = (
<HoverRefWrapper accountId={account.id}>
<Link to={`/@${account.acct}`}>
<Avatar account={account} size={18} />
</Link>
</HoverRefWrapper>
);
return (
<div className={`pane pane--${windowState}`} style={{ right: `${right}px` }}>
<HStack space={2} className='pane__header'>
{unreadCount > 0 ? unreadIcon : avatar }
<button className='pane__title' onClick={handleChatToggle(chat.id)}>
@{getAcct(account, displayFqn)}
</button>
<div className='pane__close'>
<IconButton src={require('@tabler/icons/x.svg')} title={intl.formatMessage(messages.close)} onClick={handleChatClose(chat.id)} />
</div>
</HStack>
<div className='pane__content'>
<ChatBox
chatId={chat.id}
onSetInputRef={handleInputRef}
/>
</div>
</div>
);
};
export default ChatWindow;

View File

@ -1,71 +1,198 @@
import React, { useCallback } from 'react'; import { AxiosError } from 'axios';
import { FormattedMessage } from 'react-intl'; import classNames from 'clsx';
import React, { MutableRefObject, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import DisplayName from 'soapbox/components/display-name'; import { uploadMedia } from 'soapbox/actions/media';
import Icon from 'soapbox/components/icon'; import { Stack } from 'soapbox/components/ui';
import { Avatar, Counter, HStack, Stack, Text } from 'soapbox/components/ui'; import Upload from 'soapbox/components/upload';
import emojify from 'soapbox/features/emoji/emoji'; import UploadProgress from 'soapbox/components/upload-progress';
import { useAppSelector } from 'soapbox/hooks'; import { useAppDispatch } from 'soapbox/hooks';
import { makeGetChat } from 'soapbox/selectors'; import { normalizeAttachment } from 'soapbox/normalizers';
import { IChat, useChatActions } from 'soapbox/queries/chats';
import type { Account as AccountEntity, Chat as ChatEntity } from 'soapbox/types/entities'; import ChatComposer from './chat-composer';
import ChatMessageList from './chat-message-list';
interface IChat { const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000));
chatId: string,
onClick: (chat: any) => void, const messages = defineMessages({
failedToSend: { id: 'chat.failed_to_send', defaultMessage: 'Message failed to send.' },
});
interface ChatInterface {
chat: IChat,
inputRef?: MutableRefObject<HTMLTextAreaElement | null>,
className?: string,
} }
const Chat: React.FC<IChat> = ({ chatId, onClick }) => { /**
const getChat = useCallback(makeGetChat(), []); * Clears the value of the input while dispatching the `onChange` function
const chat = useAppSelector((state) => { * which allows the <Textarea> to resize itself (this is important)
const chat = state.chats.items.get(chatId); * because we autoGrow the element as the user inputs text that spans
return chat ? getChat(state, (chat as any).toJS()) : undefined; * beyond one line
}) as ChatEntity; */
const clearNativeInputValue = (element: HTMLTextAreaElement) => {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(element, '');
const account = chat.account as AccountEntity; const ev2 = new Event('input', { bubbles: true });
if (!chat || !account) return null; element.dispatchEvent(ev2);
const unreadCount = chat.unread; }
const content = chat.getIn(['last_message', 'content']); };
const attachment = chat.getIn(['last_message', 'attachment']);
const image = attachment && (attachment as any).getIn(['pleroma', 'mime_type'], '').startsWith('image/'); /**
const parsedContent = content ? emojify(content) : ''; * Chat UI with just the messages and textarea.
* Reused between floating desktop chats and fullscreen/mobile chats.
*/
const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { createChatMessage, acceptChat } = useChatActions(chat.id);
const [content, setContent] = useState<string>('');
const [attachment, setAttachment] = useState<any>(undefined);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [resetFileKey, setResetFileKey] = useState<number>(fileKeyGen());
const [errorMessage, setErrorMessage] = useState<string>();
const isSubmitDisabled = content.length === 0 && !attachment;
const submitMessage = () => {
createChatMessage.mutate({ chatId: chat.id, content, mediaId: attachment?.id }, {
onSuccess: () => {
setErrorMessage(undefined);
},
onError: (error: AxiosError<{ error: string }>, _variables, context) => {
const message = error.response?.data?.error;
setErrorMessage(message || intl.formatMessage(messages.failedToSend));
setContent(context.prevContent as string);
},
});
clearState();
};
const clearState = () => {
if (inputRef?.current) {
clearNativeInputValue(inputRef.current);
}
setContent('');
setAttachment(undefined);
setIsUploading(false);
setUploadProgress(0);
setResetFileKey(fileKeyGen());
};
const sendMessage = () => {
if (!isSubmitDisabled && !createChatMessage.isLoading) {
submitMessage();
if (!chat.accepted) {
acceptChat.mutate();
}
}
};
const insertLine = () => setContent(content + '\n');
const handleKeyDown: React.KeyboardEventHandler = (event) => {
markRead();
if (event.key === 'Enter' && event.shiftKey) {
event.preventDefault();
insertLine();
} else if (event.key === 'Enter') {
event.preventDefault();
sendMessage();
}
};
const handleContentChange: React.ChangeEventHandler<HTMLTextAreaElement> = (event) => {
setContent(event.target.value);
};
const handlePaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
if (isSubmitDisabled && e.clipboardData && e.clipboardData.files.length === 1) {
handleFiles(e.clipboardData.files);
}
};
const markRead = () => {
// markAsRead.mutate();
// dispatch(markChatRead(chatId));
};
const handleMouseOver = () => markRead();
const handleRemoveFile = () => {
setAttachment(undefined);
setResetFileKey(fileKeyGen());
};
const onUploadProgress = (e: ProgressEvent) => {
const { loaded, total } = e;
setUploadProgress(loaded / total);
};
const handleFiles = (files: FileList) => {
setIsUploading(true);
const data = new FormData();
data.append('file', files[0]);
dispatch(uploadMedia(data, onUploadProgress)).then((response: any) => {
setAttachment(normalizeAttachment(response.data));
setIsUploading(false);
}).catch(() => {
setIsUploading(false);
});
};
useEffect(() => {
if (inputRef?.current) {
inputRef.current.focus();
}
}, [chat.id, inputRef?.current]);
return ( return (
<div className='account'> <Stack className={classNames('overflow-hidden flex flex-grow', className)} onMouseOver={handleMouseOver}>
<button className='floating-link' onClick={() => onClick(chat)} /> <div className='flex-grow h-full overflow-hidden flex justify-center'>
<HStack key={account.id} space={3} className='relative overflow-hidden'> <ChatMessageList chat={chat} />
<Avatar className='flex-none' src={account.avatar} size={36} /> </div>
<Stack className='overflow-hidden flex-1'>
<DisplayName account={account} withSuffix={false} /> {attachment && (
<HStack space={1} justifyContent='between'> <div className='relative h-48'>
{content ? ( <Upload
<Text media={attachment}
theme='muted' onDelete={handleRemoveFile}
size='sm' withPreview
className='max-h-5' />
dangerouslySetInnerHTML={{ __html: parsedContent }} </div>
truncate )}
/>
) : attachment && ( {isUploading && (
<Text theme='muted' size='sm' className='italic'> <div className='p-4'>
{image ? <FormattedMessage id='chats.attachment_image' defaultMessage='Image' /> : <FormattedMessage id='chats.attachment' defaultMessage='Attachment' />} <UploadProgress progress={uploadProgress * 100} />
</Text> </div>
)} )}
{attachment && (
<Icon <ChatComposer
className='chat__attachment-icon' ref={inputRef}
src={image ? require('@tabler/icons/photo.svg') : require('@tabler/icons/paperclip.svg')} onKeyDown={handleKeyDown}
/> value={content}
)} onChange={handleContentChange}
</HStack> onSubmit={sendMessage}
{unreadCount > 0 && ( errorMessage={errorMessage}
<div className='absolute top-1 right-0'> onSelectFile={handleFiles}
<Counter count={unreadCount} /> resetFileKey={resetFileKey}
</div> onPaste={handlePaste}
)} hasAttachment={!!attachment}
</Stack> />
</HStack> </Stack>
</div>
); );
}; };

View File

@ -0,0 +1 @@
export { Pane } from './pane';

View File

@ -0,0 +1,33 @@
import classNames from 'clsx';
import React from 'react';
interface IPane {
/** Whether the pane is open or minimized. */
isOpen: boolean,
/** Positions the pane on the screen, with 0 at the right. */
index: number,
/** Children to display in the pane. */
children: React.ReactNode,
/** Whether this is the main chat pane. */
main?: boolean,
}
/** Chat pane UI component for desktop. */
const Pane: React.FC<IPane> = ({ isOpen = false, index, children, main = false }) => {
const right = (404 * index) + 20;
return (
<div
className={classNames('flex flex-col shadow-3xl bg-white dark:bg-gray-900 rounded-t-lg fixed bottom-0 right-1 w-96 z-[99]', {
'h-[550px] max-h-[100vh]': isOpen,
'h-16': !isOpen,
})}
style={{ right: `${right}px` }}
data-testid='pane'
>
{children}
</div>
);
};
export { Pane };

View File

@ -1,51 +1,19 @@
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { launchChat } from 'soapbox/actions/chats'; import { ChatProvider } from 'soapbox/contexts/chat-context';
import AccountSearch from 'soapbox/components/account-search';
import AudioToggle from 'soapbox/features/chats/components/audio-toggle';
import { Column } from '../../components/ui'; import ChatPage from './components/chat-page/chat-page';
import ChatList from './components/chat-list'; interface IChatIndex {
params?: {
chatId?: string,
}
}
const messages = defineMessages({ const ChatIndex: React.FC<IChatIndex> = ({ params }) => (
title: { id: 'column.chats', defaultMessage: 'Chats' }, <ChatProvider>
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' }, <ChatPage chatId={params?.chatId} />
}); </ChatProvider>
);
const ChatIndex: React.FC = () => {
const intl = useIntl();
const dispatch = useDispatch();
const history = useHistory();
const handleSuggestion = (accountId: string) => {
dispatch(launchChat(accountId, history, true));
};
const handleClickChat = (chat: { id: string }) => {
history.push(`/chats/${chat.id}`);
};
return (
<Column label={intl.formatMessage(messages.title)}>
<div className='column__switch'>
<AudioToggle />
</div>
<AccountSearch
placeholder={intl.formatMessage(messages.searchPlaceholder)}
onSelected={handleSuggestion}
/>
<ChatList
onClickChat={handleClickChat}
useWindowScroll
/>
</Column>
);
};
export default ChatIndex; export default ChatIndex;

View File

@ -20,6 +20,8 @@ export interface IUploadButton {
onSelectFile: (files: FileList, intl: IntlShape) => void, onSelectFile: (files: FileList, intl: IntlShape) => void,
style?: React.CSSProperties, style?: React.CSSProperties,
resetFileKey: number | null, resetFileKey: number | null,
className?: string,
iconClassName?: string,
} }
const UploadButton: React.FC<IUploadButton> = ({ const UploadButton: React.FC<IUploadButton> = ({
@ -27,6 +29,8 @@ const UploadButton: React.FC<IUploadButton> = ({
unavailable = false, unavailable = false,
onSelectFile, onSelectFile,
resetFileKey, resetFileKey,
className = 'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500',
iconClassName,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { configuration } = useInstance(); const { configuration } = useInstance();
@ -56,7 +60,8 @@ const UploadButton: React.FC<IUploadButton> = ({
<div> <div>
<IconButton <IconButton
src={src} src={src}
className='text-gray-600 hover:text-gray-700 dark:hover:text-gray-500' className={className}
iconClassName={iconClassName}
title={intl.formatMessage(messages.upload)} title={intl.formatMessage(messages.upload)}
disabled={disabled} disabled={disabled}
onClick={handleClick} onClick={handleClick}

View File

@ -1,206 +1,44 @@
import classNames from 'clsx'; import React from 'react';
import { List as ImmutableList } from 'immutable';
import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { undoUploadCompose, changeUploadCompose, submitCompose } from 'soapbox/actions/compose'; import { undoUploadCompose, changeUploadCompose, submitCompose } from 'soapbox/actions/compose';
import { openModal } from 'soapbox/actions/modals'; import Upload from 'soapbox/components/upload';
import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon';
import IconButton from 'soapbox/components/icon-button';
import { useAppDispatch, useCompose, useInstance } from 'soapbox/hooks'; import { useAppDispatch, useCompose, useInstance } from 'soapbox/hooks';
import Motion from '../../ui/util/optional-motion'; interface IUploadCompose {
const bookIcon = require('@tabler/icons/book.svg');
const fileCodeIcon = require('@tabler/icons/file-code.svg');
const fileSpreadsheetIcon = require('@tabler/icons/file-spreadsheet.svg');
const fileTextIcon = require('@tabler/icons/file-text.svg');
const fileZipIcon = require('@tabler/icons/file-zip.svg');
const defaultIcon = require('@tabler/icons/paperclip.svg');
const presentationIcon = require('@tabler/icons/presentation.svg');
export const MIMETYPE_ICONS: Record<string, string> = {
'application/x-freearc': fileZipIcon,
'application/x-bzip': fileZipIcon,
'application/x-bzip2': fileZipIcon,
'application/gzip': fileZipIcon,
'application/vnd.rar': fileZipIcon,
'application/x-tar': fileZipIcon,
'application/zip': fileZipIcon,
'application/x-7z-compressed': fileZipIcon,
'application/x-csh': fileCodeIcon,
'application/html': fileCodeIcon,
'text/javascript': fileCodeIcon,
'application/json': fileCodeIcon,
'application/ld+json': fileCodeIcon,
'application/x-httpd-php': fileCodeIcon,
'application/x-sh': fileCodeIcon,
'application/xhtml+xml': fileCodeIcon,
'application/xml': fileCodeIcon,
'application/epub+zip': bookIcon,
'application/vnd.oasis.opendocument.spreadsheet': fileSpreadsheetIcon,
'application/vnd.ms-excel': fileSpreadsheetIcon,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': fileSpreadsheetIcon,
'application/pdf': fileTextIcon,
'application/vnd.oasis.opendocument.presentation': presentationIcon,
'application/vnd.ms-powerpoint': presentationIcon,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': presentationIcon,
'text/plain': fileTextIcon,
'application/rtf': fileTextIcon,
'application/msword': fileTextIcon,
'application/x-abiword': fileTextIcon,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': fileTextIcon,
'application/vnd.oasis.opendocument.text': fileTextIcon,
};
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
});
interface IUpload {
id: string, id: string,
composeId: string, composeId: string,
} }
const Upload: React.FC<IUpload> = ({ composeId, id }) => { const UploadCompose: React.FC<IUploadCompose> = ({ composeId, id }) => {
const intl = useIntl();
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { description_limit: descriptionLimit } = useInstance(); const { description_limit: descriptionLimit } = useInstance();
const media = useCompose(composeId).media_attachments.find(item => item.id === id)!; const media = useCompose(composeId).media_attachments.find(item => item.id === id)!;
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [dirtyDescription, setDirtyDescription] = useState<string | null>(null);
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleSubmit();
}
};
const handleSubmit = () => { const handleSubmit = () => {
handleInputBlur();
dispatch(submitCompose(composeId, history)); dispatch(submitCompose(composeId, history));
}; };
const handleUndoClick: React.MouseEventHandler = e => { const handleDescriptionChange = (description: string) => {
e.stopPropagation(); dispatch(changeUploadCompose(composeId, media.id, { description }));
};
const handleDelete = () => {
dispatch(undoUploadCompose(composeId, media.id)); dispatch(undoUploadCompose(composeId, media.id));
}; };
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
setDirtyDescription(e.target.value);
};
const handleMouseEnter = () => {
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleInputFocus = () => {
setFocused(true);
};
const handleClick = () => {
setFocused(true);
};
const handleInputBlur = () => {
setFocused(false);
setDirtyDescription(null);
if (dirtyDescription !== null) {
dispatch(changeUploadCompose(composeId, media.id, { description: dirtyDescription }));
}
};
const handleOpenModal = () => {
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 }));
};
const active = hovered || focused;
const description = dirtyDescription || (dirtyDescription !== '' && media.description) || '';
const focusX = media.meta.getIn(['focus', 'x']) as number | undefined;
const focusY = media.meta.getIn(['focus', 'y']) as number | undefined;
const x = focusX ? ((focusX / 2) + .5) * 100 : undefined;
const y = focusY ? ((focusY / -2) + .5) * 100 : undefined;
const mediaType = media.type;
const mimeType = media.pleroma.get('mime_type') as string | undefined;
const uploadIcon = mediaType === 'unknown' && (
<Icon
className='h-16 w-16 mx-auto my-12 text-gray-800 dark:text-gray-200'
src={MIMETYPE_ICONS[mimeType || ''] || defaultIcon}
/>
);
return ( return (
<div className='compose-form__upload' tabIndex={0} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'> <Upload
<Blurhash hash={media.blurhash} className='media-gallery__preview' /> media={media}
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> onDelete={handleDelete}
{({ scale }) => ( onDescriptionChange={handleDescriptionChange}
<div onSubmit={handleSubmit}
className={classNames('compose-form__upload-thumbnail', mediaType)} descriptionLimit={descriptionLimit}
style={{ withPreview
transform: `scale(${scale})`, />
backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }}
>
<div className={classNames('compose-form__upload__actions', { active })}>
<IconButton
onClick={handleUndoClick}
src={require('@tabler/icons/x.svg')}
text={<FormattedMessage id='upload_form.undo' defaultMessage='Delete' />}
/>
{/* Only display the "Preview" button for a valid attachment with a URL */}
{(mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton
onClick={handleOpenModal}
src={require('@tabler/icons/zoom-in.svg')}
text={<FormattedMessage id='upload_form.preview' defaultMessage='Preview' />}
/>
)}
</div>
<div className={classNames('compose-form__upload-description', { active })}>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
<textarea
placeholder={intl.formatMessage(messages.description)}
value={description}
maxLength={descriptionLimit}
onFocus={handleInputFocus}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
/>
</label>
</div>
<div className='compose-form__upload-preview'>
{mediaType === 'video' && (
<video autoPlay playsInline muted loop>
<source src={media.preview_url} />
</video>
)}
{uploadIcon}
</div>
</div>
)}
</Motion>
</div>
); );
}; };
export default Upload; export default UploadCompose;

View File

@ -170,13 +170,13 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
secondary: intl.formatMessage(messages.blockAndReport), secondary: intl.formatMessage(messages.blockAndReport),
onSecondary: () => { onSecondary: () => {
dispatch(blockAccount(account.id)); dispatch(blockAccount(account.id));
dispatch(initReport(account, status)); dispatch(initReport(account, { status }));
}, },
})); }));
}; };
const handleReport = () => { const handleReport = () => {
dispatch(initReport(account, status)); dispatch(initReport(account, { status }));
}; };
const handleModerate = () => { const handleModerate = () => {

View File

@ -0,0 +1,70 @@
import classNames from 'clsx';
import React from 'react';
import { HStack, Stack, Text } from 'soapbox/components/ui';
import { randomIntFromInterval } from '../utils';
import PlaceholderAvatar from './placeholder-avatar';
/** Fake chat to display while data is loading. */
const PlaceholderChatMessage = ({ isMyMessage = false }: { isMyMessage?: boolean }) => {
const messageLength = randomIntFromInterval(160, 220);
return (
<Stack
data-testid='placeholder-chat-message'
space={1}
className={classNames({
'max-w-[85%] animate-pulse': true,
'ml-auto': isMyMessage,
})}
>
<HStack
alignItems='center'
justifyContent={isMyMessage ? 'end' : 'start'}
>
<div
className={
classNames({
'text-ellipsis break-words relative rounded-md p-2': true,
'mr-2': isMyMessage,
'order-2 ml-2': !isMyMessage,
})
}
>
<div style={{ width: messageLength, height: 20 }} className='rounded-full bg-primary-50 dark:bg-primary-800' />
</div>
<div className={classNames({ 'order-1': !isMyMessage })}>
<PlaceholderAvatar size={34} />
</div>
</HStack>
<HStack
alignItems='center'
space={2}
className={classNames({
'ml-auto': isMyMessage,
})}
>
<Text
theme='muted'
size='xs'
className={classNames({
'text-right': isMyMessage,
'order-2': !isMyMessage,
})}
>
<span style={{ width: 50, height: 12 }} className='rounded-full bg-primary-50 dark:bg-primary-800 block' />
</Text>
<div className={classNames({ 'order-1': !isMyMessage })}>
<div className='w-[34px] ml-2' />
</div>
</HStack>
</Stack>
);
};
export default PlaceholderChatMessage;

View File

@ -2,24 +2,18 @@ import React from 'react';
import { HStack, Stack } from 'soapbox/components/ui'; import { HStack, Stack } from 'soapbox/components/ui';
import { randomIntFromInterval, generateText } from '../utils';
import PlaceholderAvatar from './placeholder-avatar'; import PlaceholderAvatar from './placeholder-avatar';
import PlaceholderDisplayName from './placeholder-display-name'; import PlaceholderDisplayName from './placeholder-display-name';
/** Fake chat to display while data is loading. */ /** Fake chat to display while data is loading. */
const PlaceholderChat: React.FC = () => { const PlaceholderChat = () => {
const messageLength = randomIntFromInterval(5, 75);
return ( return (
<div className='account chat-list-item--placeholder'> <div className='px-4 py-2 w-full flex flex-col animate-pulse'>
<HStack space={3}> <HStack alignItems='center' space={2}>
<PlaceholderAvatar size={36} /> <PlaceholderAvatar size={40} />
<Stack className='overflow-hidden'>
<PlaceholderDisplayName minLength={3} maxLength={25} withSuffix={false} /> <Stack alignItems='start'>
<span className='overflow-hidden text-ellipsis whitespace-nowrap text-primary-50 dark:text-primary-800'> <PlaceholderDisplayName minLength={3} maxLength={15} />
{generateText(messageLength)}
</span>
</Stack> </Stack>
</HStack> </HStack>
</div> </div>

View File

@ -0,0 +1,40 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import List, { ListItem } from 'soapbox/components/list';
import { Toggle } from 'soapbox/components/ui';
import { useOwnAccount } from 'soapbox/hooks';
import { useUpdateCredentials } from 'soapbox/queries/accounts';
const messages = defineMessages({
label: { id: 'settings.messages.label', defaultMessage: 'Allow users you follow to start a new chat with you' },
});
const MessagesSettings = () => {
const account = useOwnAccount();
const intl = useIntl();
const updateCredentials = useUpdateCredentials();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
updateCredentials.mutate({ accepts_chat_messages: event.target.checked });
};
if (!account) {
return null;
}
return (
<List>
<ListItem
label={intl.formatMessage(messages.label)}
>
<Toggle
checked={account.accepts_chat_messages}
onChange={handleChange}
/>
</ListItem>
</List>
);
};
export default MessagesSettings;

View File

@ -10,6 +10,8 @@ import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
import Preferences from '../preferences'; import Preferences from '../preferences';
import MessagesSettings from './components/messages-settings';
const messages = defineMessages({ const messages = defineMessages({
settings: { id: 'settings.settings', defaultMessage: 'Settings' }, settings: { id: 'settings.settings', defaultMessage: 'Settings' },
profile: { id: 'settings.profile', defaultMessage: 'Profile' }, profile: { id: 'settings.profile', defaultMessage: 'Profile' },
@ -101,6 +103,18 @@ const Settings = () => {
</> </>
)} )}
{features.chats ? (
<>
<CardHeader>
<CardTitle title='Direct Messages' />
</CardHeader>
<CardBody>
<MessagesSettings />
</CardBody>
</>
) : null}
<CardHeader> <CardHeader>
<CardTitle title={intl.formatMessage(messages.preferences)} /> <CardTitle title={intl.formatMessage(messages.preferences)} />
</CardHeader> </CardHeader>

View File

@ -34,6 +34,7 @@ import {
JoinEventModal, JoinEventModal,
AccountModerationModal, AccountModerationModal,
EventParticipantsModal, EventParticipantsModal,
PolicyModal,
} from 'soapbox/features/ui/util/async-components'; } from 'soapbox/features/ui/util/async-components';
import BundleContainer from '../containers/bundle-container'; import BundleContainer from '../containers/bundle-container';
@ -75,6 +76,7 @@ const MODAL_COMPONENTS = {
'JOIN_EVENT': JoinEventModal, 'JOIN_EVENT': JoinEventModal,
'ACCOUNT_MODERATION': AccountModerationModal, 'ACCOUNT_MODERATION': AccountModerationModal,
'EVENT_PARTICIPANTS': EventParticipantsModal, 'EVENT_PARTICIPANTS': EventParticipantsModal,
'POLICY': PolicyModal,
}; };
export type ModalType = keyof typeof MODAL_COMPONENTS | null; export type ModalType = keyof typeof MODAL_COMPONENTS | null;

View File

@ -4,6 +4,8 @@ import { FormattedMessage } from 'react-intl';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
import { Modal, Stack, Text, Toggle } from 'soapbox/components/ui'; import { Modal, Stack, Text, Toggle } from 'soapbox/components/ui';
import type { ButtonThemes } from 'soapbox/components/ui/button/useButtonStyles';
interface IConfirmationModal { interface IConfirmationModal {
heading: React.ReactNode, heading: React.ReactNode,
message: React.ReactNode, message: React.ReactNode,
@ -14,6 +16,7 @@ interface IConfirmationModal {
onSecondary?: () => void, onSecondary?: () => void,
onCancel: () => void, onCancel: () => void,
checkbox?: JSX.Element, checkbox?: JSX.Element,
confirmationTheme?: ButtonThemes
} }
const ConfirmationModal: React.FC<IConfirmationModal> = ({ const ConfirmationModal: React.FC<IConfirmationModal> = ({
@ -26,6 +29,7 @@ const ConfirmationModal: React.FC<IConfirmationModal> = ({
onSecondary, onSecondary,
onCancel, onCancel,
checkbox, checkbox,
confirmationTheme = 'danger',
}) => { }) => {
const [checked, setChecked] = useState(false); const [checked, setChecked] = useState(false);
@ -54,7 +58,7 @@ const ConfirmationModal: React.FC<IConfirmationModal> = ({
confirmationAction={handleClick} confirmationAction={handleClick}
confirmationText={confirm} confirmationText={confirm}
confirmationDisabled={checkbox && !checked} confirmationDisabled={checkbox && !checked}
confirmationTheme='danger' confirmationTheme={confirmationTheme}
cancelText={<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />} cancelText={<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />}
cancelAction={handleCancel} cancelAction={handleCancel}
secondaryText={secondary} secondaryText={secondary}

View File

@ -0,0 +1,163 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Text, Button, Modal, Stack, HStack } from 'soapbox/components/ui';
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
import { usePendingPolicy, useAcceptPolicy } from 'soapbox/queries/policies';
interface IPolicyModal {
onClose: (type: string) => void,
}
const DirectMessageUpdates = () => {
const soapboxConfig = useSoapboxConfig();
const { links } = soapboxConfig;
return (
<Stack space={3}>
<Stack space={4} className='border-2 border-solid border-primary-200 dark:border-primary-800 rounded-lg p-4'>
<HStack alignItems='center' space={3}>
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M0 22.5306C0 10.0873 10.0873 0 22.5306 0H26.4828C38.3664 0 48 9.6336 48 21.5172V21.5172C48 36.1433 36.1433 48 21.5172 48H18.4615C8.26551 48 0 39.7345 0 29.5385V22.5306Z' fill='url(#paint0_linear_2190_131524)' fillOpacity='0.2' />
<path fillRule='evenodd' clipRule='evenodd' d='M14.0001 19C14.0001 17.3431 15.3433 16 17.0001 16H31.0001C32.657 16 34.0001 17.3431 34.0001 19V19.9845C34.0002 19.9942 34.0002 20.004 34.0001 20.0137V29C34.0001 30.6569 32.657 32 31.0001 32H17.0001C15.3433 32 14.0001 30.6569 14.0001 29V20.0137C14 20.004 14 19.9942 14.0001 19.9845V19ZM16.0001 21.8685V29C16.0001 29.5523 16.4478 30 17.0001 30H31.0001C31.5524 30 32.0001 29.5523 32.0001 29V21.8685L25.6642 26.0925C24.6565 26.7642 23.3437 26.7642 22.336 26.0925L16.0001 21.8685ZM32.0001 19.4648L24.5548 24.4283C24.2189 24.6523 23.7813 24.6523 23.4454 24.4283L16.0001 19.4648V19C16.0001 18.4477 16.4478 18 17.0001 18H31.0001C31.5524 18 32.0001 18.4477 32.0001 19V19.4648Z' fill='#818CF8' />
<defs>
<linearGradient id='paint0_linear_2190_131524' x1='0' y1='0' x2='43.6184' y2='-3.69691' gradientUnits='userSpaceOnUse'>
<stop stopColor='#B8A3F9' />
<stop offset='1' stopColor='#9BD5FF' />
</linearGradient>
</defs>
</svg>
<Text weight='bold'>
Direct Messaging
</Text>
</HStack>
<Text theme='muted'>
Yes, direct messages are finally here!
</Text>
<Text theme='muted'>
Bring one-on-one conversations from your Feed to your DMs with
messages that automatically delete for your privacy.
</Text>
</Stack>
<Stack space={4} className='border-2 border-solid border-primary-200 dark:border-primary-800 rounded-lg p-4'>
<HStack alignItems='center' space={3}>
<svg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M0 25.7561C0 22.2672 0 20.5228 0.197492 19.0588C1.52172 9.24259 9.24259 1.52172 19.0588 0.197492C20.5228 0 22.2672 0 25.7561 0H30.1176C39.9938 0 48 8.0062 48 17.8824C48 34.5159 34.5159 48 17.8824 48H15.3192C15.0228 48 14.8747 48 14.7494 47.9979C6.66132 47.8627 0.137263 41.3387 0.0020943 33.2506C0 33.1253 0 32.9772 0 32.6808V25.7561Z' fill='url(#paint0_linear_2190_131532)' fillOpacity='0.2' />
<path fillRule='evenodd' clipRule='evenodd' d='M23.9999 14C24.5522 14 24.9999 14.4477 24.9999 15V16C24.9999 16.5523 24.5522 17 23.9999 17C23.4477 17 22.9999 16.5523 22.9999 16V15C22.9999 14.4477 23.4477 14 23.9999 14ZM16.9289 16.9289C17.3194 16.5384 17.9526 16.5384 18.3431 16.9289L19.0502 17.636C19.4407 18.0266 19.4407 18.6597 19.0502 19.0503C18.6597 19.4408 18.0265 19.4408 17.636 19.0503L16.9289 18.3431C16.5384 17.9526 16.5384 17.3195 16.9289 16.9289ZM31.071 16.9289C31.4615 17.3195 31.4615 17.9526 31.071 18.3431L30.3639 19.0503C29.9734 19.4408 29.3402 19.4408 28.9497 19.0503C28.5592 18.6597 28.5592 18.0266 28.9497 17.636L29.6568 16.9289C30.0473 16.5384 30.6805 16.5384 31.071 16.9289ZM21.1715 21.1716C19.6094 22.7337 19.6094 25.2664 21.1715 26.8285L21.7186 27.3755C21.9116 27.5686 22.0848 27.7778 22.2367 28H25.7632C25.9151 27.7778 26.0882 27.5686 26.2813 27.3755L26.8284 26.8285C28.3905 25.2664 28.3905 22.7337 26.8284 21.1716C25.2663 19.6095 22.7336 19.6095 21.1715 21.1716ZM27.2448 29.4187C27.3586 29.188 27.5101 28.9751 27.6955 28.7898L28.2426 28.2427C30.5857 25.8995 30.5857 22.1005 28.2426 19.7574C25.8994 17.4142 22.1005 17.4142 19.7573 19.7574C17.4142 22.1005 17.4142 25.8995 19.7573 28.2427L20.3044 28.7898C20.4898 28.9751 20.6413 29.188 20.7551 29.4187C20.7601 29.4295 20.7653 29.4403 20.7706 29.4509C20.9202 29.7661 20.9999 30.1134 20.9999 30.469V31C20.9999 32.6569 22.3431 34 23.9999 34C25.6568 34 26.9999 32.6569 26.9999 31V30.469C26.9999 30.1134 27.0797 29.7661 27.2292 29.4509C27.2346 29.4403 27.2398 29.4295 27.2448 29.4187ZM25.0251 30H22.9748C22.9915 30.155 22.9999 30.3116 22.9999 30.469V31C22.9999 31.5523 23.4477 32 23.9999 32C24.5522 32 24.9999 31.5523 24.9999 31V30.469C24.9999 30.3116 25.0084 30.155 25.0251 30ZM14 23.9999C14 23.4477 14.4477 22.9999 15 22.9999H16C16.5523 22.9999 17 23.4477 17 23.9999C17 24.5522 16.5523 24.9999 16 24.9999H15C14.4477 24.9999 14 24.5522 14 23.9999ZM31 23.9999C31 23.4477 31.4477 22.9999 32 22.9999H33C33.5523 22.9999 34 23.4477 34 23.9999C34 24.5522 33.5523 24.9999 33 24.9999H32C31.4477 24.9999 31 24.5522 31 23.9999Z' fill='#818CF8' />
<defs>
<linearGradient id='paint0_linear_2190_131532' x1='0' y1='0' x2='43.6184' y2='-3.69691' gradientUnits='userSpaceOnUse'>
<stop stopColor='#B8A3F9' />
<stop offset='1' stopColor='#9BD5FF' />
</linearGradient>
</defs>
</svg>
<Text weight='bold'>Privacy Policy Updates</Text>
</HStack>
<ul className='space-y-2'>
<li className='flex items-center space-x-2'>
<span className='text-primary-500 dark:text-primary-300 h-8 w-8 flex items-center justify-center text-sm font-bold rounded-full border-2 border-solid border-gray-200 dark:border-gray-800'>
1
</span>
<Text theme='muted'>Consolidates previously-separate policies</Text>
</li>
<li className='flex items-center space-x-2'>
<span className='text-primary-500 dark:text-primary-300 h-8 w-8 flex items-center justify-center text-sm font-bold rounded-full border-2 border-solid border-gray-200 dark:border-gray-800'>
2
</span>
<Text theme='muted'>Reaffirms jurisdiction-specific requirements</Text>
</li>
<li className='flex items-center space-x-2'>
<span className='text-primary-500 dark:text-primary-300 h-8 w-8 flex items-center justify-center text-sm font-bold rounded-full border-2 border-solid border-gray-200 dark:border-gray-800'>
3
</span>
<Text theme='muted'>Introduces updates regarding ads and direct messages</Text>
</li>
</ul>
{links.get('privacyPolicy') ? (
<a
className='text-primary-600 dark:text-accent-blue text-center font-bold hover:underline'
href={links.get('privacyPolicy')}
target='_blank'
>
View Privacy Policy
</a>
) : null}
</Stack>
</Stack>
);
};
const supportedPolicyIds = ['1'];
/** Modal to show privacy policy changes that need confirmation. */
const PolicyModal: React.FC<IPolicyModal> = ({ onClose }) => {
const acceptPolicy = useAcceptPolicy();
const instance = useAppSelector((state) => state.instance);
const { data: pendingPolicy, isLoading } = usePendingPolicy();
const renderPolicyBody = () => {
switch (pendingPolicy?.pending_policy_id) {
case '1':
return <DirectMessageUpdates />;
default:
return null;
}
};
const handleAccept = () => {
acceptPolicy.mutate({
policy_id: pendingPolicy?.pending_policy_id as string,
}, {
onSuccess() {
onClose('POLICY');
},
});
};
if (isLoading || !pendingPolicy) {
return null;
}
return (
<Modal title='Updates'>
<Stack space={4}>
<Text theme='muted'>
<FormattedMessage
id='modals.policy.updateTitle'
defaultMessage='Youve scored the latest version of {siteTitle}! Take a moment to review the exciting new things weve been working on.'
values={{ siteTitle: instance.title }}
/>
</Text>
{renderPolicyBody()}
<Button
theme='primary'
size='lg'
block
onClick={handleAccept}
disabled={acceptPolicy.isLoading}
>
<FormattedMessage
id='modals.policy.submit'
defaultMessage='Accept & Continue'
/>
</Button>
</Stack>
</Modal>
);
};
export { PolicyModal as default, supportedPolicyIds };

View File

@ -1,12 +1,14 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { blockAccount } from 'soapbox/actions/accounts'; import { blockAccount } from 'soapbox/actions/accounts';
import { submitReport, submitReportSuccess, submitReportFail } from 'soapbox/actions/reports'; import { submitReport, submitReportSuccess, submitReportFail } from 'soapbox/actions/reports';
import { expandAccountTimeline } from 'soapbox/actions/timelines'; import { expandAccountTimeline } from 'soapbox/actions/timelines';
import AttachmentThumbs from 'soapbox/components/attachment-thumbs'; import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
import List, { ListItem } from 'soapbox/components/list';
import StatusContent from 'soapbox/components/status-content'; import StatusContent from 'soapbox/components/status-content';
import { Modal, ProgressBar, Stack, Text } from 'soapbox/components/ui'; import { Avatar, HStack, Icon, Modal, ProgressBar, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container'; import AccountContainer from 'soapbox/containers/account-container';
import { useAccount, useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAccount, useAppDispatch, useAppSelector } from 'soapbox/hooks';
@ -21,6 +23,8 @@ const messages = defineMessages({
done: { id: 'report.done', defaultMessage: 'Done' }, done: { id: 'report.done', defaultMessage: 'Done' },
next: { id: 'report.next', defaultMessage: 'Next' }, next: { id: 'report.next', defaultMessage: 'Next' },
submit: { id: 'report.submit', defaultMessage: 'Submit' }, submit: { id: 'report.submit', defaultMessage: 'Submit' },
reportContext: { id: 'report.chatMessage.context', defaultMessage: 'When reporting a users message, the five messages before and five messages after the one selected will be passed along to our moderation team for context.' },
reportMessage: { id: 'report.chatMessage.title', defaultMessage: 'Report message' },
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' }, cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
previous: { id: 'report.previous', defaultMessage: 'Previous' }, previous: { id: 'report.previous', defaultMessage: 'Previous' },
}); });
@ -73,6 +77,12 @@ interface IReportModal {
onClose: () => void onClose: () => void
} }
enum ReportedEntities {
Account = 'Account',
Status = 'Status',
ChatMessage = 'ChatMessage'
}
const ReportModal = ({ onClose }: IReportModal) => { const ReportModal = ({ onClose }: IReportModal) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
@ -85,10 +95,23 @@ const ReportModal = ({ onClose }: IReportModal) => {
const rules = useAppSelector((state) => state.rules.items); const rules = useAppSelector((state) => state.rules.items);
const ruleIds = useAppSelector((state) => state.reports.new.rule_ids); const ruleIds = useAppSelector((state) => state.reports.new.rule_ids);
const selectedStatusIds = useAppSelector((state) => state.reports.new.status_ids); const selectedStatusIds = useAppSelector((state) => state.reports.new.status_ids);
const selectedChatMessage = useAppSelector((state) => state.reports.new.chat_message);
const isReportingAccount = useMemo(() => selectedStatusIds.size === 0, []);
const shouldRequireRule = rules.length > 0; const shouldRequireRule = rules.length > 0;
const reportedEntity = useMemo(() => {
if (selectedStatusIds.size === 0 && !selectedChatMessage) {
return ReportedEntities.Account;
} else if (selectedChatMessage) {
return ReportedEntities.ChatMessage;
} else {
return ReportedEntities.Status;
}
}, []);
const isReportingAccount = reportedEntity === ReportedEntities.Account;
const isReportingStatus = reportedEntity === ReportedEntities.Status;
const [currentStep, setCurrentStep] = useState<Steps>(Steps.ONE); const [currentStep, setCurrentStep] = useState<Steps>(Steps.ONE);
const handleSubmit = () => { const handleSubmit = () => {
@ -164,13 +187,57 @@ const ReportModal = ({ onClose }: IReportModal) => {
} }
}; };
const renderSelectedChatMessage = () => {
if (account) {
return (
<Stack space={4}>
<HStack alignItems='center' space={4} className='rounded-md border dark:border-2 border-solid border-gray-400 dark:border-gray-800 p-4'>
<div>
<Avatar src={account.avatar} className='w-8 h-8' />
</div>
<div className='bg-gray-200 dark:bg-primary-800 rounded-md p-4 flex-grow'>
<Text dangerouslySetInnerHTML={{ __html: selectedChatMessage?.content as string }} />
</div>
</HStack>
<List>
<ListItem
label={<Icon src={require('@tabler/icons/info-circle.svg')} className='text-gray-600' />}
>
<Text size='sm'>{intl.formatMessage(messages.reportContext)}</Text>
</ListItem>
</List>
</Stack>
);
}
};
const renderSelectedEntity = () => {
switch (reportedEntity) {
case ReportedEntities.Status:
return renderSelectedStatuses();
case ReportedEntities.ChatMessage:
return renderSelectedChatMessage();
}
};
const renderTitle = () => {
switch (reportedEntity) {
case ReportedEntities.ChatMessage:
return intl.formatMessage(messages.reportMessage);
default:
return <FormattedMessage id='report.target' defaultMessage='Reporting {target}' values={{ target: <strong>@{account?.acct}</strong> }} />;
}
};
const isConfirmationButtonDisabled = useMemo(() => { const isConfirmationButtonDisabled = useMemo(() => {
if (currentStep === Steps.THREE) { if (currentStep === Steps.THREE) {
return false; return false;
} }
return isSubmitting || (shouldRequireRule && ruleIds.isEmpty()) || (!isReportingAccount && selectedStatusIds.size === 0); return isSubmitting || (shouldRequireRule && ruleIds.isEmpty()) || (isReportingStatus && selectedStatusIds.size === 0);
}, [currentStep, isSubmitting, shouldRequireRule, ruleIds, selectedStatusIds.size, isReportingAccount]); }, [currentStep, isSubmitting, shouldRequireRule, ruleIds, selectedStatusIds.size, isReportingStatus]);
const calculateProgress = useCallback(() => { const calculateProgress = useCallback(() => {
switch (currentStep) { switch (currentStep) {
@ -199,7 +266,7 @@ const ReportModal = ({ onClose }: IReportModal) => {
return ( return (
<Modal <Modal
title={<FormattedMessage id='report.target' defaultMessage='Reporting {target}' values={{ target: <strong>@{account.acct}</strong> }} />} title={renderTitle()}
onClose={onClose} onClose={onClose}
cancelText={cancelText} cancelText={cancelText}
cancelAction={currentStep === Steps.THREE ? undefined : cancelAction} cancelAction={currentStep === Steps.THREE ? undefined : cancelAction}
@ -211,7 +278,7 @@ const ReportModal = ({ onClose }: IReportModal) => {
<Stack space={4}> <Stack space={4}>
<ProgressBar progress={calculateProgress()} /> <ProgressBar progress={calculateProgress()} />
{(currentStep !== Steps.THREE && !isReportingAccount) && renderSelectedStatuses()} {(currentStep !== Steps.THREE && !isReportingAccount) && renderSelectedEntity()}
<StepToRender account={account} /> <StepToRender account={account} />
</Stack> </Stack>

View File

@ -25,7 +25,7 @@ const renderTermsOfServiceLink = (href: string) => (
<a <a
href={href} href={href}
target='_blank' target='_blank'
className='hover:underline text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-500' className='hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue'
> >
{termsOfServiceText} {termsOfServiceText}
</a> </a>

View File

@ -110,7 +110,7 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
{menu.map((menuItem, idx) => { {menu.map((menuItem, idx) => {
if (menuItem.toggle) { if (menuItem.toggle) {
return ( return (
<div key={idx} className='flex flex-row items-center justify-between px-4 py-1 text-sm text-gray-700 dark:text-gray-400'> <div key={idx} className='flex flex-row items-center justify-between space-x-4 px-4 py-1 text-sm text-gray-700 dark:text-gray-400'>
<span>{menuItem.text}</span> <span>{menuItem.text}</span>
{menuItem.toggle} {menuItem.toggle}

View File

@ -1,7 +1,6 @@
'use strict'; 'use strict';
import debounce from 'lodash/debounce'; import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom'; import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
@ -9,7 +8,6 @@ import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
import { fetchFollowRequests } from 'soapbox/actions/accounts'; import { fetchFollowRequests } from 'soapbox/actions/accounts';
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin'; import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
import { fetchAnnouncements } from 'soapbox/actions/announcements'; import { fetchAnnouncements } from 'soapbox/actions/announcements';
import { fetchChats } from 'soapbox/actions/chats';
import { uploadCompose, resetCompose } from 'soapbox/actions/compose'; import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis'; import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
import { uploadEventBanner } from 'soapbox/actions/events'; import { uploadEventBanner } from 'soapbox/actions/events';
@ -26,18 +24,22 @@ import Icon from 'soapbox/components/icon';
import SidebarNavigation from 'soapbox/components/sidebar-navigation'; import SidebarNavigation from 'soapbox/components/sidebar-navigation';
import ThumbNavigation from 'soapbox/components/thumb-navigation'; import ThumbNavigation from 'soapbox/components/thumb-navigation';
import { Layout } from 'soapbox/components/ui'; import { Layout } from 'soapbox/components/ui';
import { useStatContext } from 'soapbox/contexts/stat-context';
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useInstance } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useInstance } from 'soapbox/hooks';
import AdminPage from 'soapbox/pages/admin-page'; import AdminPage from 'soapbox/pages/admin-page';
import ChatsPage from 'soapbox/pages/chats-page';
import DefaultPage from 'soapbox/pages/default-page'; import DefaultPage from 'soapbox/pages/default-page';
import EventPage from 'soapbox/pages/event-page'; import EventPage from 'soapbox/pages/event-page';
import HomePage from 'soapbox/pages/home-page'; import HomePage from 'soapbox/pages/home-page';
import ProfilePage from 'soapbox/pages/profile-page'; import ProfilePage from 'soapbox/pages/profile-page';
import RemoteInstancePage from 'soapbox/pages/remote-instance-page'; import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
import StatusPage from 'soapbox/pages/status-page'; import StatusPage from 'soapbox/pages/status-page';
import { usePendingPolicy } from 'soapbox/queries/policies';
import { getAccessToken, getVapidKey } from 'soapbox/utils/auth'; import { getAccessToken, getVapidKey } from 'soapbox/utils/auth';
import { isStandalone } from 'soapbox/utils/state'; import { isStandalone } from 'soapbox/utils/state';
import BackgroundShapes from './components/background-shapes'; import BackgroundShapes from './components/background-shapes';
import { supportedPolicyIds } from './components/modals/policy-modal';
import Navbar from './components/navbar'; import Navbar from './components/navbar';
import BundleContainer from './containers/bundle-container'; import BundleContainer from './containers/bundle-container';
import { import {
@ -63,15 +65,9 @@ import {
Filters, Filters,
PinnedStatuses, PinnedStatuses,
Search, Search,
// Groups,
// GroupTimeline,
ListTimeline, ListTimeline,
Lists, Lists,
Bookmarks, Bookmarks,
// GroupMembers,
// GroupRemovedAccounts,
// GroupCreate,
// GroupEdit,
Settings, Settings,
MediaDisplay, MediaDisplay,
EditProfile, EditProfile,
@ -85,8 +81,7 @@ import {
// Backups, // Backups,
MfaForm, MfaForm,
ChatIndex, ChatIndex,
ChatRoom, ChatWidget,
ChatPanes,
ServerInfo, ServerInfo,
Dashboard, Dashboard,
ModerationLog, ModerationLog,
@ -125,8 +120,6 @@ import 'soapbox/components/status';
const EmptyPage = HomePage; const EmptyPage = HomePage;
const isMobile = (width: number): boolean => width <= 1190;
const messages = defineMessages({ const messages = defineMessages({
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave.' }, beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave.' },
publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }, publish: { id: 'compose_form.publish', defaultMessage: 'Publish' },
@ -255,8 +248,10 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
{features.profileDirectory && <WrappedRoute path='/directory' publicRoute page={DefaultPage} component={Directory} content={children} />} {features.profileDirectory && <WrappedRoute path='/directory' publicRoute page={DefaultPage} component={Directory} content={children} />}
{features.events && <WrappedRoute path='/events' page={DefaultPage} component={Events} content={children} />} {features.events && <WrappedRoute path='/events' page={DefaultPage} component={Events} content={children} />}
{features.chats && <WrappedRoute path='/chats' exact page={DefaultPage} component={ChatIndex} content={children} />} {features.chats && <WrappedRoute path='/chats' exact page={ChatsPage} component={ChatIndex} content={children} />}
{features.chats && <WrappedRoute path='/chats/:chatId' page={DefaultPage} component={ChatRoom} content={children} />} {features.chats && <WrappedRoute path='/chats/new' page={ChatsPage} component={ChatIndex} content={children} />}
{features.chats && <WrappedRoute path='/chats/settings' page={ChatsPage} component={ChatIndex} content={children} />}
{features.chats && <WrappedRoute path='/chats/:chatId' page={ChatsPage} component={ChatIndex} content={children} />}
<WrappedRoute path='/follow_requests' page={DefaultPage} component={FollowRequests} content={children} /> <WrappedRoute path='/follow_requests' page={DefaultPage} component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} /> <WrappedRoute path='/blocks' page={DefaultPage} component={Blocks} content={children} />
@ -326,10 +321,11 @@ const UI: React.FC = ({ children }) => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { data: pendingPolicy } = usePendingPolicy();
const instance = useInstance(); const instance = useInstance();
const statContext = useStatContext();
const [draggingOver, setDraggingOver] = useState<boolean>(false); const [draggingOver, setDraggingOver] = useState<boolean>(false);
const [mobile, setMobile] = useState<boolean>(isMobile(window.innerWidth));
const dragTargets = useRef<EventTarget[]>([]); const dragTargets = useRef<EventTarget[]>([]);
const disconnect = useRef<any>(null); const disconnect = useRef<any>(null);
@ -425,7 +421,7 @@ const UI: React.FC = ({ children }) => {
const connectStreaming = () => { const connectStreaming = () => {
if (!disconnect.current && accessToken && streamingUrl) { if (!disconnect.current && accessToken && streamingUrl) {
disconnect.current = dispatch(connectUserStream()); disconnect.current = dispatch(connectUserStream({ statContext }));
} }
}; };
@ -436,12 +432,6 @@ const UI: React.FC = ({ children }) => {
} }
}; };
const handleResize = useCallback(debounce(() => {
setMobile(isMobile(window.innerWidth));
}, 500, {
trailing: true,
}), [setMobile]);
/** Load initial data when a user is logged in */ /** Load initial data when a user is logged in */
const loadAccountData = () => { const loadAccountData = () => {
if (!account) return; if (!account) return;
@ -457,10 +447,6 @@ const UI: React.FC = ({ children }) => {
dispatch(fetchAnnouncements()); dispatch(fetchAnnouncements());
if (features.chats) {
dispatch(fetchChats());
}
if (account.staff) { if (account.staff) {
dispatch(fetchReports({ resolved: false })); dispatch(fetchReports({ resolved: false }));
dispatch(fetchUsers(['local', 'need_approval'])); dispatch(fetchUsers(['local', 'need_approval']));
@ -480,7 +466,6 @@ const UI: React.FC = ({ children }) => {
}; };
useEffect(() => { useEffect(() => {
window.addEventListener('resize', handleResize, { passive: true });
document.addEventListener('dragenter', handleDragEnter, false); document.addEventListener('dragenter', handleDragEnter, false);
document.addEventListener('dragover', handleDragOver, false); document.addEventListener('dragover', handleDragOver, false);
document.addEventListener('drop', handleDrop, false); document.addEventListener('drop', handleDrop, false);
@ -495,7 +480,6 @@ const UI: React.FC = ({ children }) => {
} }
return () => { return () => {
window.removeEventListener('resize', handleResize);
document.removeEventListener('dragenter', handleDragEnter); document.removeEventListener('dragenter', handleDragEnter);
document.removeEventListener('dragover', handleDragOver); document.removeEventListener('dragover', handleDragOver);
document.removeEventListener('drop', handleDrop); document.removeEventListener('drop', handleDrop);
@ -518,6 +502,14 @@ const UI: React.FC = ({ children }) => {
dispatch(registerPushNotifications()); dispatch(registerPushNotifications());
}, [vapidKey]); }, [vapidKey]);
useEffect(() => {
if (account && pendingPolicy && supportedPolicyIds.includes(pendingPolicy.pending_policy_id)) {
setTimeout(() => {
dispatch(openModal('POLICY'));
}, 500);
}
}, [pendingPolicy, !!account]);
const handleHotkeyNew = (e?: KeyboardEvent) => { const handleHotkeyNew = (e?: KeyboardEvent) => {
e?.preventDefault(); e?.preventDefault();
if (!node.current) return; if (!node.current) return;
@ -679,9 +671,14 @@ const UI: React.FC = ({ children }) => {
{Component => <Component />} {Component => <Component />}
</BundleContainer> </BundleContainer>
)} )}
{me && features.chats && !mobile && (
<BundleContainer fetchComponent={ChatPanes}> {me && features.chats && (
{Component => <Component />} <BundleContainer fetchComponent={ChatWidget}>
{Component => (
<div className='hidden xl:block'>
<Component />
</div>
)}
</BundleContainer> </BundleContainer>
)} )}
<ThumbNavigation /> <ThumbNavigation />

View File

@ -110,6 +110,10 @@ export function AccountModerationModal() {
return import(/* webpackChunkName: "modals/account-moderation-modal" */'../components/modals/account-moderation-modal/account-moderation-modal'); return import(/* webpackChunkName: "modals/account-moderation-modal" */'../components/modals/account-moderation-modal/account-moderation-modal');
} }
export function PolicyModal() {
return import(/* webpackChunkName: "modals/policy-modal" */'../components/modals/policy-modal');
}
export function MediaGallery() { export function MediaGallery() {
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media-gallery'); return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media-gallery');
} }
@ -290,12 +294,8 @@ export function ChatIndex() {
return import(/* webpackChunkName: "features/chats" */'../../chats'); return import(/* webpackChunkName: "features/chats" */'../../chats');
} }
export function ChatRoom() { export function ChatWidget() {
return import(/* webpackChunkName: "features/chats/chat_room" */'../../chats/chat-room'); return import(/* webpackChunkName: "features/chats/components/chat-widget" */'../../chats/components/chat-widget/chat-widget');
}
export function ChatPanes() {
return import(/* webpackChunkName: "features/chats/components/chat_panes" */'../../chats/components/chat-panes');
} }
export function ServerInfo() { export function ServerInfo() {

View File

@ -3,6 +3,7 @@ export { useApi } from './useApi';
export { useAppDispatch } from './useAppDispatch'; export { useAppDispatch } from './useAppDispatch';
export { useAppSelector } from './useAppSelector'; export { useAppSelector } from './useAppSelector';
export { useCompose } from './useCompose'; export { useCompose } from './useCompose';
export { useDebounce } from './useDebounce';
export { useDimensions } from './useDimensions'; export { useDimensions } from './useDimensions';
export { useFeatures } from './useFeatures'; export { useFeatures } from './useFeatures';
export { useInstance } from './useInstance'; export { useInstance } from './useInstance';

View File

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
const useDebounce = (value: string, delay: number): string => {
const [debouncedValue, setDebouncedValue] = useState<string>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
};
export { useDebounce };

View File

@ -0,0 +1,62 @@
[
{
"id": "1",
"unread": 0,
"created_by_account": "2",
"last_message": {
"account_id": "2",
"chat_id": "85",
"content": "last message content",
"created_at": "2022-09-28T17:43:01.432Z",
"id": "1166",
"unread": false,
"discarded_at": "2022-09-29T19:09:30.253Z"
},
"created_at": "2022-08-26T14:49:16.360Z",
"updated_at": "2022-09-29T19:09:30.257Z",
"accepted": true,
"discarded_at": null,
"account": {
"id": "2",
"username": "leonard",
"acct": "leonard",
"display_name": "leonard",
"created_at": "2021-10-19T00:00:00.000Z",
"avatar": "original.jpg",
"avatar_static": "original.jpg",
"verified": false,
"accepting_messages": true,
"chats_onboarded": true
}
},
{
"id": "2",
"unread": 0,
"created_by_account": "3",
"last_message": {
"account_id": "3",
"chat_id": "125",
"content": "\u003cp\u003eInventore enim numquam nihil facilis nostrum eum natus provident quis veritatis esse dolorem praesentium rem cumque.\u003c/p\u003e",
"created_at": "2022-09-23T14:09:29.625Z",
"id": "1033",
"unread": false,
"discarded_at": null
},
"created_at": "2022-09-22T15:06:49.675Z",
"updated_at": "2022-09-23T14:09:29.628Z",
"accepted": true,
"discarded_at": null,
"account": {
"id": "3",
"username": "sheldon",
"acct": "sheldon",
"display_name": "sheldon",
"created_at": "2022-09-22T00:00:00.000Z",
"avatar": "original.jpg",
"avatar_static": "original.jpg",
"verified": false,
"accepting_messages": true,
"chats_onboarded": true
}
}
]

View File

@ -1,5 +1,5 @@
import { configureMockStore } from '@jedmao/redux-mock-store'; import { configureMockStore } from '@jedmao/redux-mock-store';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { render, RenderOptions } from '@testing-library/react'; import { render, RenderOptions } from '@testing-library/react';
import { renderHook, RenderHookOptions } from '@testing-library/react-hooks'; import { renderHook, RenderHookOptions } from '@testing-library/react-hooks';
import { merge } from 'immutable'; import { merge } from 'immutable';
@ -11,6 +11,10 @@ import { Action, applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { ChatProvider } from 'soapbox/contexts/chat-context';
import { StatProvider } from 'soapbox/contexts/stat-context';
import { queryClient } from 'soapbox/queries/client';
import NotificationsContainer from '../features/ui/containers/notifications-container'; import NotificationsContainer from '../features/ui/containers/notifications-container';
import { default as rootReducer } from '../reducers'; import { default as rootReducer } from '../reducers';
@ -27,29 +31,14 @@ const applyActions = (state: any, actions: any, reducer: any) => {
return actions.reduce((state: any, action: any) => reducer(state, action), state); return actions.reduce((state: any, action: any) => reducer(state, action), state);
}; };
/** React Query client for tests. */
const queryClient = new QueryClient({
logger: {
// eslint-disable-next-line no-console
log: console.log,
warn: console.warn,
error: () => { },
},
defaultOptions: {
queries: {
staleTime: 0,
cacheTime: Infinity,
retry: false,
},
},
});
const createTestStore = (initialState: any) => createStore(rootReducer, initialState, applyMiddleware(thunk)); const createTestStore = (initialState: any) => createStore(rootReducer, initialState, applyMiddleware(thunk));
const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => { const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
let store: ReturnType<typeof createTestStore>; let store: ReturnType<typeof createTestStore>;
let appState = rootState; let appState = rootState;
if (storeProps) { if (storeProps && typeof storeProps.getState !== 'undefined') { // storeProps is a store
store = storeProps;
} else if (storeProps) { // storeProps is state
appState = merge(rootState, storeProps); appState = merge(rootState, storeProps);
store = createTestStore(appState); store = createTestStore(appState);
} else { } else {
@ -63,15 +52,19 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
return ( return (
<Provider store={props.store}> <Provider store={props.store}>
<QueryClientProvider client={queryClient}> <MemoryRouter {...routerProps}>
<IntlProvider locale={props.locale}> <StatProvider>
<MemoryRouter {...routerProps}> <QueryClientProvider client={queryClient}>
{children} <ChatProvider>
<IntlProvider locale={props.locale}>
{children}
<NotificationsContainer /> <NotificationsContainer />
</MemoryRouter> </IntlProvider>
</IntlProvider> </ChatProvider>
</QueryClientProvider> </QueryClientProvider>
</StatProvider>
</MemoryRouter>
</Provider> </Provider>
); );
}; };

View File

@ -6,6 +6,9 @@ import { __clear as clearApiMocks } from '../api/__mocks__';
jest.mock('soapbox/api'); jest.mock('soapbox/api');
afterEach(() => clearApiMocks()); afterEach(() => clearApiMocks());
// Query mocking
jest.mock('soapbox/queries/client');
// Mock IndexedDB // Mock IndexedDB
// https://dev.to/andyhaskell/testing-your-indexeddb-code-with-jest-2o17 // https://dev.to/andyhaskell/testing-your-indexeddb-code-with-jest-2o17
require('fake-indexeddb/auto'); require('fake-indexeddb/auto');

View File

@ -1,65 +1,22 @@
'use strict'; 'use strict';
import { AnyAction } from 'redux';
import { play, soundCache } from 'soapbox/utils/sounds';
import type { ThunkMiddleware } from 'redux-thunk'; import type { ThunkMiddleware } from 'redux-thunk';
import type { Sounds } from 'soapbox/utils/sounds';
/** Soapbox audio clip. */
type Sound = {
src: string,
type: string,
}
/** Produce HTML5 audio from sound data. */ interface Action extends AnyAction {
const createAudio = (sources: Sound[]): HTMLAudioElement => { meta: {
const audio = new Audio(); sound: Sounds
sources.forEach(({ type, src }) => {
const source = document.createElement('source');
source.type = type;
source.src = src;
audio.appendChild(source);
});
return audio;
};
/** Play HTML5 sound. */
const play = (audio: HTMLAudioElement): void => {
if (!audio.paused) {
audio.pause();
if (typeof audio.fastSeek === 'function') {
audio.fastSeek(0);
} else {
audio.currentTime = 0;
}
} }
}
audio.play();
};
/** Middleware to play sounds in response to certain Redux actions. */ /** Middleware to play sounds in response to certain Redux actions. */
export default function soundsMiddleware(): ThunkMiddleware { export default function soundsMiddleware(): ThunkMiddleware {
const soundCache: Record<string, HTMLAudioElement> = { return () => next => (action: Action) => {
boop: createAudio([
{
src: require('../../assets/sounds/boop.ogg'),
type: 'audio/ogg',
},
{
src: require('../../assets/sounds/boop.mp3'),
type: 'audio/mpeg',
},
]),
chat: createAudio([
{
src: require('../../assets/sounds/chat.oga'),
type: 'audio/ogg',
},
{
src: require('../../assets/sounds/chat.mp3'),
type: 'audio/mpeg',
},
]),
};
return () => next => action => {
if (action.meta?.sound && soundCache[action.meta.sound]) { if (action.meta?.sound && soundCache[action.meta.sound]) {
play(soundCache[action.meta.sound]); play(soundCache[action.meta.sound]);
} }

View File

@ -9,6 +9,9 @@ describe('normalizeInstance()', () => {
contact_account: {}, contact_account: {},
configuration: { configuration: {
media_attachments: {}, media_attachments: {},
chats: {
max_characters: 500,
},
polls: { polls: {
max_options: 4, max_options: 4,
max_characters_per_option: 25, max_characters_per_option: 25,

Some files were not shown because too many files have changed in this diff Show More