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:
commit
ebd126ac3c
|
@ -204,9 +204,7 @@ export const rememberAuthAccount = (accountUrl: string) =>
|
|||
|
||||
export const loadCredentials = (token: string, accountUrl: string) =>
|
||||
(dispatch: AppDispatch) => dispatch(rememberAuthAccount(accountUrl))
|
||||
.then(() => {
|
||||
dispatch(verifyCredentials(token, accountUrl));
|
||||
})
|
||||
.then(() => dispatch(verifyCredentials(token, accountUrl)))
|
||||
.catch(() => dispatch(verifyCredentials(token, accountUrl)));
|
||||
|
||||
export const logIn = (username: string, password: string) =>
|
||||
|
|
|
@ -89,6 +89,7 @@ const updateNotificationsQueue = (notification: APIEntity, intlMessages: Record<
|
|||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
if (!notification.type) return; // drop invalid notifications
|
||||
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 filters = getFilters(getState(), { contextType: 'notifications' });
|
||||
|
|
|
@ -4,7 +4,7 @@ import { openModal } from './modals';
|
|||
|
||||
import type { AxiosError } from 'axios';
|
||||
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_CANCEL = 'REPORT_CANCEL';
|
||||
|
@ -20,26 +20,23 @@ const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE';
|
|||
|
||||
const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE';
|
||||
|
||||
const initReport = (account: Account, status?: Status) =>
|
||||
(dispatch: AppDispatch) => {
|
||||
type ReportedEntity = {
|
||||
status?: Status,
|
||||
chatMessage?: ChatMessage
|
||||
}
|
||||
|
||||
const initReport = (account: Account, entities?: ReportedEntity) => (dispatch: AppDispatch) => {
|
||||
const { status, chatMessage } = entities || {};
|
||||
|
||||
dispatch({
|
||||
type: REPORT_INIT,
|
||||
account,
|
||||
status,
|
||||
chatMessage,
|
||||
});
|
||||
|
||||
return dispatch(openModal('REPORT'));
|
||||
};
|
||||
|
||||
const initReportById = (accountId: string) =>
|
||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({
|
||||
type: REPORT_INIT,
|
||||
account: getState().accounts.get(accountId),
|
||||
});
|
||||
|
||||
dispatch(openModal('REPORT'));
|
||||
};
|
||||
};
|
||||
|
||||
const cancelReport = () => ({
|
||||
type: REPORT_CANCEL,
|
||||
|
@ -59,6 +56,7 @@ const submitReport = () =>
|
|||
return api(getState).post('/api/v1/reports', {
|
||||
account_id: reports.getIn(['new', 'account_id']),
|
||||
status_ids: reports.getIn(['new', 'status_ids']),
|
||||
message_ids: [reports.getIn(['new', 'chat_message', 'id'])],
|
||||
rule_ids: reports.getIn(['new', 'rule_ids']),
|
||||
comment: reports.getIn(['new', 'comment']),
|
||||
forward: reports.getIn(['new', 'forward']),
|
||||
|
@ -110,7 +108,6 @@ export {
|
|||
REPORT_BLOCK_CHANGE,
|
||||
REPORT_RULE_CHANGE,
|
||||
initReport,
|
||||
initReportById,
|
||||
cancelReport,
|
||||
toggleStatusReport,
|
||||
submitReport,
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import messages from 'soapbox/locales/messages';
|
||||
import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { getUnreadChatsCount, updateChatListItem } from 'soapbox/utils/chats';
|
||||
import { removePageItem } from 'soapbox/utils/queries';
|
||||
import { play, soundCache } from 'soapbox/utils/sounds';
|
||||
|
||||
import { connectStream } from '../stream';
|
||||
|
||||
|
@ -22,8 +27,9 @@ import {
|
|||
processTimelineUpdate,
|
||||
} from './timelines';
|
||||
|
||||
import type { IStatContext } from 'soapbox/contexts/stat-context';
|
||||
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_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 = (
|
||||
timelineId: string,
|
||||
path: string,
|
||||
pollingRefresh: ((dispatch: AppDispatch, done?: () => void) => void) | null = null,
|
||||
accept: ((status: APIEntity) => boolean) | null = null,
|
||||
opts?: StreamOpts,
|
||||
) => connectStream(path, pollingRefresh, (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const locale = getLocale(getState());
|
||||
|
||||
|
@ -78,7 +118,14 @@ const connectTimelineStream = (
|
|||
// break;
|
||||
case 'notification':
|
||||
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 => {
|
||||
console.error(error);
|
||||
});
|
||||
|
@ -90,18 +137,37 @@ const connectTimelineStream = (
|
|||
dispatch(fetchFilters());
|
||||
break;
|
||||
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 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({
|
||||
type: STREAMING_CHAT_UPDATE,
|
||||
chat,
|
||||
me,
|
||||
// Only play sounds for recipient messages
|
||||
meta: !messageOwned && getSettings(getState()).getIn(['chats', 'sound']) && { sound: 'chat' },
|
||||
// Don't update own messages from streaming
|
||||
if (!messageOwned) {
|
||||
updateChatListItem(chat);
|
||||
|
||||
if (settings.getIn(['chats', 'sound'])) {
|
||||
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;
|
||||
case 'pleroma:follow_relationships_update':
|
||||
|
@ -129,8 +195,8 @@ const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () =>
|
|||
dispatch(expandNotifications({}, () =>
|
||||
dispatch(fetchAnnouncements(done))))));
|
||||
|
||||
const connectUserStream = () =>
|
||||
connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
||||
const connectUserStream = (opts?: StreamOpts) =>
|
||||
connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification, null, opts);
|
||||
|
||||
const connectCommunityStream = ({ onlyMedia }: Record<string, any> = {}) =>
|
||||
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
|
||||
|
|
|
@ -21,6 +21,11 @@ export const getLinks = (response: AxiosResponse): LinkHeader => {
|
|||
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[]) => {
|
||||
const axios = api.baseClient(...params);
|
||||
setupMock(axios);
|
||||
|
|
|
@ -3,7 +3,9 @@ import React, { useState } from 'react';
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
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({
|
||||
placeholder: { id: 'account_search.placeholder', defaultMessage: 'Search for an account' },
|
||||
|
@ -16,10 +18,18 @@ interface IAccountSearch {
|
|||
placeholder?: string,
|
||||
/** Position of results relative to the input. */
|
||||
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. */
|
||||
const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
|
||||
const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, className, showButtons = true, ...rest }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
|
@ -56,11 +66,12 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className='search search--account'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
|
||||
<div className='w-full'>
|
||||
<label className='sr-only'>{intl.formatMessage(messages.placeholder)}</label>
|
||||
|
||||
<div className='relative'>
|
||||
<AutosuggestAccountInput
|
||||
className='rounded-full'
|
||||
className={classNames('rounded-full', className)}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
|
@ -68,10 +79,26 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
|
|||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
</label>
|
||||
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}>
|
||||
<Icon src={require('@tabler/icons/search.svg')} className={classNames('svg-icon--search', { active: isEmpty() })} />
|
||||
<Icon src={require('@tabler/icons/backspace.svg')} className={classNames('svg-icon--backspace', { active: !isEmpty() })} aria-label={intl.formatMessage(messages.placeholder)} />
|
||||
|
||||
{showButtons && (
|
||||
<div
|
||||
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>
|
||||
);
|
||||
|
|
|
@ -14,6 +14,7 @@ const noOp = () => { };
|
|||
interface IAutosuggestAccountInput {
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>,
|
||||
onSelected: (accountId: string) => void,
|
||||
autoFocus?: boolean,
|
||||
value: string,
|
||||
limit?: number,
|
||||
className?: string,
|
||||
|
@ -21,6 +22,8 @@ interface IAutosuggestAccountInput {
|
|||
menu?: Menu,
|
||||
onKeyDown?: React.KeyboardEventHandler,
|
||||
theme?: InputThemes,
|
||||
/** Search only among people who follow you (TruthSocial). */
|
||||
followers?: boolean,
|
||||
}
|
||||
|
||||
const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
|
||||
|
@ -28,6 +31,7 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
|
|||
onSelected,
|
||||
value = '',
|
||||
limit = 4,
|
||||
followers = false,
|
||||
...rest
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
@ -44,7 +48,7 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
|
|||
};
|
||||
|
||||
const handleAccountSearch = useCallback(throttle(q => {
|
||||
const params = { q, limit, resolve: false };
|
||||
const params = { q, limit, followers, resolve: false };
|
||||
|
||||
dispatch(accountSearch(params, controller.current.signal))
|
||||
.then((accounts: { id: string }[]) => {
|
||||
|
@ -67,6 +71,12 @@ const AutosuggestAccountInput: React.FC<IAutosuggestAccountInput> = ({
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (rest.autoFocus) {
|
||||
handleAccountSearch('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === '') {
|
||||
clearResults();
|
||||
|
|
|
@ -9,42 +9,13 @@ import Icon from 'soapbox/components/icon';
|
|||
import { Input } from 'soapbox/components/ui';
|
||||
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
||||
import { isRtl } from 'soapbox/rtl';
|
||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||
|
||||
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
|
||||
import type { InputThemes } from 'soapbox/components/ui/input/input';
|
||||
|
||||
type CursorMatch = [
|
||||
tokenStart: number | null,
|
||||
token: string | null,
|
||||
];
|
||||
|
||||
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'> {
|
||||
value: string,
|
||||
suggestions: ImmutableList<any>,
|
||||
|
@ -62,6 +33,7 @@ export interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputEl
|
|||
menu?: Menu,
|
||||
resultsPosition: string,
|
||||
renderSuggestion?: React.FC<{ id: string }>,
|
||||
hidePortal?: boolean,
|
||||
theme?: InputThemes,
|
||||
}
|
||||
|
||||
|
@ -89,7 +61,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
input: HTMLInputElement | null = null;
|
||||
|
||||
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) {
|
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
|
||||
|
@ -292,11 +268,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
}
|
||||
|
||||
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 style: React.CSSProperties = { direction: 'ltr' };
|
||||
|
||||
const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
|
||||
const visible = !hidePortal && !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
|
||||
|
||||
if (isRtl(value)) {
|
||||
style.direction = 'rtl';
|
||||
|
|
|
@ -4,6 +4,8 @@ import React from 'react';
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||
|
||||
import AutosuggestAccount from '../features/compose/components/autosuggest-account';
|
||||
import { isRtl } from '../rtl';
|
||||
|
||||
|
@ -11,31 +13,6 @@ import AutosuggestEmoji, { Emoji } from './autosuggest-emoji';
|
|||
|
||||
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 {
|
||||
id?: string,
|
||||
value: string,
|
||||
|
@ -72,7 +49,11 @@ class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea>
|
|||
};
|
||||
|
||||
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) {
|
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
|
||||
|
|
|
@ -219,7 +219,12 @@ class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState
|
|||
// It should not be transformed when mounting because the resulting
|
||||
// size will be used to determine the coordinate of the menu by
|
||||
// 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 }} />
|
||||
<ul>
|
||||
{items.map((option, i) => this.renderItem(option, i))}
|
||||
|
|
|
@ -5,18 +5,19 @@ import { Counter } from 'soapbox/components/ui';
|
|||
|
||||
interface IIconWithCounter extends React.HTMLAttributes<HTMLDivElement> {
|
||||
count: number,
|
||||
countMax?: number
|
||||
icon?: string;
|
||||
src?: string;
|
||||
}
|
||||
|
||||
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, ...rest }) => {
|
||||
const IconWithCounter: React.FC<IIconWithCounter> = ({ icon, count, countMax, ...rest }) => {
|
||||
return (
|
||||
<div className='relative'>
|
||||
<Icon id={icon} {...rest as IIcon} />
|
||||
|
||||
{count > 0 && (
|
||||
<span className='absolute -top-2 -right-2'>
|
||||
<Counter count={count} />
|
||||
<span className='absolute -top-2 -right-3'>
|
||||
<Counter count={count} countMax={countMax} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -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;
|
|
@ -14,10 +14,12 @@ const List: React.FC = ({ children }) => (
|
|||
interface IListItem {
|
||||
label: 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 domId = `list-group-${id}`;
|
||||
|
||||
|
@ -28,8 +30,8 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
|
|||
};
|
||||
|
||||
const Comp = onClick ? 'a' : 'div';
|
||||
const LabelComp = onClick ? 'span' : 'label';
|
||||
const linkProps = onClick ? { onClick, onKeyDown, tabIndex: 0, role: 'link' } : {};
|
||||
const LabelComp = onClick || onSelect ? 'span' : 'label';
|
||||
const linkProps = onClick || onSelect ? { onClick: onClick || onSelect, onKeyDown, tabIndex: 0, role: 'link' } : {};
|
||||
|
||||
const renderChildren = React.useCallback(() => {
|
||||
return React.Children.map(children, (child) => {
|
||||
|
@ -52,7 +54,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
|
|||
<Comp
|
||||
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,
|
||||
'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}
|
||||
>
|
||||
|
@ -70,7 +72,19 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
|
|||
|
||||
<Icon src={require('@tabler/icons/chevron-right.svg')} className='ml-1' />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
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 { Attachment } from 'soapbox/types/entities';
|
||||
import { truncateFilename } from 'soapbox/utils/media';
|
||||
|
@ -262,7 +262,7 @@ const Item: React.FC<IItem> = ({
|
|||
interface IMediaGallery {
|
||||
sensitive?: boolean,
|
||||
media: ImmutableList<Attachment>,
|
||||
height: number,
|
||||
height?: number,
|
||||
onOpenMedia: (media: ImmutableList<Attachment>, index: number) => void,
|
||||
defaultWidth?: number,
|
||||
cacheWidth?: (width: number) => void,
|
||||
|
|
|
@ -8,6 +8,8 @@ import { cancelReplyCompose } from 'soapbox/actions/compose';
|
|||
import { cancelEventCompose } from 'soapbox/actions/events';
|
||||
import { openModal, closeModal } from 'soapbox/actions/modals';
|
||||
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 { 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') {
|
||||
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 {
|
||||
onClose();
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ import { Icon, Text } from './ui';
|
|||
interface ISidebarNavigationLink {
|
||||
/** Notification count, if any. */
|
||||
count?: number,
|
||||
/** Optional max to cap count (ie: N+) */
|
||||
countMax?: number
|
||||
/** URL to an SVG icon. */
|
||||
icon: string,
|
||||
/** Link label. */
|
||||
|
@ -19,7 +21,7 @@ interface ISidebarNavigationLink {
|
|||
|
||||
/** Desktop sidebar navigation link. */
|
||||
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 handleClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
|
@ -45,6 +47,7 @@ const SidebarNavigationLink = React.forwardRef((props: ISidebarNavigationLink, r
|
|||
<Icon
|
||||
src={icon}
|
||||
count={count}
|
||||
countMax={countMax}
|
||||
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-primary-500 dark:text-primary-400': isActive,
|
||||
|
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
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 { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks';
|
||||
|
||||
|
@ -20,12 +21,12 @@ const messages = defineMessages({
|
|||
/** Desktop sidebar with links to different views in the app. */
|
||||
const SidebarNavigation = () => {
|
||||
const intl = useIntl();
|
||||
const { unreadChatsCount } = useStatContext();
|
||||
|
||||
const features = useFeatures();
|
||||
const settings = useSettings();
|
||||
const account = useOwnAccount();
|
||||
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 dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
|
||||
|
||||
|
@ -87,8 +88,9 @@ const SidebarNavigation = () => {
|
|||
<SidebarNavigationLink
|
||||
to='/chats'
|
||||
icon={require('@tabler/icons/messages.svg')}
|
||||
count={chatsCount}
|
||||
text={<FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' />}
|
||||
count={unreadChatsCount}
|
||||
countMax={9}
|
||||
text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -241,7 +241,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
secondary: intl.formatMessage(messages.blockAndReport),
|
||||
onSecondary: () => {
|
||||
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) => {
|
||||
dispatch(initReport(status.account as Account, status));
|
||||
dispatch(initReport(status.account as Account, { status }));
|
||||
};
|
||||
|
||||
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Icon, Text } from 'soapbox/components/ui';
|
|||
|
||||
interface IThumbNavigationLink {
|
||||
count?: number,
|
||||
countMax?: number,
|
||||
src: string,
|
||||
text: string | React.ReactElement,
|
||||
to: string,
|
||||
|
@ -14,7 +15,7 @@ interface IThumbNavigationLink {
|
|||
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 isActive = (): boolean => {
|
||||
|
@ -38,6 +39,7 @@ const ThumbNavigationLink: React.FC<IThumbNavigationLink> = ({ count, src, text,
|
|||
'text-primary-500': active,
|
||||
})}
|
||||
count={count}
|
||||
countMax={countMax}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
|
|
|
@ -2,12 +2,14 @@ import React from 'react';
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import ThumbNavigationLink from 'soapbox/components/thumb-navigation-link';
|
||||
import { useStatContext } from 'soapbox/contexts/stat-context';
|
||||
import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
const ThumbNavigation: React.FC = (): JSX.Element => {
|
||||
const account = useOwnAccount();
|
||||
const { unreadChatsCount } = useStatContext();
|
||||
|
||||
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 features = useFeatures();
|
||||
|
||||
|
@ -20,7 +22,8 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
|
|||
text={<FormattedMessage id='navigation.chats' defaultMessage='Chats' />}
|
||||
to='/chats'
|
||||
exact
|
||||
count={chatsCount}
|
||||
count={unreadChatsCount}
|
||||
countMax={9}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import './combobox.css';
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxPopover,
|
||||
ComboboxList,
|
||||
ComboboxOption,
|
||||
ComboboxOptionText,
|
||||
} from '@reach/combobox';
|
|
@ -5,13 +5,15 @@ import { shortNumberFormat } from 'soapbox/utils/numbers';
|
|||
interface ICounter {
|
||||
/** Number this counter should display. */
|
||||
count: number,
|
||||
/** Optional max number (ie: N+) */
|
||||
countMax?: number
|
||||
}
|
||||
|
||||
/** A simple counter for notifications, etc. */
|
||||
const Counter: React.FC<ICounter> = ({ count }) => {
|
||||
const Counter: React.FC<ICounter> = ({ count, countMax }) => {
|
||||
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'>
|
||||
{shortNumberFormat(count)}
|
||||
<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, countMax)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -18,7 +18,7 @@ const Divider = ({ text, textSize = 'md' }: IDivider) => (
|
|||
|
||||
{text && (
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -14,6 +14,7 @@ const alignItemsOptions = {
|
|||
bottom: 'items-end',
|
||||
center: 'items-center',
|
||||
start: 'items-start',
|
||||
stretch: 'items-stretch',
|
||||
};
|
||||
|
||||
const spaces = {
|
||||
|
|
|
@ -9,6 +9,8 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
|||
className?: string,
|
||||
/** Number to display a counter over the icon. */
|
||||
count?: number,
|
||||
/** Optional max to cap count (ie: N+) */
|
||||
countMax?: number,
|
||||
/** Tooltip text for the icon. */
|
||||
alt?: string,
|
||||
/** URL to the svg icon. */
|
||||
|
@ -18,11 +20,11 @@ interface IIcon extends Pick<React.SVGAttributes<SVGAElement>, 'strokeWidth'> {
|
|||
}
|
||||
|
||||
/** Renders and SVG icon with optional counter. */
|
||||
const Icon: React.FC<IIcon> = ({ src, alt, count, size, ...filteredProps }): JSX.Element => (
|
||||
<div className='relative' data-testid='icon'>
|
||||
const Icon: React.FC<IIcon> = ({ src, alt, count, size, countMax, ...filteredProps }): JSX.Element => (
|
||||
<div className='flex flex-col flex-shrink-0 relative' data-testid='icon'>
|
||||
{count ? (
|
||||
<span className='absolute -top-2 -right-3'>
|
||||
<Counter count={count} />
|
||||
<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} countMax={countMax} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
|
|
|
@ -5,6 +5,14 @@ export { default as Button } from './button/button';
|
|||
export { Card, CardBody, CardHeader, CardTitle } from './card/card';
|
||||
export { default as Checkbox } from './checkbox/checkbox';
|
||||
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 Datepicker } from './datepicker/datepicker';
|
||||
export { default as Divider } from './divider/divider';
|
||||
|
|
|
@ -12,7 +12,7 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
/** 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'> {
|
||||
/** Put the cursor into the input on mount. */
|
||||
|
@ -61,9 +61,11 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
|||
return (
|
||||
<div
|
||||
className={
|
||||
classNames('mt-1 relative shadow-sm', outerClassName, {
|
||||
classNames('relative', {
|
||||
'rounded-md': 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}
|
||||
type={revealed ? 'text' : type}
|
||||
ref={ref}
|
||||
className={classNames({
|
||||
'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':
|
||||
className={classNames('text-base placeholder:text-gray-600 dark:placeholder:text-gray-600', {
|
||||
'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),
|
||||
'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',
|
||||
'bg-transparent border-none': theme === 'transparent',
|
||||
'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append,
|
||||
'text-red-600 border-red-600': hasError,
|
||||
'pl-8': typeof icon !== 'undefined',
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
[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;
|
||||
|
||||
z-index: 1003;
|
||||
@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];
|
||||
}
|
||||
|
||||
[data-reach-menu-button] {
|
||||
|
|
|
@ -5,28 +5,36 @@ import {
|
|||
MenuItems,
|
||||
MenuPopover,
|
||||
MenuLink,
|
||||
MenuPopoverProps,
|
||||
MenuListProps,
|
||||
} from '@reach/menu-button';
|
||||
import { positionDefault, positionRight } from '@reach/popover';
|
||||
import classNames from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import './menu.css';
|
||||
|
||||
interface IMenuList extends Omit<MenuPopoverProps, 'position'> {
|
||||
interface IMenuList extends Omit<MenuListProps, 'position'> {
|
||||
/** Position of the dropdown menu. */
|
||||
position?: 'left' | 'right'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Renders children as a dropdown menu. */
|
||||
const MenuList: React.FC<IMenuList> = (props) => (
|
||||
const MenuList: React.FC<IMenuList> = (props) => {
|
||||
const { position, className, ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<MenuPopover position={props.position === 'left' ? positionDefault : positionRight}>
|
||||
<MenuItems
|
||||
onKeyDown={(event) => event.nativeEvent.stopImmediatePropagation()}
|
||||
className='py-1 bg-white dark:bg-primary-900 rounded-lg shadow-menu'
|
||||
{...props}
|
||||
className={
|
||||
classNames(className, 'py-1 bg-white dark:bg-primary-900 rounded-lg shadow-menu')
|
||||
}
|
||||
{...filteredProps}
|
||||
/>
|
||||
</MenuPopover>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
/** Divides menu items. */
|
||||
const MenuDivider = () => <hr />;
|
||||
|
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import Button from '../button/button';
|
||||
import { ButtonThemes } from '../button/useButtonStyles';
|
||||
import HStack from '../hstack/hstack';
|
||||
import IconButton from '../icon-button/icon-button';
|
||||
|
||||
|
@ -38,7 +39,7 @@ interface IModal {
|
|||
/** Confirmation button text. */
|
||||
confirmationText?: React.ReactNode,
|
||||
/** Confirmation button theme. */
|
||||
confirmationTheme?: 'danger',
|
||||
confirmationTheme?: ButtonThemes,
|
||||
/** Callback when the modal is closed. */
|
||||
onClose?: () => void,
|
||||
/** Callback when the secondary action is chosen. */
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
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'> {
|
||||
/** Put the cursor into the input on mount. */
|
||||
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. */
|
||||
defaultValue?: string,
|
||||
/** Internal input name. */
|
||||
|
@ -18,24 +24,64 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
|
|||
autoComplete?: string,
|
||||
/** Whether to display the textarea in red. */
|
||||
hasError?: boolean,
|
||||
/** Whether or not you can resize the teztarea */
|
||||
isResizeable?: boolean,
|
||||
}
|
||||
|
||||
/** Textarea with custom styles. */
|
||||
const Textarea = React.forwardRef(
|
||||
({ isCodeEditor = false, hasError = false, ...props }: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
|
||||
const Textarea = React.forwardRef(({
|
||||
isCodeEditor = false,
|
||||
hasError = false,
|
||||
isResizeable = true,
|
||||
onChange,
|
||||
autoGrow = false,
|
||||
maxRows = 10,
|
||||
minRows = 1,
|
||||
...props
|
||||
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
|
||||
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;
|
||||
|
|
|
@ -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;
|
|
@ -17,6 +17,7 @@ import * as BuildConfig from 'soapbox/build-config';
|
|||
import GdprBanner from 'soapbox/components/gdpr-banner';
|
||||
import Helmet from 'soapbox/components/helmet';
|
||||
import LoadingScreen from 'soapbox/components/loading-screen';
|
||||
import { StatProvider } from 'soapbox/contexts/stat-context';
|
||||
import AuthLayout from 'soapbox/features/auth-layout';
|
||||
import EmbeddedStatus from 'soapbox/features/embedded-status';
|
||||
import PublicLayout from 'soapbox/features/public-layout';
|
||||
|
@ -85,6 +86,7 @@ const loadInitial = () => {
|
|||
/** Highest level node with the Redux store. */
|
||||
const SoapboxMount = () => {
|
||||
useCachedLocationHandler();
|
||||
|
||||
const me = useAppSelector(state => state.me);
|
||||
const instance = useInstance();
|
||||
const account = useOwnAccount();
|
||||
|
@ -295,11 +297,13 @@ const Soapbox: React.FC = () => {
|
|||
return (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StatProvider>
|
||||
<SoapboxHead>
|
||||
<SoapboxLoad>
|
||||
<SoapboxMount />
|
||||
</SoapboxLoad>
|
||||
</SoapboxHead>
|
||||
</StatProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -1,12 +1,13 @@
|
|||
'use strict';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
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 { blockDomain, unblockDomain } from 'soapbox/actions/domain-blocks';
|
||||
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 { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||
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 { isRemote } from 'soapbox/utils/accounts';
|
||||
|
||||
|
@ -82,6 +85,21 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
const features = useFeatures();
|
||||
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) {
|
||||
return (
|
||||
<div className='-mt-4 -mx-4'>
|
||||
|
@ -141,11 +159,11 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
if (account.relationship?.endorsed) {
|
||||
dispatch(unpinAccount(account.id))
|
||||
.then(() => dispatch(snackbar.success(intl.formatMessage(messages.userUnendorsed, { acct: account.acct }))))
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
} else {
|
||||
dispatch(pinAccount(account.id))
|
||||
.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 = () => {
|
||||
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
|
||||
};
|
||||
|
@ -304,13 +318,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
icon: require('@tabler/icons/at.svg'),
|
||||
});
|
||||
|
||||
if (account.getIn(['pleroma', 'accepts_chat_messages']) === true) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.chat, { name: account.username }),
|
||||
action: onChat,
|
||||
icon: require('@tabler/icons/messages.svg'),
|
||||
});
|
||||
} else if (features.privacyScopes) {
|
||||
if (features.privacyScopes) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.direct, { name: account.username }),
|
||||
action: onDirect,
|
||||
|
@ -494,34 +502,43 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
return info;
|
||||
};
|
||||
|
||||
// const renderMessageButton = () => {
|
||||
// if (!ownAccount || !account || account.id === ownAccount?.id) {
|
||||
// return null;
|
||||
// }
|
||||
const renderMessageButton = () => {
|
||||
if (features.chatsWithFollowers) { // Truth Social
|
||||
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 (
|
||||
// <IconButton
|
||||
// src={require('@tabler/icons/messages.svg')}
|
||||
// onClick={onChat}
|
||||
// title={intl.formatMessage(messages.chat, { name: account.username })}
|
||||
// />
|
||||
// );
|
||||
// } else {
|
||||
// return (
|
||||
// <IconButton
|
||||
// src={require('@tabler/icons/mail.svg')}
|
||||
// onClick={onDirect}
|
||||
// title={intl.formatMessage(messages.direct, { name: account.username })}
|
||||
// theme='outlined'
|
||||
// className='px-2'
|
||||
// iconClassName='w-4 h-4'
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
// };
|
||||
return (
|
||||
<IconButton
|
||||
src={require('@tabler/icons/messages.svg')}
|
||||
onClick={() => createAndNavigateToChat.mutate(account.id)}
|
||||
title={intl.formatMessage(messages.chat, { name: account.username })}
|
||||
theme='outlined'
|
||||
className='px-2'
|
||||
iconClassName='w-4 h-4'
|
||||
disabled={createAndNavigateToChat.isLoading}
|
||||
/>
|
||||
);
|
||||
} else if (account.getIn(['pleroma', 'accepts_chat_messages']) === true) {
|
||||
return (
|
||||
<IconButton
|
||||
src={require('@tabler/icons/messages.svg')}
|
||||
onClick={() => createAndNavigateToChat.mutate(account.id)}
|
||||
title={intl.formatMessage(messages.chat, { name: account.username })}
|
||||
theme='outlined'
|
||||
className='px-2'
|
||||
iconClassName='w-4 h-4'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderShareButton = () => {
|
||||
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'>
|
||||
<HStack space={2} className='mt-10'>
|
||||
<SubscriptionButton account={account} />
|
||||
{renderMessageButton()}
|
||||
{renderShareButton()}
|
||||
|
||||
{ownAccount && (
|
||||
<Menu>
|
||||
|
@ -597,7 +616,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
children={null}
|
||||
/>
|
||||
|
||||
<MenuList>
|
||||
<MenuList className='w-56'>
|
||||
{menu.map((menuItem, idx) => {
|
||||
if (typeof menuItem?.text === 'undefined') {
|
||||
return <MenuDivider key={idx} />;
|
||||
|
@ -622,9 +641,6 @@ const Header: React.FC<IHeader> = ({ account }) => {
|
|||
</Menu>
|
||||
)}
|
||||
|
||||
{renderShareButton()}
|
||||
{/* {renderMessageButton()} */}
|
||||
|
||||
<ActionButton account={account} />
|
||||
</HStack>
|
||||
</div>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
|
|||
import { Avatar, Card, HStack, Icon, IconButton, Stack, Text } from 'soapbox/components/ui';
|
||||
import StatusCard from 'soapbox/features/status/components/card';
|
||||
import { useInstance } from 'soapbox/hooks';
|
||||
import { AdKeys } from 'soapbox/queries/ads';
|
||||
|
||||
import type { Ad as AdEntity } from 'soapbox/types/soapbox';
|
||||
|
||||
|
@ -31,7 +32,7 @@ const Ad: React.FC<IAd> = ({ ad }) => {
|
|||
|
||||
/** Invalidate query cache for ads. */
|
||||
const bustCache = (): void => {
|
||||
queryClient.invalidateQueries(['ads']);
|
||||
queryClient.invalidateQueries(AdKeys.ads);
|
||||
};
|
||||
|
||||
/** Toggle the info box on click. */
|
||||
|
@ -106,7 +107,7 @@ const Ad: React.FC<IAd> = ({ ad }) => {
|
|||
</Stack>
|
||||
</HStack>
|
||||
|
||||
<StatusCard card={ad.card} onOpenMedia={() => {}} horizontal />
|
||||
<StatusCard card={ad.card} onOpenMedia={() => { }} horizontal />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,95 +1,93 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React, { useCallback } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import classNames from 'clsx';
|
||||
import React, { useRef, useState } from 'react';
|
||||
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 { Card, Text } from 'soapbox/components/ui';
|
||||
import { Spinner, Stack } from 'soapbox/components/ui';
|
||||
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';
|
||||
|
||||
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,
|
||||
);
|
||||
import ChatListItem from './chat-list-item';
|
||||
|
||||
interface IChatList {
|
||||
onClickChat: (chat: any) => void,
|
||||
useWindowScroll?: boolean,
|
||||
searchValue?: string
|
||||
}
|
||||
|
||||
const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false, searchValue }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const chatIds = useAppSelector(state => sortedChatIdsSelector(state.chats.items));
|
||||
const hasMore = useAppSelector(state => !!state.chats.next);
|
||||
const isLoading = useAppSelector(state => state.chats.isLoading);
|
||||
const chatListRef = useRef(null);
|
||||
|
||||
const isEmpty = chatIds.size === 0;
|
||||
const { chatsQuery: { data: chats, isFetching, hasNextPage, fetchNextPage } } = useChats(searchValue);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (hasMore && !isLoading) {
|
||||
dispatch(expandChats());
|
||||
const [isNearBottom, setNearBottom] = useState<boolean>(false);
|
||||
const [isNearTop, setNearTop] = useState<boolean>(true);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [dispatch, hasMore, isLoading]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
return dispatch(fetchChats()) as any;
|
||||
};
|
||||
|
||||
const renderEmpty = () => isLoading ? <PlaceholderChat /> : (
|
||||
<Card className='mt-2' variant='rounded' size='lg'>
|
||||
<Text>{intl.formatMessage(messages.emptyMessage)}</Text>
|
||||
</Card>
|
||||
const handleRefresh = () => dispatch(fetchChats());
|
||||
|
||||
const renderEmpty = () => {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Stack space={2}>
|
||||
<PlaceholderChat />
|
||||
<PlaceholderChat />
|
||||
<PlaceholderChat />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative h-full'>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{isEmpty ? renderEmpty() : (
|
||||
<Virtuoso
|
||||
className='chat-list'
|
||||
ref={chatListRef}
|
||||
atTopStateChange={(atTop) => setNearTop(atTop)}
|
||||
atBottomStateChange={(atBottom) => setNearBottom(atBottom)}
|
||||
useWindowScroll={useWindowScroll}
|
||||
data={chatIds.toArray()}
|
||||
data={chats}
|
||||
endReached={handleLoadMore}
|
||||
itemContent={(_index, chatId) => (
|
||||
<Chat chatId={chatId} onClick={onClickChat} />
|
||||
)}
|
||||
itemContent={(_index, chat) => (
|
||||
<div className='px-2'>
|
||||
<ChatListItem chat={chat} onClick={onClickChat} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
components={{
|
||||
ScrollSeekPlaceholder: () => <PlaceholderChat />,
|
||||
Footer: () => hasMore ? <PlaceholderChat /> : null,
|
||||
Footer: () => hasNextPage ? <Spinner withText={false} /> : null,
|
||||
EmptyPlaceholder: renderEmpty,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -1,41 +1,49 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import classNames from 'clsx';
|
||||
import {
|
||||
Map as ImmutableMap,
|
||||
List as ImmutableList,
|
||||
OrderedSet as ImmutableOrderedSet,
|
||||
} from 'immutable';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import escape from 'lodash/escape';
|
||||
import throttle from 'lodash/throttle';
|
||||
import React, { useState, useEffect, useRef, useLayoutEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
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 { initReportById } from 'soapbox/actions/reports';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { initReport } from 'soapbox/actions/reports';
|
||||
import { Avatar, Button, Divider, HStack, Icon, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import PlaceholderChatMessage from 'soapbox/features/placeholder/components/placeholder-chat-message';
|
||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||
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 ChatMessageListIntro from './chat-message-list-intro';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown-menu';
|
||||
import type { ChatMessage as ChatMessageEntity } from 'soapbox/types/entities';
|
||||
|
||||
const BIG_EMOJI_LIMIT = 1;
|
||||
const BIG_EMOJI_LIMIT = 3;
|
||||
|
||||
const messages = defineMessages({
|
||||
today: { id: 'chats.dividers.today', defaultMessage: 'Today' },
|
||||
more: { id: 'chats.actions.more', defaultMessage: 'More' },
|
||||
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete message' },
|
||||
report: { id: 'chats.actions.report', defaultMessage: 'Report user' },
|
||||
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' },
|
||||
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';
|
||||
|
||||
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 currDate = new Date(curr.created_at).getDate();
|
||||
const nowDate = new Date().getDate();
|
||||
|
@ -51,58 +59,102 @@ const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).redu
|
|||
return map.set(`:${emoji.get('shortcode')}:`, emoji);
|
||||
}, ImmutableMap());
|
||||
|
||||
const getChatMessages = createSelector(
|
||||
[(chatMessages: ImmutableMap<string, ChatMessageEntity>, chatMessageIds: ImmutableOrderedSet<string>) => (
|
||||
chatMessageIds.reduce((acc, curr) => {
|
||||
const chatMessage = chatMessages.get(curr);
|
||||
return chatMessage ? acc.push(chatMessage) : acc;
|
||||
}, ImmutableList<ChatMessageEntity>())
|
||||
)],
|
||||
chatMessages => chatMessages,
|
||||
);
|
||||
const START_INDEX = 10000;
|
||||
|
||||
const List: Components['List'] = React.forwardRef((props, ref) => {
|
||||
const { context, ...rest } = props;
|
||||
return <div ref={ref} {...rest} className='mb-2' />;
|
||||
});
|
||||
|
||||
interface IChatMessageList {
|
||||
/** Chat the messages are being rendered from. */
|
||||
chatId: string,
|
||||
/** Message IDs to render. */
|
||||
chatMessageIds: ImmutableOrderedSet<string>,
|
||||
/** Whether to make the chatbox fill the height of the screen. */
|
||||
autosize?: boolean,
|
||||
chat: IChat,
|
||||
}
|
||||
|
||||
/** Scrollable list of chat messages. */
|
||||
const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, autosize }) => {
|
||||
const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const account = useOwnAccount();
|
||||
const features = useFeatures();
|
||||
|
||||
const me = useAppSelector(state => state.me);
|
||||
const chatMessages = useAppSelector(state => getChatMessages(state.chat_messages, chatMessageIds));
|
||||
const lastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === chat.account.id)?.date;
|
||||
const myLastReadMessageDateString = chat.latest_read_message_by_account?.find((latest) => latest.id === account?.id)?.date;
|
||||
const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null;
|
||||
const myLastReadMessageTimestamp = myLastReadMessageDateString ? new Date(myLastReadMessageDateString) : null;
|
||||
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const node = useRef<VirtuosoHandle>(null);
|
||||
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const messagesEnd = useRef<HTMLDivElement>(null);
|
||||
const lastComputedScroll = useRef<number | undefined>(undefined);
|
||||
const scrollBottom = useRef<number | undefined>(undefined);
|
||||
const { deleteChatMessage, markChatAsRead } = useChatActions(chat.id);
|
||||
const {
|
||||
data: chatMessages,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useChatMessages(chat);
|
||||
|
||||
const initialCount = useMemo(() => chatMessages.count(), []);
|
||||
const formattedChatMessages = chatMessages || [];
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEnd.current?.scrollIntoView(false);
|
||||
};
|
||||
const me = useAppSelector((state) => state.me);
|
||||
const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by']));
|
||||
|
||||
const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(ChatKeys.chatMessages(chat.id));
|
||||
},
|
||||
});
|
||||
|
||||
const lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null;
|
||||
|
||||
const cachedChatMessages = useMemo(() => {
|
||||
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) => {
|
||||
return intl.formatDate(
|
||||
new Date(chatMessage.created_at), {
|
||||
return intl.formatDate(new Date(chatMessage.created_at), {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const setBubbleRef = (c: HTMLDivElement) => {
|
||||
|
@ -114,54 +166,14 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
|
|||
link.setAttribute('rel', 'ugc nofollow noopener');
|
||||
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 elem = node.current;
|
||||
if (!elem) return false;
|
||||
|
||||
const scrollBottom = elem.scrollHeight - elem.offsetHeight - elem.scrollTop;
|
||||
return scrollBottom < elem.offsetHeight * 1.5;
|
||||
};
|
||||
|
||||
const handleResize = throttle(() => {
|
||||
if (isNearBottom()) {
|
||||
scrollToBottom();
|
||||
const handleStartReached = useCallback(() => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, 150);
|
||||
|
||||
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,
|
||||
}));
|
||||
return false;
|
||||
}, [firstItemIndex, hasNextPage, isFetching]);
|
||||
|
||||
const onOpenMedia = (media: any, index: number) => {
|
||||
dispatch(openModal('MEDIA', { media, index }));
|
||||
|
@ -171,18 +183,15 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
|
|||
const { attachment } = chatMessage;
|
||||
if (!attachment) return null;
|
||||
return (
|
||||
<div className='chat-message__media'>
|
||||
<Bundle fetchComponent={MediaGallery}>
|
||||
{(Component: any) => (
|
||||
<Component
|
||||
media={ImmutableList([attachment])}
|
||||
height={120}
|
||||
onOpenMedia={onOpenMedia}
|
||||
visible
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -199,137 +208,279 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
|
|||
return emojify(formatted, emojiMap.toJS());
|
||||
};
|
||||
|
||||
const renderDivider = (key: React.Key, text: string) => (
|
||||
<div className='chat-messages__divider' key={key}>{text}</div>
|
||||
);
|
||||
const renderDivider = (key: React.Key, text: string) => <Divider key={key} text={text} textSize='sm' />;
|
||||
|
||||
const handleDeleteMessage = (chatId: string, messageId: string) => {
|
||||
return () => {
|
||||
dispatch(deleteChatMessage(chatId, messageId));
|
||||
};
|
||||
};
|
||||
|
||||
const handleReportUser = (userId: string) => {
|
||||
return () => {
|
||||
dispatch(initReportById(userId));
|
||||
};
|
||||
const handleCopyText = (chatMessage: ChatMessageEntity) => {
|
||||
if (navigator.clipboard) {
|
||||
const text = stripHTML(chatMessage.content);
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
};
|
||||
|
||||
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),
|
||||
action: handleDeleteMessage(chatMessage.chat_id, chatMessage.id),
|
||||
action: () => handleDeleteMessage.mutate(chatMessage.id),
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
destructive: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (chatMessage.account_id !== me) {
|
||||
});
|
||||
} else {
|
||||
if (features.reportChats) {
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.report),
|
||||
action: handleReportUser(chatMessage.account_id),
|
||||
action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)),
|
||||
icon: require('@tabler/icons/flag.svg'),
|
||||
});
|
||||
}
|
||||
menu.push({
|
||||
text: intl.formatMessage(messages.deleteForMe),
|
||||
action: () => handleDeleteMessage.mutate(chatMessage.id),
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('chat-message', {
|
||||
'chat-message--me': chatMessage.account_id === me,
|
||||
'chat-message--pending': chatMessage.pending,
|
||||
<div key={chatMessage.id} className='group' data-testid='chat-message'>
|
||||
<Stack
|
||||
space={1.5}
|
||||
className={classNames({
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
key={chatMessage.id}
|
||||
>
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent={isMyMessage ? 'end' : 'start'}
|
||||
className={classNames({
|
||||
'opacity-50': chatMessage.pending,
|
||||
})}
|
||||
>
|
||||
{menu.length > 0 && (
|
||||
<div
|
||||
title={getFormattedTimestamp(chatMessage)}
|
||||
className='chat-message__bubble'
|
||||
ref={setBubbleRef}
|
||||
tabIndex={0}
|
||||
className={classNames({
|
||||
'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'
|
||||
>
|
||||
{maybeRenderMedia(chatMessage)}
|
||||
<Text size='sm' dangerouslySetInnerHTML={{ __html: parseContent(chatMessage) }} />
|
||||
<div className='chat-message__menu'>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
dropdownMenuStyle={{ zIndex: 1000 }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchChatMessages(chatId));
|
||||
|
||||
node.current?.addEventListener('scroll', e => handleScroll.current(e));
|
||||
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();
|
||||
const lastMessage = formattedChatMessages[formattedChatMessages.length - 1];
|
||||
if (!lastMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First load.
|
||||
if (chatMessages.count() !== initialCount) {
|
||||
setInitialLoad(false);
|
||||
setIsLoading(false);
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [chatMessages.count()]);
|
||||
const lastMessageId = lastMessage.id;
|
||||
const isMessagePending = lastMessage.pending;
|
||||
const isAlreadyRead = myLastReadMessageTimestamp ? myLastReadMessageTimestamp >= new Date(lastMessage.created_at) : false;
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messagesEnd.current]);
|
||||
|
||||
// History added.
|
||||
useEffect(() => {
|
||||
// Restore scroll bar position when loading old messages.
|
||||
if (!initialLoad) {
|
||||
restoreScrollPosition();
|
||||
/**
|
||||
* Only "mark the message as read" if..
|
||||
* 1) it is not pending and
|
||||
* 2) it has not already been read
|
||||
*/
|
||||
if (!isMessagePending && !isAlreadyRead) {
|
||||
markChatAsRead(lastMessageId);
|
||||
}
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
}, [chatMessageIds.first()]);
|
||||
|
||||
return (
|
||||
<div className='chat-messages' style={{ height: autosize ? 'calc(100vh - 16rem)' : undefined }} ref={node}>
|
||||
{chatMessages.reduce((acc, curr, idx) => {
|
||||
const lastMessage = chatMessages.get(idx - 1);
|
||||
|
||||
if (lastMessage) {
|
||||
const key = `${curr.id}_divider`;
|
||||
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;
|
||||
<div className='h-full flex flex-col flex-grow space-y-6'>
|
||||
<div className='flex-grow flex flex-col justify-end pb-2'>
|
||||
<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} />;
|
||||
}
|
||||
|
||||
acc.push(renderMessage(curr));
|
||||
return acc;
|
||||
}, [] as React.ReactNode[])}
|
||||
<div style={{ float: 'left', clear: 'both' }} ref={messagesEnd} />
|
||||
if (!hasNextPage && !isLoading) {
|
||||
return <ChatMessageListIntro />;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,71 +1,198 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { AxiosError } from 'axios';
|
||||
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 Icon from 'soapbox/components/icon';
|
||||
import { Avatar, Counter, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetChat } from 'soapbox/selectors';
|
||||
import { uploadMedia } from 'soapbox/actions/media';
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
import Upload from 'soapbox/components/upload';
|
||||
import UploadProgress from 'soapbox/components/upload-progress';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
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 {
|
||||
chatId: string,
|
||||
onClick: (chat: any) => void,
|
||||
const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000));
|
||||
|
||||
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(), []);
|
||||
const chat = useAppSelector((state) => {
|
||||
const chat = state.chats.items.get(chatId);
|
||||
return chat ? getChat(state, (chat as any).toJS()) : undefined;
|
||||
}) as ChatEntity;
|
||||
/**
|
||||
* Clears the value of the input while dispatching the `onChange` function
|
||||
* which allows the <Textarea> to resize itself (this is important)
|
||||
* because we autoGrow the element as the user inputs text that spans
|
||||
* beyond one line
|
||||
*/
|
||||
const clearNativeInputValue = (element: HTMLTextAreaElement) => {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(element, '');
|
||||
|
||||
const account = chat.account as AccountEntity;
|
||||
if (!chat || !account) return null;
|
||||
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) : '';
|
||||
const ev2 = new Event('input', { bubbles: true });
|
||||
element.dispatchEvent(ev2);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className='account'>
|
||||
<button className='floating-link' onClick={() => onClick(chat)} />
|
||||
<HStack key={account.id} space={3} className='relative overflow-hidden'>
|
||||
<Avatar className='flex-none' src={account.avatar} size={36} />
|
||||
<Stack className='overflow-hidden flex-1'>
|
||||
<DisplayName account={account} withSuffix={false} />
|
||||
<HStack space={1} justifyContent='between'>
|
||||
{content ? (
|
||||
<Text
|
||||
theme='muted'
|
||||
size='sm'
|
||||
className='max-h-5'
|
||||
dangerouslySetInnerHTML={{ __html: parsedContent }}
|
||||
truncate
|
||||
/>
|
||||
) : attachment && (
|
||||
<Text theme='muted' size='sm' className='italic'>
|
||||
{image ? <FormattedMessage id='chats.attachment_image' defaultMessage='Image' /> : <FormattedMessage id='chats.attachment' defaultMessage='Attachment' />}
|
||||
</Text>
|
||||
)}
|
||||
<Stack className={classNames('overflow-hidden flex flex-grow', className)} onMouseOver={handleMouseOver}>
|
||||
<div className='flex-grow h-full overflow-hidden flex justify-center'>
|
||||
<ChatMessageList chat={chat} />
|
||||
</div>
|
||||
|
||||
{attachment && (
|
||||
<Icon
|
||||
className='chat__attachment-icon'
|
||||
src={image ? require('@tabler/icons/photo.svg') : require('@tabler/icons/paperclip.svg')}
|
||||
<div className='relative h-48'>
|
||||
<Upload
|
||||
media={attachment}
|
||||
onDelete={handleRemoveFile}
|
||||
withPreview
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
{unreadCount > 0 && (
|
||||
<div className='absolute top-1 right-0'>
|
||||
<Counter count={unreadCount} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className='p-4'>
|
||||
<UploadProgress progress={uploadProgress * 100} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatComposer
|
||||
ref={inputRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
onSubmit={sendMessage}
|
||||
errorMessage={errorMessage}
|
||||
onSelectFile={handleFiles}
|
||||
resetFileKey={resetFileKey}
|
||||
onPaste={handlePaste}
|
||||
hasAttachment={!!attachment}
|
||||
/>
|
||||
</Stack>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { Pane } from './pane';
|
|
@ -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 };
|
|
@ -1,51 +1,19 @@
|
|||
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 AccountSearch from 'soapbox/components/account-search';
|
||||
import AudioToggle from 'soapbox/features/chats/components/audio-toggle';
|
||||
import { ChatProvider } from 'soapbox/contexts/chat-context';
|
||||
|
||||
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({
|
||||
title: { id: 'column.chats', defaultMessage: 'Chats' },
|
||||
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
const ChatIndex: React.FC<IChatIndex> = ({ params }) => (
|
||||
<ChatProvider>
|
||||
<ChatPage chatId={params?.chatId} />
|
||||
</ChatProvider>
|
||||
);
|
||||
|
||||
export default ChatIndex;
|
||||
|
|
|
@ -20,6 +20,8 @@ export interface IUploadButton {
|
|||
onSelectFile: (files: FileList, intl: IntlShape) => void,
|
||||
style?: React.CSSProperties,
|
||||
resetFileKey: number | null,
|
||||
className?: string,
|
||||
iconClassName?: string,
|
||||
}
|
||||
|
||||
const UploadButton: React.FC<IUploadButton> = ({
|
||||
|
@ -27,6 +29,8 @@ const UploadButton: React.FC<IUploadButton> = ({
|
|||
unavailable = false,
|
||||
onSelectFile,
|
||||
resetFileKey,
|
||||
className = 'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500',
|
||||
iconClassName,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { configuration } = useInstance();
|
||||
|
@ -56,7 +60,8 @@ const UploadButton: React.FC<IUploadButton> = ({
|
|||
<div>
|
||||
<IconButton
|
||||
src={src}
|
||||
className='text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
|
||||
className={className}
|
||||
iconClassName={iconClassName}
|
||||
title={intl.formatMessage(messages.upload)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
|
|
|
@ -1,206 +1,44 @@
|
|||
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 React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { undoUploadCompose, changeUploadCompose, submitCompose } from 'soapbox/actions/compose';
|
||||
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 Upload from 'soapbox/components/upload';
|
||||
import { useAppDispatch, useCompose, useInstance } from 'soapbox/hooks';
|
||||
|
||||
import Motion from '../../ui/util/optional-motion';
|
||||
|
||||
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 {
|
||||
interface IUploadCompose {
|
||||
id: string,
|
||||
composeId: string,
|
||||
}
|
||||
|
||||
const Upload: React.FC<IUpload> = ({ composeId, id }) => {
|
||||
const intl = useIntl();
|
||||
const UploadCompose: React.FC<IUploadCompose> = ({ composeId, id }) => {
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const { description_limit: descriptionLimit } = useInstance();
|
||||
|
||||
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 = () => {
|
||||
handleInputBlur();
|
||||
dispatch(submitCompose(composeId, history));
|
||||
};
|
||||
|
||||
const handleUndoClick: React.MouseEventHandler = e => {
|
||||
e.stopPropagation();
|
||||
const handleDescriptionChange = (description: string) => {
|
||||
dispatch(changeUploadCompose(composeId, media.id, { description }));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
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 (
|
||||
<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 })}>
|
||||
<IconButton
|
||||
onClick={handleUndoClick}
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
text={<FormattedMessage id='upload_form.undo' defaultMessage='Delete' />}
|
||||
<Upload
|
||||
media={media}
|
||||
onDelete={handleDelete}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
onSubmit={handleSubmit}
|
||||
descriptionLimit={descriptionLimit}
|
||||
withPreview
|
||||
/>
|
||||
|
||||
{/* 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;
|
||||
|
|
|
@ -170,13 +170,13 @@ const EventHeader: React.FC<IEventHeader> = ({ status }) => {
|
|||
secondary: intl.formatMessage(messages.blockAndReport),
|
||||
onSecondary: () => {
|
||||
dispatch(blockAccount(account.id));
|
||||
dispatch(initReport(account, status));
|
||||
dispatch(initReport(account, { status }));
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleReport = () => {
|
||||
dispatch(initReport(account, status));
|
||||
dispatch(initReport(account, { status }));
|
||||
};
|
||||
|
||||
const handleModerate = () => {
|
||||
|
|
|
@ -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;
|
|
@ -2,24 +2,18 @@ import React from 'react';
|
|||
|
||||
import { HStack, Stack } from 'soapbox/components/ui';
|
||||
|
||||
import { randomIntFromInterval, generateText } from '../utils';
|
||||
|
||||
import PlaceholderAvatar from './placeholder-avatar';
|
||||
import PlaceholderDisplayName from './placeholder-display-name';
|
||||
|
||||
/** Fake chat to display while data is loading. */
|
||||
const PlaceholderChat: React.FC = () => {
|
||||
const messageLength = randomIntFromInterval(5, 75);
|
||||
|
||||
const PlaceholderChat = () => {
|
||||
return (
|
||||
<div className='account chat-list-item--placeholder'>
|
||||
<HStack space={3}>
|
||||
<PlaceholderAvatar size={36} />
|
||||
<Stack className='overflow-hidden'>
|
||||
<PlaceholderDisplayName minLength={3} maxLength={25} withSuffix={false} />
|
||||
<span className='overflow-hidden text-ellipsis whitespace-nowrap text-primary-50 dark:text-primary-800'>
|
||||
{generateText(messageLength)}
|
||||
</span>
|
||||
<div className='px-4 py-2 w-full flex flex-col animate-pulse'>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<PlaceholderAvatar size={40} />
|
||||
|
||||
<Stack alignItems='start'>
|
||||
<PlaceholderDisplayName minLength={3} maxLength={15} />
|
||||
</Stack>
|
||||
</HStack>
|
||||
</div>
|
||||
|
|
|
@ -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;
|
|
@ -10,6 +10,8 @@ import { useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
|||
|
||||
import Preferences from '../preferences';
|
||||
|
||||
import MessagesSettings from './components/messages-settings';
|
||||
|
||||
const messages = defineMessages({
|
||||
settings: { id: 'settings.settings', defaultMessage: 'Settings' },
|
||||
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>
|
||||
<CardTitle title={intl.formatMessage(messages.preferences)} />
|
||||
</CardHeader>
|
||||
|
|
|
@ -34,6 +34,7 @@ import {
|
|||
JoinEventModal,
|
||||
AccountModerationModal,
|
||||
EventParticipantsModal,
|
||||
PolicyModal,
|
||||
} from 'soapbox/features/ui/util/async-components';
|
||||
|
||||
import BundleContainer from '../containers/bundle-container';
|
||||
|
@ -75,6 +76,7 @@ const MODAL_COMPONENTS = {
|
|||
'JOIN_EVENT': JoinEventModal,
|
||||
'ACCOUNT_MODERATION': AccountModerationModal,
|
||||
'EVENT_PARTICIPANTS': EventParticipantsModal,
|
||||
'POLICY': PolicyModal,
|
||||
};
|
||||
|
||||
export type ModalType = keyof typeof MODAL_COMPONENTS | null;
|
||||
|
|
|
@ -4,6 +4,8 @@ import { FormattedMessage } from 'react-intl';
|
|||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { Modal, Stack, Text, Toggle } from 'soapbox/components/ui';
|
||||
|
||||
import type { ButtonThemes } from 'soapbox/components/ui/button/useButtonStyles';
|
||||
|
||||
interface IConfirmationModal {
|
||||
heading: React.ReactNode,
|
||||
message: React.ReactNode,
|
||||
|
@ -14,6 +16,7 @@ interface IConfirmationModal {
|
|||
onSecondary?: () => void,
|
||||
onCancel: () => void,
|
||||
checkbox?: JSX.Element,
|
||||
confirmationTheme?: ButtonThemes
|
||||
}
|
||||
|
||||
const ConfirmationModal: React.FC<IConfirmationModal> = ({
|
||||
|
@ -26,6 +29,7 @@ const ConfirmationModal: React.FC<IConfirmationModal> = ({
|
|||
onSecondary,
|
||||
onCancel,
|
||||
checkbox,
|
||||
confirmationTheme = 'danger',
|
||||
}) => {
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
|
@ -54,7 +58,7 @@ const ConfirmationModal: React.FC<IConfirmationModal> = ({
|
|||
confirmationAction={handleClick}
|
||||
confirmationText={confirm}
|
||||
confirmationDisabled={checkbox && !checked}
|
||||
confirmationTheme='danger'
|
||||
confirmationTheme={confirmationTheme}
|
||||
cancelText={<FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />}
|
||||
cancelAction={handleCancel}
|
||||
secondaryText={secondary}
|
||||
|
|
|
@ -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='You’ve scored the latest version of {siteTitle}! Take a moment to review the exciting new things we’ve 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 };
|
|
@ -1,12 +1,14 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
|
||||
import { blockAccount } from 'soapbox/actions/accounts';
|
||||
import { submitReport, submitReportSuccess, submitReportFail } from 'soapbox/actions/reports';
|
||||
import { expandAccountTimeline } from 'soapbox/actions/timelines';
|
||||
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
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 { useAccount, useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
|
@ -21,6 +23,8 @@ const messages = defineMessages({
|
|||
done: { id: 'report.done', defaultMessage: 'Done' },
|
||||
next: { id: 'report.next', defaultMessage: 'Next' },
|
||||
submit: { id: 'report.submit', defaultMessage: 'Submit' },
|
||||
reportContext: { id: 'report.chatMessage.context', defaultMessage: 'When reporting a user’s 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' },
|
||||
previous: { id: 'report.previous', defaultMessage: 'Previous' },
|
||||
});
|
||||
|
@ -73,6 +77,12 @@ interface IReportModal {
|
|||
onClose: () => void
|
||||
}
|
||||
|
||||
enum ReportedEntities {
|
||||
Account = 'Account',
|
||||
Status = 'Status',
|
||||
ChatMessage = 'ChatMessage'
|
||||
}
|
||||
|
||||
const ReportModal = ({ onClose }: IReportModal) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
@ -85,10 +95,23 @@ const ReportModal = ({ onClose }: IReportModal) => {
|
|||
const rules = useAppSelector((state) => state.rules.items);
|
||||
const ruleIds = useAppSelector((state) => state.reports.new.rule_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 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 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(() => {
|
||||
if (currentStep === Steps.THREE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isSubmitting || (shouldRequireRule && ruleIds.isEmpty()) || (!isReportingAccount && selectedStatusIds.size === 0);
|
||||
}, [currentStep, isSubmitting, shouldRequireRule, ruleIds, selectedStatusIds.size, isReportingAccount]);
|
||||
return isSubmitting || (shouldRequireRule && ruleIds.isEmpty()) || (isReportingStatus && selectedStatusIds.size === 0);
|
||||
}, [currentStep, isSubmitting, shouldRequireRule, ruleIds, selectedStatusIds.size, isReportingStatus]);
|
||||
|
||||
const calculateProgress = useCallback(() => {
|
||||
switch (currentStep) {
|
||||
|
@ -199,7 +266,7 @@ const ReportModal = ({ onClose }: IReportModal) => {
|
|||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='report.target' defaultMessage='Reporting {target}' values={{ target: <strong>@{account.acct}</strong> }} />}
|
||||
title={renderTitle()}
|
||||
onClose={onClose}
|
||||
cancelText={cancelText}
|
||||
cancelAction={currentStep === Steps.THREE ? undefined : cancelAction}
|
||||
|
@ -211,7 +278,7 @@ const ReportModal = ({ onClose }: IReportModal) => {
|
|||
<Stack space={4}>
|
||||
<ProgressBar progress={calculateProgress()} />
|
||||
|
||||
{(currentStep !== Steps.THREE && !isReportingAccount) && renderSelectedStatuses()}
|
||||
{(currentStep !== Steps.THREE && !isReportingAccount) && renderSelectedEntity()}
|
||||
|
||||
<StepToRender account={account} />
|
||||
</Stack>
|
||||
|
|
|
@ -25,7 +25,7 @@ const renderTermsOfServiceLink = (href: string) => (
|
|||
<a
|
||||
href={href}
|
||||
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}
|
||||
</a>
|
||||
|
|
|
@ -110,7 +110,7 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
|||
{menu.map((menuItem, idx) => {
|
||||
if (menuItem.toggle) {
|
||||
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>
|
||||
|
||||
{menuItem.toggle}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
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 { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
|
||||
import { fetchAnnouncements } from 'soapbox/actions/announcements';
|
||||
import { fetchChats } from 'soapbox/actions/chats';
|
||||
import { uploadCompose, resetCompose } from 'soapbox/actions/compose';
|
||||
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
|
||||
import { uploadEventBanner } from 'soapbox/actions/events';
|
||||
|
@ -26,18 +24,22 @@ import Icon from 'soapbox/components/icon';
|
|||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
||||
import ThumbNavigation from 'soapbox/components/thumb-navigation';
|
||||
import { Layout } from 'soapbox/components/ui';
|
||||
import { useStatContext } from 'soapbox/contexts/stat-context';
|
||||
import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useInstance } from 'soapbox/hooks';
|
||||
import AdminPage from 'soapbox/pages/admin-page';
|
||||
import ChatsPage from 'soapbox/pages/chats-page';
|
||||
import DefaultPage from 'soapbox/pages/default-page';
|
||||
import EventPage from 'soapbox/pages/event-page';
|
||||
import HomePage from 'soapbox/pages/home-page';
|
||||
import ProfilePage from 'soapbox/pages/profile-page';
|
||||
import RemoteInstancePage from 'soapbox/pages/remote-instance-page';
|
||||
import StatusPage from 'soapbox/pages/status-page';
|
||||
import { usePendingPolicy } from 'soapbox/queries/policies';
|
||||
import { getAccessToken, getVapidKey } from 'soapbox/utils/auth';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
|
||||
import BackgroundShapes from './components/background-shapes';
|
||||
import { supportedPolicyIds } from './components/modals/policy-modal';
|
||||
import Navbar from './components/navbar';
|
||||
import BundleContainer from './containers/bundle-container';
|
||||
import {
|
||||
|
@ -63,15 +65,9 @@ import {
|
|||
Filters,
|
||||
PinnedStatuses,
|
||||
Search,
|
||||
// Groups,
|
||||
// GroupTimeline,
|
||||
ListTimeline,
|
||||
Lists,
|
||||
Bookmarks,
|
||||
// GroupMembers,
|
||||
// GroupRemovedAccounts,
|
||||
// GroupCreate,
|
||||
// GroupEdit,
|
||||
Settings,
|
||||
MediaDisplay,
|
||||
EditProfile,
|
||||
|
@ -85,8 +81,7 @@ import {
|
|||
// Backups,
|
||||
MfaForm,
|
||||
ChatIndex,
|
||||
ChatRoom,
|
||||
ChatPanes,
|
||||
ChatWidget,
|
||||
ServerInfo,
|
||||
Dashboard,
|
||||
ModerationLog,
|
||||
|
@ -125,8 +120,6 @@ import 'soapbox/components/status';
|
|||
|
||||
const EmptyPage = HomePage;
|
||||
|
||||
const isMobile = (width: number): boolean => width <= 1190;
|
||||
|
||||
const messages = defineMessages({
|
||||
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave.' },
|
||||
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.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/:chatId' page={DefaultPage} component={ChatRoom} content={children} />}
|
||||
{features.chats && <WrappedRoute path='/chats' exact page={ChatsPage} component={ChatIndex} 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='/blocks' page={DefaultPage} component={Blocks} content={children} />
|
||||
|
@ -326,10 +321,11 @@ const UI: React.FC = ({ children }) => {
|
|||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const { data: pendingPolicy } = usePendingPolicy();
|
||||
const instance = useInstance();
|
||||
const statContext = useStatContext();
|
||||
|
||||
const [draggingOver, setDraggingOver] = useState<boolean>(false);
|
||||
const [mobile, setMobile] = useState<boolean>(isMobile(window.innerWidth));
|
||||
|
||||
const dragTargets = useRef<EventTarget[]>([]);
|
||||
const disconnect = useRef<any>(null);
|
||||
|
@ -425,7 +421,7 @@ const UI: React.FC = ({ children }) => {
|
|||
|
||||
const connectStreaming = () => {
|
||||
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 */
|
||||
const loadAccountData = () => {
|
||||
if (!account) return;
|
||||
|
@ -457,10 +447,6 @@ const UI: React.FC = ({ children }) => {
|
|||
|
||||
dispatch(fetchAnnouncements());
|
||||
|
||||
if (features.chats) {
|
||||
dispatch(fetchChats());
|
||||
}
|
||||
|
||||
if (account.staff) {
|
||||
dispatch(fetchReports({ resolved: false }));
|
||||
dispatch(fetchUsers(['local', 'need_approval']));
|
||||
|
@ -480,7 +466,6 @@ const UI: React.FC = ({ children }) => {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
document.addEventListener('dragenter', handleDragEnter, false);
|
||||
document.addEventListener('dragover', handleDragOver, false);
|
||||
document.addEventListener('drop', handleDrop, false);
|
||||
|
@ -495,7 +480,6 @@ const UI: React.FC = ({ children }) => {
|
|||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.removeEventListener('dragenter', handleDragEnter);
|
||||
document.removeEventListener('dragover', handleDragOver);
|
||||
document.removeEventListener('drop', handleDrop);
|
||||
|
@ -518,6 +502,14 @@ const UI: React.FC = ({ children }) => {
|
|||
dispatch(registerPushNotifications());
|
||||
}, [vapidKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (account && pendingPolicy && supportedPolicyIds.includes(pendingPolicy.pending_policy_id)) {
|
||||
setTimeout(() => {
|
||||
dispatch(openModal('POLICY'));
|
||||
}, 500);
|
||||
}
|
||||
}, [pendingPolicy, !!account]);
|
||||
|
||||
const handleHotkeyNew = (e?: KeyboardEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!node.current) return;
|
||||
|
@ -679,9 +671,14 @@ const UI: React.FC = ({ children }) => {
|
|||
{Component => <Component />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
{me && features.chats && !mobile && (
|
||||
<BundleContainer fetchComponent={ChatPanes}>
|
||||
{Component => <Component />}
|
||||
|
||||
{me && features.chats && (
|
||||
<BundleContainer fetchComponent={ChatWidget}>
|
||||
{Component => (
|
||||
<div className='hidden xl:block'>
|
||||
<Component />
|
||||
</div>
|
||||
)}
|
||||
</BundleContainer>
|
||||
)}
|
||||
<ThumbNavigation />
|
||||
|
|
|
@ -110,6 +110,10 @@ export function AccountModerationModal() {
|
|||
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() {
|
||||
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media-gallery');
|
||||
}
|
||||
|
@ -290,12 +294,8 @@ export function ChatIndex() {
|
|||
return import(/* webpackChunkName: "features/chats" */'../../chats');
|
||||
}
|
||||
|
||||
export function ChatRoom() {
|
||||
return import(/* webpackChunkName: "features/chats/chat_room" */'../../chats/chat-room');
|
||||
}
|
||||
|
||||
export function ChatPanes() {
|
||||
return import(/* webpackChunkName: "features/chats/components/chat_panes" */'../../chats/components/chat-panes');
|
||||
export function ChatWidget() {
|
||||
return import(/* webpackChunkName: "features/chats/components/chat-widget" */'../../chats/components/chat-widget/chat-widget');
|
||||
}
|
||||
|
||||
export function ServerInfo() {
|
||||
|
|
|
@ -3,6 +3,7 @@ export { useApi } from './useApi';
|
|||
export { useAppDispatch } from './useAppDispatch';
|
||||
export { useAppSelector } from './useAppSelector';
|
||||
export { useCompose } from './useCompose';
|
||||
export { useDebounce } from './useDebounce';
|
||||
export { useDimensions } from './useDimensions';
|
||||
export { useFeatures } from './useFeatures';
|
||||
export { useInstance } from './useInstance';
|
||||
|
|
|
@ -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 };
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
|
@ -1,5 +1,5 @@
|
|||
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 { renderHook, RenderHookOptions } from '@testing-library/react-hooks';
|
||||
import { merge } from 'immutable';
|
||||
|
@ -11,6 +11,10 @@ import { Action, applyMiddleware, createStore } from 'redux';
|
|||
import thunk from 'redux-thunk';
|
||||
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 { 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);
|
||||
};
|
||||
|
||||
/** 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 TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
|
||||
let store: ReturnType<typeof createTestStore>;
|
||||
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);
|
||||
store = createTestStore(appState);
|
||||
} else {
|
||||
|
@ -63,15 +52,19 @@ const TestApp: FC<any> = ({ children, storeProps, routerProps = {} }) => {
|
|||
|
||||
return (
|
||||
<Provider store={props.store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IntlProvider locale={props.locale}>
|
||||
<MemoryRouter {...routerProps}>
|
||||
<StatProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChatProvider>
|
||||
<IntlProvider locale={props.locale}>
|
||||
{children}
|
||||
|
||||
<NotificationsContainer />
|
||||
</MemoryRouter>
|
||||
</IntlProvider>
|
||||
</ChatProvider>
|
||||
</QueryClientProvider>
|
||||
</StatProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,6 +6,9 @@ import { __clear as clearApiMocks } from '../api/__mocks__';
|
|||
jest.mock('soapbox/api');
|
||||
afterEach(() => clearApiMocks());
|
||||
|
||||
// Query mocking
|
||||
jest.mock('soapbox/queries/client');
|
||||
|
||||
// Mock IndexedDB
|
||||
// https://dev.to/andyhaskell/testing-your-indexeddb-code-with-jest-2o17
|
||||
require('fake-indexeddb/auto');
|
||||
|
|
|
@ -1,65 +1,22 @@
|
|||
'use strict';
|
||||
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { play, soundCache } from 'soapbox/utils/sounds';
|
||||
|
||||
import type { ThunkMiddleware } from 'redux-thunk';
|
||||
import type { Sounds } from 'soapbox/utils/sounds';
|
||||
|
||||
/** Soapbox audio clip. */
|
||||
type Sound = {
|
||||
src: string,
|
||||
type: string,
|
||||
|
||||
interface Action extends AnyAction {
|
||||
meta: {
|
||||
sound: Sounds
|
||||
}
|
||||
}
|
||||
|
||||
/** Produce HTML5 audio from sound data. */
|
||||
const createAudio = (sources: Sound[]): HTMLAudioElement => {
|
||||
const audio = new Audio();
|
||||
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. */
|
||||
export default function soundsMiddleware(): ThunkMiddleware {
|
||||
const soundCache: Record<string, HTMLAudioElement> = {
|
||||
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 => {
|
||||
return () => next => (action: Action) => {
|
||||
if (action.meta?.sound && soundCache[action.meta.sound]) {
|
||||
play(soundCache[action.meta.sound]);
|
||||
}
|
||||
|
|
|
@ -9,6 +9,9 @@ describe('normalizeInstance()', () => {
|
|||
contact_account: {},
|
||||
configuration: {
|
||||
media_attachments: {},
|
||||
chats: {
|
||||
max_characters: 500,
|
||||
},
|
||||
polls: {
|
||||
max_options: 4,
|
||||
max_characters_per_option: 25,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue