Merge remote-tracking branch 'origin/chats' into chats
This commit is contained in:
commit
0576565c83
12 changed files with 153 additions and 108 deletions
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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 }}
|
||||
/>
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
45
app/soapbox/features/chats/components/chat-search-input.tsx
Normal file
45
app/soapbox/features/chats/components/chat-search-input.tsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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: '',
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
42
app/soapbox/utils/queries.ts
Normal file
42
app/soapbox/utils/queries.ts
Normal 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,
|
||||
};
|
Loading…
Reference in a new issue