Merge remote-tracking branch 'origin/chats' into chats

This commit is contained in:
Justin 2022-09-23 09:04:05 -04:00
commit 0576565c83
12 changed files with 153 additions and 108 deletions

View file

@ -1,6 +1,6 @@
import { getSettings } from 'soapbox/actions/settings';
import messages from 'soapbox/locales/messages';
import { queryClient } from 'soapbox/queries/client';
import { updatePageItem, appendPageItem } from 'soapbox/utils/queries';
import { play, soundCache } from 'soapbox/utils/sounds';
import { connectStream } from '../stream';
@ -24,8 +24,6 @@ import {
processTimelineUpdate,
} from './timelines';
import type { InfiniteData } from '@tanstack/react-query';
import type { PaginatedResult } from 'soapbox/queries/chats';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { APIEntity, Chat, ChatMessage } from 'soapbox/types/entities';
@ -56,24 +54,10 @@ interface ChatPayload extends Omit<Chat, 'last_message'> {
const updateChat = (payload: ChatPayload) => {
const { last_message: lastMessage } = payload;
queryClient.setQueriesData<InfiniteData<PaginatedResult<Chat>>>(['chats', 'search'], (data) => {
if (data) {
const pages = data.pages.map(page => {
const result = page.result.map(chat => chat.id === payload.id ? payload as any : chat);
return { ...page, result };
});
return { ...data, pages };
}
});
updatePageItem<Chat>(['chats', 'search'], payload as any, (o, n) => o.id === n.id);
if (lastMessage) {
queryClient.setQueryData<InfiniteData<PaginatedResult<ChatMessage>>>(['chats', 'messages', payload.id], (data) => {
if (data) {
const pages = [...data.pages];
pages[0] = { ...pages[0], result: [...pages[0].result, lastMessage] };
return { ...data, pages };
}
});
appendPageItem(['chats', 'messages', payload.id], lastMessage);
}
};

View file

@ -98,7 +98,7 @@ const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, chatSilence, onC
weight='medium'
theme='muted'
truncate
className='w-full truncate-child pointer-events-none'
className='w-full h-5 truncate-child pointer-events-none'
data-testid='chat-last-message'
dangerouslySetInnerHTML={{ __html: chat.last_message?.content }}
/>

View file

@ -21,7 +21,10 @@ const ChatPage = () => {
const { top } = containerRef.current.getBoundingClientRect();
const fullHeight = document.body.offsetHeight;
setHeight(fullHeight - top);
// On mobile, account for bottom navigation.
const offset = document.body.clientWidth < 976 ? -61 : 0;
setHeight(fullHeight - top + offset);
};
useEffect(() => {

View file

@ -1,62 +1,55 @@
import { useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import React from 'react';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import AccountSearch from 'soapbox/components/account_search';
import { CardTitle, Stack } from 'soapbox/components/ui';
import { CardTitle, HStack, IconButton, Stack } from 'soapbox/components/ui';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { useAppDispatch } from 'soapbox/hooks';
import { IChat, useChats } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
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' },
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },
});
const ChatPageSidebar = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const [search, setSearch] = useState('');
const { setChat } = useChatContext();
const { getOrCreateChatByAccountId } = useChats();
const handleSuggestion = (accountId: string) => {
handleClickOnSearchResult.mutate(accountId);
};
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);
queryClient.invalidateQueries(['chats', 'search']);
},
});
const debouncedSearch = useDebounce(search, 300);
const handleClickChat = (chat: IChat) => setChat(chat);
return (
<Stack space={4} className='h-full'>
<Stack space={4} className='px-4 pt-4'>
<CardTitle title={intl.formatMessage(messages.title)} />
<HStack alignItems='center' justifyContent='between'>
<CardTitle title={intl.formatMessage(messages.title)} />
<AccountSearch
placeholder={intl.formatMessage(messages.searchPlaceholder)}
onSelected={handleSuggestion}
/>
<IconButton
src={require('@tabler/icons/edit.svg')}
iconClassName='w-5 h-5 text-gray-600'
/>
</HStack>
{features.chatsSearch && (
<ChatSearchInput
value={search}
onChange={e => setSearch(e.target.value)}
onClear={() => setSearch('')}
/>
)}
</Stack>
<Stack className='flex-grow h-full'>
<ChatList onClickChat={handleClickChat} />
<ChatList
onClickChat={handleClickChat}
searchValue={debouncedSearch}
/>
</Stack>
</Stack>
);

View file

@ -1,14 +1,14 @@
import sumBy from 'lodash/sumBy';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Icon, Input, Stack } from 'soapbox/components/ui';
import { Stack } from 'soapbox/components/ui';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { useDebounce } from 'soapbox/hooks';
import { useDebounce, useFeatures } from 'soapbox/hooks';
import { IChat, useChats } from 'soapbox/queries/chats';
import ChatList from '../chat-list';
import ChatPaneHeader from '../chat-pane-header';
import ChatSearchInput from '../chat-search-input';
import ChatSearch from '../chat-search/chat-search';
import EmptyResultsBlankslate from '../chat-search/empty-results-blankslate';
import ChatWindow from '../chat-window';
@ -16,12 +16,8 @@ import { Pane } from '../ui';
import Blankslate from './blankslate';
const messages = defineMessages({
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Search inbox' },
});
const ChatPane = () => {
const intl = useIntl();
const features = useFeatures();
const debounce = useDebounce;
const [value, setValue] = useState<string>();
@ -49,26 +45,15 @@ const ChatPane = () => {
if (hasSearchValue || Number(chats?.length) > 0) {
return (
<Stack space={4} className='flex-grow h-full'>
<div className='px-4'>
<Input
type='text'
autoFocus
placeholder={intl.formatMessage(messages.searchPlaceholder)}
className='rounded-full'
value={value || ''}
onChange={(event) => setValue(event.target.value)}
isSearch
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>
{features.chatsSearch && (
<div className='px-4'>
<ChatSearchInput
value={value || ''}
onChange={(event) => setValue(event.target.value)}
onClear={clearValue}
/>
</div>
)}
{Number(chats?.length) > 0 ? (
<ChatList

View file

@ -0,0 +1,45 @@
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
type='text'
autoFocus
placeholder={intl.formatMessage(messages.searchPlaceholder)}
className='rounded-full'
value={value}
onChange={onChange}
isSearch
append={
<button onClick={onClear}>
<Icon
src={value.length ? require('@tabler/icons/x.svg') : require('@tabler/icons/search.svg')}
className='h-4 w-4 text-gray-700 dark:text-gray-600'
aria-hidden='true'
/>
</button>
}
/>
);
};
export default ChatSearchInput;

View file

@ -1,26 +1,16 @@
import React, { useEffect } from 'react';
import React from 'react';
import { connectDirectStream } from 'soapbox/actions/streaming';
import { ChatProvider } from 'soapbox/contexts/chat-context';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { useOwnAccount } from 'soapbox/hooks';
import ChatPane from './chat-pane/chat-pane';
const ChatWidget = () => {
const account = useOwnAccount();
const dispatch = useAppDispatch();
const path = location.pathname;
const shouldHideWidget = Boolean(path.match(/^\/chats/));
useEffect(() => {
const disconnect = dispatch(connectDirectStream());
return (() => {
disconnect();
});
}, []);
if (!account?.chats_onboarded || shouldHideWidget) {
return null;
}

View file

@ -27,7 +27,7 @@ export const AccountRecord = ImmutableRecord({
avatar_static: '',
birthday: '',
bot: false,
chats_onboarded: false,
chats_onboarded: true,
created_at: '',
discoverable: false,
display_name: '',

View file

@ -3,7 +3,7 @@ import React from 'react';
/** Custom layout for chats on desktop. */
const ChatsPage: React.FC = ({ children }) => {
return (
<div className='md:col-span-12 lg:col-span-9 pb-16 sm:pb-0'>
<div className='md:col-span-12 lg:col-span-9'>
{children}
</div>
);

View file

@ -6,8 +6,9 @@ import snackbar from 'soapbox/actions/snackbar';
import { getNextLink } from 'soapbox/api';
import compareId from 'soapbox/compare_id';
import { useChatContext } from 'soapbox/contexts/chat-context';
import { useApi, useAppDispatch } from 'soapbox/hooks';
import { useApi, useAppDispatch, useFeatures } from 'soapbox/hooks';
import { normalizeChatMessage } from 'soapbox/normalizers';
import { flattenPages, updatePageItem } from 'soapbox/utils/queries';
import { queryClient } from './client';
@ -88,10 +89,7 @@ const useChatMessages = (chatId: string) => {
},
});
const data = queryInfo.data?.pages.reduce<IChatMessage[]>(
(prev: IChatMessage[], curr) => [...curr.result, ...prev],
[],
);
const data = flattenPages(queryInfo);
return {
...queryInfo,
@ -102,10 +100,12 @@ const useChatMessages = (chatId: string) => {
const useChats = (search?: string) => {
const api = useApi();
const dispatch = useAppDispatch();
const features = useFeatures();
const getChats = async(pageParam?: any): Promise<PaginatedResult<IChat>> => {
const endpoint = features.chatsV2 ? '/api/v2/pleroma/chats' : '/api/v1/pleroma/chats';
const nextPageLink = pageParam?.link;
const uri = nextPageLink || '/api/v1/pleroma/chats';
const uri = nextPageLink || endpoint;
const response = await api.get<IChat[]>(uri, {
params: {
search,
@ -137,10 +137,7 @@ const useChats = (search?: string) => {
},
});
const data = queryInfo.data?.pages.reduce<IChat[]>(
(prev: IChat[], curr) => [...prev, ...curr.result],
[],
);
const data = flattenPages(queryInfo);
const chatsQuery = {
...queryInfo,
@ -158,7 +155,7 @@ const useChat = (chatId: string) => {
const markChatAsRead = (lastReadId: string) => {
api.post<IChat>(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId })
.then(() => queryClient.invalidateQueries(['chats', 'search']))
.then(({ data }) => updatePageItem(['chats', 'search'], data, (o, n) => o.id === n.id))
.catch(() => null);
};

View file

@ -204,6 +204,12 @@ const getInstanceFeatures = (instance: Instance) => {
*/
chats: v.software === TRUTHSOCIAL || (v.software === PLEROMA && gte(v.version, '2.1.0')),
/**
* Ability to search among chats.
* @see GET /api/v1/pleroma/chats
*/
chatsSearch: v.software === TRUTHSOCIAL,
/**
* Paginated chats API.
* @see GET /api/v2/chats

View file

@ -0,0 +1,42 @@
import { queryClient } from 'soapbox/queries/client';
import type { InfiniteData, QueryKey, UseInfiniteQueryResult } from '@tanstack/react-query';
import type { PaginatedResult } from 'soapbox/queries/chats';
/** Flatten paginated results into a single array. */
const flattenPages = <T>(queryInfo: UseInfiniteQueryResult<PaginatedResult<T>>) => {
return queryInfo.data?.pages.reduce<T[]>(
(prev: T[], curr) => [...prev, ...curr.result],
[],
);
};
/** Traverse pages and update the item inside if found. */
const updatePageItem = <T>(queryKey: QueryKey, newItem: T, isItem: (item: T, newItem: T) => boolean) => {
queryClient.setQueriesData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
if (data) {
const pages = data.pages.map(page => {
const result = page.result.map(item => isItem(item, newItem) ? newItem : item);
return { ...page, result };
});
return { ...data, pages };
}
});
};
/** Insert the new item at the beginning of the first page. */
const appendPageItem = <T>(queryKey: QueryKey, newItem: T) => {
queryClient.setQueryData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
if (data) {
const pages = [...data.pages];
pages[0] = { ...pages[0], result: [...pages[0].result, newItem] };
return { ...data, pages };
}
});
};
export {
flattenPages,
updatePageItem,
appendPageItem,
};