diff --git a/app/soapbox/features/chats/components/chat-list.tsx b/app/soapbox/features/chats/components/chat-list.tsx index 825b64871..027bc00a8 100644 --- a/app/soapbox/features/chats/components/chat-list.tsx +++ b/app/soapbox/features/chats/components/chat-list.tsx @@ -1,44 +1,15 @@ -import { Map as ImmutableMap } from 'immutable'; -import React, { useCallback } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; +import React from 'react'; import { useDispatch } from 'react-redux'; 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 PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat'; -import { useAppSelector } from 'soapbox/hooks'; +import { Stack } from 'soapbox/components/ui'; +import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder-chat'; +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) => ( - 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 Blankslate from './chat-pane/blankslate'; interface IChatList { onClickChat: (chat: any) => void, @@ -47,44 +18,51 @@ interface IChatList { const ChatList: React.FC = ({ onClickChat, useWindowScroll = false }) => { const dispatch = useDispatch(); - const intl = useIntl(); - const chatIds = useAppSelector(state => sortedChatIdsSelector(state.chats.items)); - const hasMore = useAppSelector(state => !!state.chats.next); - const isLoading = useAppSelector(state => state.chats.isLoading); + const { chatsQuery: { data: chats, isFetching } } = useChats(); - const isEmpty = chatIds.size === 0; + const isEmpty = chats?.length === 0; + + // const handleLoadMore = useCallback(() => { + // if (hasMore && !isLoading) { + // dispatch(expandChats()); + // } + // }, [dispatch, hasMore, isLoading]); + + const handleLoadMore = () => console.log('load more'); - const handleLoadMore = useCallback(() => { - if (hasMore && !isLoading) { - dispatch(expandChats()); - } - }, [dispatch, hasMore, isLoading]); const handleRefresh = () => { return dispatch(fetchChats()) as any; }; - const renderEmpty = () => isLoading ? : ( - - {intl.formatMessage(messages.emptyMessage)} - - ); + const renderEmpty = () => { + if (isFetching) { + return ( + + + + + + ); + } else { + return ; + } + }; return ( {isEmpty ? renderEmpty() : ( ( - + itemContent={(_index, chat) => ( + )} components={{ ScrollSeekPlaceholder: () => , - Footer: () => hasMore ? : null, + // Footer: () => hasMore ? : null, EmptyPlaceholder: renderEmpty, }} /> diff --git a/app/soapbox/features/chats/components/chat-message-list.tsx b/app/soapbox/features/chats/components/chat-message-list.tsx index 4c112ba92..1b2fe5200 100644 --- a/app/soapbox/features/chats/components/chat-message-list.tsx +++ b/app/soapbox/features/chats/components/chat-message-list.tsx @@ -18,7 +18,7 @@ import { Avatar, Button, HStack, IconButton, Spinner, Stack, Text } from 'soapbo import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import { useChatContext } from 'soapbox/contexts/chat-context'; import emojify from 'soapbox/features/emoji/emoji'; -import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat'; +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, useOwnAccount } from 'soapbox/hooks'; @@ -412,11 +412,11 @@ const ChatMessageList: React.FC = ({ chat, chatMessageIds, aut
{isLoading ? ( <> - - - - - + + + + + ) : ( formattedChatMessages.reduce((acc: any, curr: any, idx: number) => { diff --git a/app/soapbox/features/chats/components/chat-pane.tsx b/app/soapbox/features/chats/components/chat-pane.tsx deleted file mode 100644 index 2279947d6..000000000 --- a/app/soapbox/features/chats/components/chat-pane.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; -import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; -import sumBy from 'lodash/sumBy'; -import React, { useEffect, useState } 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 snackbar from 'soapbox/actions/snackbar'; -import AccountSearch from 'soapbox/components/account_search'; -import { Avatar, Button, Counter, HStack, Icon, IconButton, Input, Spinner, Stack, Text } from 'soapbox/components/ui'; -import VerificationBadge from 'soapbox/components/verification_badge'; -import { ChatProvider, useChatContext } from 'soapbox/contexts/chat-context'; -import AudioToggle from 'soapbox/features/chats/components/audio-toggle'; -import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account'; -import { useAppDispatch, useAppSelector, useDebounce, useSettings } from 'soapbox/hooks'; -import { IChat, useChats } from 'soapbox/queries/chats'; -import useAccountSearch from 'soapbox/queries/search'; -import { RootState } from 'soapbox/store'; -import { Chat } from 'soapbox/types/entities'; - -import ChatList from './chat-list'; -import ChatPaneHeader from './chat-pane-header'; -import ChatWindow from './chat-window'; -import { Pane } from './ui'; - -const messages = defineMessages({ - searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Type a name' }, -}); - -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: ImmutableMap, 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 ChatPane = () => { - const intl = useIntl(); - const dispatch = useAppDispatch(); - const debounce = useDebounce; - - const { chat, setChat, isOpen, toggleChatPane } = useChatContext(); - - const [value, setValue] = useState(); - const debouncedValue = debounce(value as string, 300); - - const { chatsQuery: { data: chats, isFetching }, getOrCreateChatByAccountId } = useChats(); - const { data: accounts } = useAccountSearch(debouncedValue); - - const panes = useAppSelector((state) => normalizeChatPanes(state)); - const unreadCount = sumBy(chats, (chat) => chat.unread); - - const isSearching = accounts && accounts.length > 0; - const hasSearchValue = value && value.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) => { - setChat(response.data); - }, - }); - - - const clearValue = () => { - if (hasSearchValue) { - setValue(''); - } - }; - - const renderBody = () => { - if (isFetching) { - return ( -
- -
- ); - } else if (isSearching) { - return ( - - {accounts.map((account: any) => ( - - ))} - - ); - } else if (chats && chats.length > 0) { - return ( - - {chats.map((chat) => ( - - ))} - - ); - } else { - return ( - - - No messages yet - You can start a conversation with anyone that follows you. - - - - - ); - } - }; - - return ( -
- - {chat?.id ? ( - - ) : ( - <> - - - {isOpen ? ( - -
- setValue(event.target.value)} - isSearch - append={ - - } - /> -
- - {renderBody()} -
- ) : null} - - )} -
- - {/* {panes.map((pane, i) => ( - - ))} */} -
- ); -}; - -export default ChatPane; 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..17208c658 --- /dev/null +++ b/app/soapbox/features/chats/components/chat-pane/blankslate.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Button, Stack, Text } from 'soapbox/components/ui'; + +const Blankslate = () => ( + + + No messages yet + You can start a conversation with anyone that follows you. + + + {/* */} + +); + +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..dbe180c3f --- /dev/null +++ b/app/soapbox/features/chats/components/chat-pane/chat-pane.tsx @@ -0,0 +1,138 @@ +import { useMutation } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import sumBy from 'lodash/sumBy'; +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import snackbar from 'soapbox/actions/snackbar'; +import { Avatar, HStack, Icon, Input, Stack, Text } from 'soapbox/components/ui'; +import VerificationBadge from 'soapbox/components/verification_badge'; +import { useChatContext } from 'soapbox/contexts/chat-context'; +import { useAppDispatch, useDebounce } from 'soapbox/hooks'; +import { useChats } from 'soapbox/queries/chats'; +import useAccountSearch from 'soapbox/queries/search'; + +import ChatList from '../chat-list'; +import ChatPaneHeader from '../chat-pane-header'; +import ChatWindow from '../chat-window'; +import { Pane } from '../ui'; + +const messages = defineMessages({ + searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Type a name' }, +}); + +const ChatPane = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const debounce = useDebounce; + + const { chat, setChat, isOpen, toggleChatPane } = useChatContext(); + const { chatsQuery: { data: chats }, getOrCreateChatByAccountId } = useChats(); + + const [value, setValue] = useState(); + const debouncedValue = debounce(value as string, 300); + + const { data: accounts } = useAccountSearch(debouncedValue); + + const unreadCount = sumBy(chats, (chat) => chat.unread); + + const isSearching = accounts && accounts.length > 0; + const hasSearchValue = value && value.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) => { + setChat(response.data); + }, + }); + + + const clearValue = () => { + if (hasSearchValue) { + setValue(''); + } + }; + + const renderBody = () => { + if (isSearching) { + return ( + + {accounts.map((account: any) => ( + + ))} + + ); + } else { + return setChat(chat)} useWindowScroll={false} />; + } + }; + + // Active chat + if (chat?.id) { + return ( + + + + ); + } + + return ( + + + + {isOpen ? ( + +
+ setValue(event.target.value)} + isSearch + append={ + + } + /> +
+ + {renderBody()} +
+ ) : null} +
+ ); +}; + +export default ChatPane; diff --git a/app/soapbox/features/chats/components/chat-widget.tsx b/app/soapbox/features/chats/components/chat-widget.tsx index 3fa52641f..a956eeb20 100644 --- a/app/soapbox/features/chats/components/chat-widget.tsx +++ b/app/soapbox/features/chats/components/chat-widget.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { ChatProvider } from 'soapbox/contexts/chat-context'; -import ChatPane from './chat-pane'; +import ChatPane from './chat-pane/chat-pane'; const ChatWidget = () => { return ( diff --git a/app/soapbox/features/chats/components/chat.tsx b/app/soapbox/features/chats/components/chat.tsx index 13d2b7fee..8673c7ee8 100644 --- a/app/soapbox/features/chats/components/chat.tsx +++ b/app/soapbox/features/chats/components/chat.tsx @@ -1,72 +1,35 @@ import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import Avatar from 'soapbox/components/avatar'; -import DisplayName from 'soapbox/components/display-name'; -import Icon from 'soapbox/components/icon'; -import { Counter } from 'soapbox/components/ui'; -import emojify from 'soapbox/features/emoji/emoji'; -import { useAppSelector } from 'soapbox/hooks'; -import { makeGetChat } from 'soapbox/selectors'; +import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui'; +import VerificationBadge from 'soapbox/components/verification_badge'; -import type { Account as AccountEntity, Chat as ChatEntity } from 'soapbox/types/entities'; +import type { IChat } from 'soapbox/queries/chats'; -const getChat = makeGetChat(); - -interface IChat { - chatId: string, +interface IChatInterface { + chat: IChat, onClick: (chat: any) => void, } -const Chat: React.FC = ({ chatId, onClick }) => { - const chat = useAppSelector((state) => { - const chat = state.chats.items.get(chatId); - return chat ? getChat(state, (chat as any).toJS()) : undefined; - }) as ChatEntity; - - 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 Chat: React.FC = ({ chat, onClick }) => { return ( -
-
-
- + @{chat.account?.acct} + + + ); }; diff --git a/app/soapbox/features/placeholder/components/placeholder_chat.tsx b/app/soapbox/features/placeholder/components/placeholder-chat-message.tsx similarity index 100% rename from app/soapbox/features/placeholder/components/placeholder_chat.tsx rename to app/soapbox/features/placeholder/components/placeholder-chat-message.tsx diff --git a/app/soapbox/features/placeholder/components/placeholder-chat.tsx b/app/soapbox/features/placeholder/components/placeholder-chat.tsx new file mode 100644 index 000000000..a7747babc --- /dev/null +++ b/app/soapbox/features/placeholder/components/placeholder-chat.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { HStack, Stack } from 'soapbox/components/ui'; + +import PlaceholderAvatar from './placeholder_avatar'; +import PlaceholderDisplayName from './placeholder_display_name'; + +/** Fake chat to display while data is loading. */ +const PlaceholderChat = () => { + return ( +
+ + + + + + + +
+ ); +}; + +export default PlaceholderChat;