- {chatMessages.reduce((acc, curr, idx) => {
- const lastMessage = chatMessages.get(idx - 1);
+
+
+
{
+ if (chatMessage.type === 'divider') {
+ return renderDivider(index, chatMessage.text);
+ } else {
+ return (
+
+ {renderMessage(chatMessage)}
+
+ );
+ }
+ }}
+ components={{
+ List,
+ Header: () => {
+ if (hasNextPage || isFetchingNextPage) {
+ return ;
+ }
- 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;
- }
- }
+ if (!hasNextPage && !isLoading) {
+ return ;
+ }
- acc.push(renderMessage(curr));
- return acc;
- }, [] as React.ReactNode[])}
-
+ return null;
+ },
+ }}
+ />
+
);
};
diff --git a/app/soapbox/features/chats/components/chat-page/__tests__/chat-page.test.tsx b/app/soapbox/features/chats/components/chat-page/__tests__/chat-page.test.tsx
new file mode 100644
index 000000000..ddac6c8bc
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-page/__tests__/chat-page.test.tsx
@@ -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('
', () => {
+ let store: any;
+
+ describe('before you finish onboarding', () => {
+ it('renders the Welcome component', () => {
+ render(
);
+
+ 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(
, 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(
, undefined, store);
+ await userEvent.click(screen.getByTestId('button'));
+ expect(screen.getByTestId('toast')).toHaveTextContent('Chat Settings failed to update.');
+ });
+ });
+ });
+});
diff --git a/app/soapbox/features/chats/components/chat-page/chat-page.tsx b/app/soapbox/features/chats/components/chat-page/chat-page.tsx
new file mode 100644
index 000000000..407d49e7b
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-page/chat-page.tsx
@@ -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
= ({ 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(null);
+ const [height, setHeight] = useState('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 (
+
+ {isOnboarded ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default ChatPage;
diff --git a/app/soapbox/features/chats/components/chat-page/components/blankslate-empty.tsx b/app/soapbox/features/chats/components/chat-page/components/blankslate-empty.tsx
new file mode 100644
index 000000000..f2d8a38f5
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-page/components/blankslate-empty.tsx
@@ -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 = () => {
+ const history = useHistory();
+
+ const handleNewChat = () => {
+ history.push('/chats/new');
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default BlankslateEmpty;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-page/components/blankslate-with-chats.tsx b/app/soapbox/features/chats/components/chat-page/components/blankslate-with-chats.tsx
new file mode 100644
index 000000000..4285df0b9
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-page/components/blankslate-with-chats.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default BlankslateWithChats;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-page/components/chat-page-main.tsx b/app/soapbox/features/chats/components/chat-page/components/chat-page-main.tsx
new file mode 100644
index 000000000..638a7fea1
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-page/components/chat-page-main.tsx
@@ -0,0 +1,250 @@
+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(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 ;
+ }
+
+ if (!currentChatId) {
+ return ;
+ }
+
+ if (!chat) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ history.push('/chats')}
+ />
+
+
+
+
+
+
+
+
+
+
+ {chat.account.display_name || `@${chat.account.username}`}
+
+
+ {chat.account.verified && }
+
+
+ {chat.message_expiration && (
+
+
+ {intl.formatMessage(messages.autoDeleteMessage, { day: secondsToDays(chat.message_expiration) })}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ChatPageMain;
diff --git a/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx b/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx
new file mode 100644
index 000000000..c362a4159
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { useHistory } from 'react-router-dom';
+
+import { CardTitle, HStack, IconButton, Stack } from 'soapbox/components/ui';
+
+import ChatSearch from '../../chat-search/chat-search';
+
+interface IChatPageNew {
+}
+
+/** New message form to create a chat. */
+const ChatPageNew: React.FC = () => {
+ const history = useHistory();
+
+ return (
+
+
+
+ history.push('/chats')}
+ />
+
+
+
+
+
+
+
+ );
+};
+
+export default ChatPageNew;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-page/components/chat-page-settings.tsx b/app/soapbox/features/chats/components/chat-page/components/chat-page-settings.tsx
new file mode 100644
index 000000000..7a9a813fe
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-page/components/chat-page-settings.tsx
@@ -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({
+ 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 (
+
+
+ history.push('/chats')}
+ />
+
+
+
+
+
+
+ );
+};
+
+export default ChatPageSettings;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-page/components/chat-page-sidebar.tsx b/app/soapbox/features/chats/components/chat-page/components/chat-page-sidebar.tsx
new file mode 100644
index 000000000..6f19948de
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-page/components/chat-page-sidebar.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ {features.chatsSearch && (
+ setSearch(e.target.value)}
+ onClear={() => setSearch('')}
+ />
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default ChatPageSidebar;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-page/components/welcome.tsx b/app/soapbox/features/chats/components/chat-page/components/welcome.tsx
new file mode 100644
index 000000000..a937e8eac
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-page/components/welcome.tsx
@@ -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 direct 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({
+ chats_onboarded: true,
+ accepts_chat_messages: account?.accepts_chat_messages,
+ });
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+
+ updateCredentials.mutate(data);
+ };
+
+ return (
+
+
+
+ {intl.formatMessage(messages.title, { br:
})}
+
+
+
+ {intl.formatMessage(messages.subtitle)}
+
+
+
+
+
+ );
+};
+
+export default Welcome;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-pane/__tests__/chat-pane.test.tsx b/app/soapbox/features/chats/components/chat-pane/__tests__/chat-pane.test.tsx
new file mode 100644
index 000000000..5dcfda549
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-pane/__tests__/chat-pane.test.tsx
@@ -0,0 +1,65 @@
+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 { mockStore, render, rootState, screen, waitFor } from 'soapbox/jest/test-helpers';
+
+import ChatPane from '../chat-pane';
+
+const renderComponentWithChatContext = (store = {}) => render(
+
+
+
+
+
+
+ ,
+ undefined,
+ store,
+);
+
+describe('', () => {
+ describe('when there are no chats', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
+ store = mockStore(state);
+
+ __stub((mock) => {
+ mock.onGet('/api/v1/pleroma/chats').reply(200, [], {
+ link: null,
+ });
+ });
+ });
+
+ it('renders the blankslate', async () => {
+ renderComponentWithChatContext(store);
+
+ 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: '; rel=\'prev\'',
+ });
+ });
+ });
+
+ it('does not render the search input', async () => {
+ renderComponentWithChatContext();
+
+ await waitFor(() => {
+ expect(screen.queryAllByTestId('chat-search-input')).toHaveLength(0);
+ });
+ });
+ });
+});
diff --git a/app/soapbox/features/chats/components/chat-pane/blankslate.tsx b/app/soapbox/features/chats/components/chat-pane/blankslate.tsx
new file mode 100644
index 000000000..d817a8f48
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-pane/blankslate.tsx
@@ -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: 'Search for someone to chat with.' },
+ action: { id: 'chat_search.empty_results_blankslate.action', defaultMessage: 'Message someone' },
+});
+
+interface IBlankslate {
+ onSearch(): void
+}
+
+const Blankslate = ({ onSearch }: IBlankslate) => {
+ const intl = useIntl();
+
+ return (
+
+
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ {intl.formatMessage(messages.body)}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Blankslate;
diff --git a/app/soapbox/features/chats/components/chat-pane/chat-pane.tsx b/app/soapbox/features/chats/components/chat-pane/chat-pane.tsx
new file mode 100644
index 000000000..59953b54c
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-pane/chat-pane.tsx
@@ -0,0 +1,122 @@
+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 ChatSearchHeader from '../chat-widget/headers/chat-search-header';
+import { Pane } from '../ui';
+
+import Blankslate from './blankslate';
+
+const ChatPane = () => {
+ const features = useFeatures();
+ const debounce = useDebounce;
+ const { unreadChatsCount } = useStatContext();
+
+ const [value, setValue] = useState();
+ 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 (
+
+ {features.chatsSearch && (
+
+ setValue(event.target.value)}
+ onClear={clearValue}
+ />
+
+ )}
+
+ {(Number(chats?.length) > 0 || isLoading) ? (
+
+ ) : (
+
+ )}
+
+ );
+ } else if (chats?.length === 0) {
+ return (
+ {
+ changeScreen(ChatWidgetScreens.SEARCH);
+ }}
+ />
+ );
+ }
+ };
+
+ // Active chat
+ if (screen === ChatWidgetScreens.CHAT || screen === ChatWidgetScreens.CHAT_SETTINGS) {
+ return (
+
+
+
+ );
+ }
+
+ if (screen === ChatWidgetScreens.SEARCH) {
+ return (
+
+
+
+ {isOpen ? : null}
+
+ );
+ }
+
+ return (
+
+ }
+ unreadCount={unreadChatsCount}
+ isOpen={isOpen}
+ onToggle={toggleChatPane}
+ secondaryAction={() => {
+ changeScreen(ChatWidgetScreens.SEARCH);
+ setValue(undefined);
+
+ if (!isOpen) {
+ toggleChatPane();
+ }
+ }}
+ secondaryActionIcon={require('@tabler/icons/edit.svg')}
+ />
+
+ {isOpen ? renderBody() : null}
+
+ );
+};
+
+export default ChatPane;
diff --git a/app/soapbox/features/chats/components/chat-panes.tsx b/app/soapbox/features/chats/components/chat-panes.tsx
deleted file mode 100644
index 0aab9734f..000000000
--- a/app/soapbox/features/chats/components/chat-panes.tsx
+++ /dev/null
@@ -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, panes = ImmutableList>()) => (
- 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 = (
-
-
- {unreadCount > 0 && (
-
-
-
- )}
-
-
-
-
- {open && (
- <>
-
-
- >
- )}
-
-
- );
-
- return (
-
- {mainWindowPane}
- {panes.map((pane, i) => (
-
- ))}
-
- );
-};
-
-export default ChatPanes;
diff --git a/app/soapbox/features/chats/components/chat-search-input.tsx b/app/soapbox/features/chats/components/chat-search-input.tsx
new file mode 100644
index 000000000..bc8644aab
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-search-input.tsx
@@ -0,0 +1,47 @@
+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,
+ /** Callback when the input is cleared. */
+ onClear: React.MouseEventHandler,
+}
+
+/** Search input for filtering chats. */
+const ChatSearchInput: React.FC = ({ value, onChange, onClear }) => {
+ const intl = useIntl();
+
+ return (
+
+
+
+ }
+ />
+ );
+};
+
+export default ChatSearchInput;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-search/__tests__/chat-search.test.tsx b/app/soapbox/features/chats/components/chat-search/__tests__/chat-search.test.tsx
new file mode 100644
index 000000000..e7ea8d739
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-search/__tests__/chat-search.test.tsx
@@ -0,0 +1,69 @@
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { VirtuosoMockContext } from 'react-virtuoso';
+
+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(
+
+
+
+ ,
+ ,
+);
+
+describe('', () => {
+ beforeEach(async() => {
+ renderComponent();
+ });
+
+ it('renders the search input', () => {
+ expect(screen.getByTestId('search')).toBeInTheDocument();
+ });
+
+ describe('when searching', () => {
+ describe('with results', () => {
+ 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() => {
+ const user = userEvent.setup();
+ await user.type(screen.getByTestId('search'), 'ste');
+
+ await waitFor(() => {
+ expect(screen.queryAllByTestId('account')).toHaveLength(1);
+ });
+ });
+ });
+
+ describe('without results', () => {
+ beforeEach(() => {
+ __stub((mock) => {
+ mock.onGet('/api/v1/accounts/search').reply(200, []);
+ });
+ });
+
+ it('renders accounts', async() => {
+ const user = userEvent.setup();
+ await user.type(screen.getByTestId('search'), 'ste');
+
+ await waitFor(() => {
+ expect(screen.getByTestId('no-results')).toBeInTheDocument();
+ });
+ });
+ });
+ });
+});
diff --git a/app/soapbox/features/chats/components/chat-search/blankslate.tsx b/app/soapbox/features/chats/components/chat-search/blankslate.tsx
new file mode 100644
index 000000000..ed2583e9f
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-search/blankslate.tsx
@@ -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: 'Start a chat' },
+ body: { id: 'chat_search.blankslate.body', defaultMessage: 'Search for someone to chat with.' },
+});
+
+const Blankslate = () => {
+ const intl = useIntl();
+
+ return (
+
+
+ {intl.formatMessage(messages.title)}
+
+
+ {intl.formatMessage(messages.body)}
+
+
+ );
+};
+
+export default Blankslate;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-search/chat-search.tsx b/app/soapbox/features/chats/components/chat-search/chat-search.tsx
new file mode 100644
index 000000000..c6b9536a5
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-search/chat-search.tsx
@@ -0,0 +1,115 @@
+import { useMutation } from '@tanstack/react-query';
+import { AxiosError } from 'axios';
+import React, { useState } from 'react';
+import { useHistory } from 'react-router-dom';
+
+import { Icon, Input, Stack } from 'soapbox/components/ui';
+import { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context';
+import { useDebounce } from 'soapbox/hooks';
+import { useChats } from 'soapbox/queries/chats';
+import { queryClient } from 'soapbox/queries/client';
+import useAccountSearch from 'soapbox/queries/search';
+import toast from 'soapbox/toast';
+
+import { ChatKeys } from '../../../../queries/chats';
+
+import Blankslate from './blankslate';
+import EmptyResultsBlankslate from './empty-results-blankslate';
+import Results from './results';
+
+interface IChatSearch {
+ isMainPage?: boolean
+}
+
+const ChatSearch = (props: IChatSearch) => {
+ const { isMainPage = false } = props;
+
+ const debounce = useDebounce;
+ const history = useHistory();
+
+ const { changeScreen } = useChatContext();
+ const { getOrCreateChatByAccountId } = useChats();
+
+ const [value, setValue] = useState('');
+ const debouncedValue = debounce(value as string, 300);
+
+ const accountSearchResult = useAccountSearch(debouncedValue);
+ const { data: accounts, isFetching } = accountSearchResult;
+
+ 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;
+ toast.error(data?.error);
+ },
+ onSuccess: (response) => {
+ if (isMainPage) {
+ history.push(`/chats/${response.data.id}`);
+ } else {
+ changeScreen(ChatWidgetScreens.CHAT, response.data.id);
+ }
+
+ queryClient.invalidateQueries(ChatKeys.chatSearch());
+ },
+ });
+
+ const renderBody = () => {
+ if (hasSearchResults) {
+ return (
+ {
+ handleClickOnSearchResult.mutate(id);
+ clearValue();
+ }}
+ />
+ );
+ } else if (hasSearchValue && !hasSearchResults && !isFetching) {
+ return ;
+ } else {
+ return ;
+ }
+ };
+
+ const clearValue = () => {
+ if (hasSearchValue) {
+ setValue('');
+ }
+ };
+
+ return (
+
+
+ setValue(event.target.value)}
+ outerClassName='mt-0'
+ theme='search'
+ append={
+
+ }
+ />
+
+
+
+ {renderBody()}
+
+
+ );
+};
+
+export default ChatSearch;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-search/empty-results-blankslate.tsx b/app/soapbox/features/chats/components/chat-search/empty-results-blankslate.tsx
new file mode 100644
index 000000000..3e360347e
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-search/empty-results-blankslate.tsx
@@ -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 (
+
+
+ {intl.formatMessage(messages.title)}
+
+
+
+ {intl.formatMessage(messages.body)}
+
+
+ );
+};
+
+export default EmptyResultsBlankslate;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-search/results.tsx b/app/soapbox/features/chats/components/chat-search/results.tsx
new file mode 100644
index 000000000..48909f67e
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-search/results.tsx
@@ -0,0 +1,80 @@
+import classNames from 'clsx';
+import React, { useCallback, useState } from 'react';
+import { Virtuoso } from 'react-virtuoso';
+
+import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
+import VerificationBadge from 'soapbox/components/verification-badge';
+import useAccountSearch from 'soapbox/queries/search';
+
+interface IResults {
+ accountSearchResult: ReturnType
+ onSelect(id: string): void
+}
+
+const Results = ({ accountSearchResult, onSelect }: IResults) => {
+ const { data: accounts, isFetching, hasNextPage, fetchNextPage } = accountSearchResult;
+
+ const [isNearBottom, setNearBottom] = useState(false);
+ const [isNearTop, setNearTop] = useState(true);
+
+ const handleLoadMore = () => {
+ if (hasNextPage && !isFetching) {
+ fetchNextPage();
+ }
+ };
+
+ const renderAccount = useCallback((_index, account) => (
+
+ ), []);
+
+ return (
+
+
(
+
+ {renderAccount(index, chat)}
+
+ )}
+ endReached={handleLoadMore}
+ atTopStateChange={(atTop) => setNearTop(atTop)}
+ atBottomStateChange={(atBottom) => setNearBottom(atBottom)}
+ />
+
+ <>
+
+
+ >
+
+ );
+};
+
+export default Results;
diff --git a/app/soapbox/features/chats/components/chat-widget/chat-pane-header.tsx b/app/soapbox/features/chats/components/chat-widget/chat-pane-header.tsx
new file mode 100644
index 000000000..20967d171
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-widget/chat-pane-header.tsx
@@ -0,0 +1,74 @@
+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 = {};
+ if (isToggleable) {
+ buttonProps.onClick = onToggle;
+ }
+
+ return (
+
+
+
+ {title}
+
+
+ {(typeof unreadCount !== 'undefined' && unreadCount > 0) && (
+
+
+ ({unreadCount})
+
+
+
+
+ )}
+
+
+
+ {secondaryAction ? (
+
+ ) : null}
+
+
+
+
+ );
+};
+
+export default ChatPaneHeader;
diff --git a/app/soapbox/features/chats/components/chat-widget/chat-settings.tsx b/app/soapbox/features/chats/components/chat-widget/chat-settings.tsx
new file mode 100644
index 000000000..b00c4dac8
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-widget/chat-settings.tsx
@@ -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 (
+ <>
+
+
+
+
+ {intl.formatMessage(messages.title)}
+
+
+ }
+ />
+
+
+
+
+
+ {chat.account.display_name}
+ @{chat.account.acct}
+
+
+
+ {features.chatsExpiration && (
+
+
+
+
+
+ )}
+
+
+
+
+ {features.chatsDelete && (
+
+ )}
+
+
+ >
+ );
+};
+
+export default ChatSettings;
diff --git a/app/soapbox/features/chats/components/chat-widget/chat-widget.tsx b/app/soapbox/features/chats/components/chat-widget/chat-widget.tsx
new file mode 100644
index 000000000..a155abbaa
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-widget/chat-widget.tsx
@@ -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 (
+
+
+
+ );
+};
+
+export default ChatWidget;
diff --git a/app/soapbox/features/chats/components/chat-widget/chat-window.tsx b/app/soapbox/features/chats/components/chat-widget/chat-window.tsx
new file mode 100644
index 000000000..e28c4af18
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-widget/chat-window.tsx
@@ -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 (
+
+ {children}
+
+ );
+};
+
+/** Floating desktop chat window. */
+const ChatWindow = () => {
+ const intl = useIntl();
+
+ const { chat, currentChatId, screen, changeScreen, isOpen, needsAcceptance, toggleChatPane } = useChatContext();
+
+ const inputRef = useRef(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 ;
+ }
+
+ return (
+ <>
+
+ {isOpen && (
+
+ )}
+
+
+ {isOpen && (
+
+
+
+ )}
+
+
+
+
+ {chat.account.display_name || `@${chat.account.acct}`}
+ {chat.account.verified && }
+
+
+
+ {chat.message_expiration && (
+
+
+ {intl.formatMessage(messages.autoDeleteMessage, { day: secondsToDays(chat.message_expiration) })}
+
+
+ )}
+
+
+
+ }
+ secondaryAction={secondaryAction()}
+ secondaryActionIcon={isOpen ? require('@tabler/icons/info-circle.svg') : require('@tabler/icons/edit.svg')}
+ isToggleable={!isOpen}
+ isOpen={isOpen}
+ onToggle={toggleChatPane}
+ />
+
+
+
+
+ >
+ );
+};
+
+export default ChatWindow;
diff --git a/app/soapbox/features/chats/components/chat-widget/headers/chat-search-header.tsx b/app/soapbox/features/chats/components/chat-widget/headers/chat-search-header.tsx
new file mode 100644
index 000000000..d0fd40921
--- /dev/null
+++ b/app/soapbox/features/chats/components/chat-widget/headers/chat-search-header.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+
+import { HStack, Icon, Text } from 'soapbox/components/ui';
+import { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context';
+
+import ChatPaneHeader from '../chat-pane-header';
+
+const messages = defineMessages({
+ title: { id: 'chat_search.title', defaultMessage: 'Messages' },
+});
+
+const ChatSearchHeader = () => {
+ const intl = useIntl();
+
+ const { changeScreen, isOpen, toggleChatPane } = useChatContext();
+
+ return (
+
+
+
+
+ {intl.formatMessage(messages.title)}
+
+
+ }
+ isOpen={isOpen}
+ isToggleable={false}
+ onToggle={toggleChatPane}
+ />
+ );
+};
+
+export default ChatSearchHeader;
\ No newline at end of file
diff --git a/app/soapbox/features/chats/components/chat-window.tsx b/app/soapbox/features/chats/components/chat-window.tsx
deleted file mode 100644
index 9808dce3f..000000000
--- a/app/soapbox/features/chats/components/chat-window.tsx
+++ /dev/null
@@ -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 = ({ 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(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 = (
-
-
-
- );
-
- const avatar = (
-
-
-
-
-
- );
-
- return (
-
-
- {unreadCount > 0 ? unreadIcon : avatar }
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default ChatWindow;
diff --git a/app/soapbox/features/chats/components/chat.tsx b/app/soapbox/features/chats/components/chat.tsx
index 5b4635fab..73f735e86 100644
--- a/app/soapbox/features/chats/components/chat.tsx
+++ b/app/soapbox/features/chats/components/chat.tsx
@@ -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,
+ className?: string,
}
-const Chat: React.FC = ({ 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