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

chats-router
Alex Gleason 2022-09-28 12:53:03 -05:00
commit 44e7b5f831
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
37 zmienionych plików z 663 dodań i 278 usunięć

Wyświetl plik

@ -277,6 +277,7 @@ module.exports = {
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-undef': 'off', // https://stackoverflow.com/a/69155899
'space-before-function-paren': 'off',
},
parser: '@typescript-eslint/parser',
},

Wyświetl plik

@ -3,6 +3,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { getSettings } from 'soapbox/actions/settings';
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
import { useStatContext } from 'soapbox/contexts/stat-context';
import ComposeButton from 'soapbox/features/ui/components/compose-button';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { getFeatures } from 'soapbox/utils/features';
@ -24,12 +25,12 @@ const messages = defineMessages({
/** Desktop sidebar with links to different views in the app. */
const SidebarNavigation = () => {
const intl = useIntl();
const { unreadChatsCount } = useStatContext();
const instance = useAppSelector((state) => state.instance);
const settings = useAppSelector((state) => getSettings(state));
const account = useOwnAccount();
const notificationCount = useAppSelector((state) => state.notifications.get('unread'));
const chatsCount = useAppSelector((state) => state.chats.items.reduce((acc, curr) => acc + Math.min(curr.unread || 0, 1), 0));
const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count());
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
@ -114,7 +115,7 @@ const SidebarNavigation = () => {
<SidebarNavigationLink
to='/chats'
icon={require('@tabler/icons/mail.svg')}
count={chatsCount}
count={unreadChatsCount}
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
/>
);

Wyświetl plik

@ -2,13 +2,15 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import ThumbNavigationLink from 'soapbox/components/thumb_navigation-link';
import { useStatContext } from 'soapbox/contexts/stat-context';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { getFeatures } from 'soapbox/utils/features';
const ThumbNavigation: React.FC = (): JSX.Element => {
const account = useOwnAccount();
const { unreadChatsCount } = useStatContext();
const notificationCount = useAppSelector((state) => state.notifications.unread);
const chatsCount = useAppSelector((state) => state.chats.items.reduce((acc, curr) => acc + Math.min(curr.unread || 0, 1), 0));
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
const features = getFeatures(useAppSelector((state) => state.instance));
@ -21,7 +23,7 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
text={<FormattedMessage id='navigation.direct_messages' defaultMessage='Messages' />}
to='/chats'
exact
count={chatsCount}
count={unreadChatsCount}
/>
);
}

Wyświetl plik

@ -41,8 +41,8 @@ const Widget: React.FC<IWidget> = ({
action,
}): JSX.Element => {
return (
<Stack space={2}>
<HStack alignItems='center'>
<Stack space={4}>
<HStack alignItems='center' justifyContent='between'>
<WidgetTitle title={title} />
{action || (onActionClick && (
<IconButton

Wyświetl plik

@ -0,0 +1,29 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
type IStatContext = {
unreadChatsCount: number,
setUnreadChatsCount: React.Dispatch<React.SetStateAction<number>>
}
const StatContext = createContext<any>({
unreadChatsCount: 0,
});
const StatProvider: React.FC = ({ children }) => {
const [unreadChatsCount, setUnreadChatsCount] = useState<number>(0);
const value = useMemo(() => ({
unreadChatsCount,
setUnreadChatsCount,
}), [unreadChatsCount]);
return (
<StatContext.Provider value={value}>
{children}
</StatContext.Provider>
);
};
const useStatContext = (): IStatContext => useContext(StatContext);
export { StatProvider, useStatContext };

Wyświetl plik

@ -6,6 +6,7 @@ import { Stack, HStack, Card, Avatar, Text, Icon } from 'soapbox/components/ui';
import IconButton from 'soapbox/components/ui/icon-button/icon-button';
import StatusCard from 'soapbox/features/status/components/card';
import { useAppSelector } from 'soapbox/hooks';
import { adKeys } from 'soapbox/queries/ads';
import type { Card as CardEntity } from 'soapbox/types/entities';
@ -29,7 +30,7 @@ const Ad: React.FC<IAd> = ({ card, impression, expires }) => {
/** Invalidate query cache for ads. */
const bustCache = (): void => {
queryClient.invalidateQueries(['ads']);
queryClient.invalidateQueries(adKeys.ads);
};
/** Toggle the info box on click. */

Wyświetl plik

@ -1,10 +1,24 @@
import React from 'react';
import React, { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import RelativeTimestamp from 'soapbox/components/relative-timestamp';
import { Avatar, HStack, Icon, Stack, Text } from 'soapbox/components/ui';
import VerificationBadge from 'soapbox/components/verification_badge';
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
import { useAppDispatch } from 'soapbox/hooks';
import { IChat, IChatSilence, useChat, useChatSilence } from 'soapbox/queries/chats';
import type { IChat, IChatSilence } from 'soapbox/queries/chats';
import type { Menu } from 'soapbox/components/dropdown_menu';
const messages = defineMessages({
silenceNotifications: { id: 'chat_settings.silence_notifications', defaultMessage: 'Silence notifications' },
unsilenceNotifications: { id: 'chat_settings.unsilence_notifications', defaultMessage: 'Unsilence notifications' },
leaveMessage: { id: 'chat_settings.leave.message', defaultMessage: 'Are you sure you want to leave this chat? Messages will be deleted for you and this chat will be removed from your inbox.' },
leaveHeading: { id: 'chat_settings.leave.heading', defaultMessage: 'Leave Chat' },
leaveConfirm: { id: 'chat_settings.leave.confirm', defaultMessage: 'Leave Chat' },
leaveChat: { id: 'chat_settings.options.leave_chat', defaultMessage: 'Leave Chat' },
});
interface IChatListItemInterface {
chat: IChat,
@ -13,12 +27,60 @@ interface IChatListItemInterface {
}
const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, chatSilence, onClick }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { handleSilence } = useChatSilence(chat);
const { deleteChat } = useChat(chat?.id as string);
const menu = useMemo((): Menu => {
const menu: Menu = [];
if (chatSilence) {
menu.push({
text: intl.formatMessage(messages.unsilenceNotifications),
action: (event) => {
event.stopPropagation();
handleSilence();
},
icon: require('@tabler/icons/bell.svg'),
});
} else {
menu.push({
text: intl.formatMessage(messages.silenceNotifications),
action: (event) => {
event.stopPropagation();
handleSilence();
},
icon: require('@tabler/icons/bell-off.svg'),
});
}
menu.push({
text: intl.formatMessage(messages.leaveChat),
action: (event) => {
event.stopPropagation();
dispatch(openModal('CONFIRM', {
heading: intl.formatMessage(messages.leaveHeading),
message: intl.formatMessage(messages.leaveMessage),
confirm: intl.formatMessage(messages.leaveConfirm),
confirmationTheme: 'primary',
onConfirm: () => deleteChat.mutate(),
}));
},
icon: require('@tabler/icons/logout.svg'),
});
return menu;
}, [chatSilence]);
return (
<button
key={chat.id}
type='button'
onClick={() => onClick(chat)}
className='px-2 py-3 w-full flex flex-col rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 focus:shadow-inset-ring'
className='group px-2 py-3 w-full flex flex-col rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 focus:shadow-inset-ring'
data-testid='chat'
>
<HStack alignItems='center' justifyContent='between' space={2} className='w-full'>
@ -36,7 +98,7 @@ const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, chatSilence, onC
align='left'
size='sm'
weight='medium'
theme='muted'
theme={chat.last_message.unread ? 'default' : 'muted'}
truncate
className='w-full h-5 truncate-child pointer-events-none'
data-testid='chat-last-message'
@ -47,6 +109,16 @@ const ChatListItem: React.FC<IChatListItemInterface> = ({ chat, chatSilence, onC
</HStack>
<HStack alignItems='center' space={2}>
<div className='text-gray-600 hidden group-hover:block hover:text-gray-100'>
{/* TODO: fix nested buttons here */}
<DropdownMenuContainer
items={menu}
src={require('@tabler/icons/dots.svg')}
title='Settings'
/>
</div>
{chatSilence ? (
<Icon src={require('@tabler/icons/bell-off.svg')} className='w-5 h-5 text-gray-600' />
) : null}

Wyświetl plik

@ -14,11 +14,10 @@ import ChatListItem from './chat-list-item';
interface IChatList {
onClickChat: (chat: any) => void,
useWindowScroll?: boolean,
fade?: boolean,
searchValue?: string
}
const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false, searchValue, fade }) => {
const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false, searchValue }) => {
const dispatch = useAppDispatch();
const chatListRef = useRef(null);
@ -76,22 +75,20 @@ const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false, s
)}
</PullToRefresh>
{fade && (
<>
<div
className={classNames('inset-x-0 top-0 flex rounded-t-lg justify-center bg-gradient-to-b from-white pb-12 pt-8 pointer-events-none dark:from-gray-900 absolute transition-opacity duration-500', {
'opacity-0': isNearTop,
'opacity-100': !isNearTop,
})}
/>
<div
className={classNames('inset-x-0 bottom-0 flex rounded-b-lg justify-center bg-gradient-to-t from-white pt-12 pb-8 pointer-events-none dark:from-gray-900 absolute transition-opacity duration-500', {
'opacity-0': isNearBottom,
'opacity-100': !isNearBottom,
})}
/>
</>
)}
<>
<div
className={classNames('inset-x-0 top-0 flex rounded-t-lg justify-center bg-gradient-to-b from-white to-transparent pb-12 pt-8 pointer-events-none dark:from-gray-900 absolute transition-opacity duration-500', {
'opacity-0': isNearTop,
'opacity-100': !isNearTop,
})}
/>
<div
className={classNames('inset-x-0 bottom-0 flex rounded-b-lg justify-center bg-gradient-to-t from-white to-transparent pt-12 pb-8 pointer-events-none dark:from-gray-900 absolute transition-opacity duration-500', {
'opacity-0': isNearBottom,
'opacity-100': !isNearBottom,
})}
/>
</>
</div>
);
};

Wyświetl plik

@ -11,7 +11,7 @@ import { useChat } from 'soapbox/queries/chats';
const messages = defineMessages({
leaveChatHeading: { id: 'chat_message_list_intro.leave_chat.heading', defaultMessage: 'Leave Chat' },
leaveChatMessage: { id: 'chat_message_list_intro.leave_chat.message', defaultMessage: 'Are you sure you want to leave this chat? This conversation will be removed from your inbox.' },
leaveChatMessage: { id: 'chat_message_list_intro.leave_chat.message', defaultMessage: 'Are you sure you want to leave this chat? Messages will be deleted for you and this chat will be removed from your inbox.' },
leaveChatConfirm: { id: 'chat_message_list_intro.leave_chat.confirm', defaultMessage: 'Leave Chat' },
intro: { id: 'chat_message_list_intro.intro', defaultMessage: 'wants to start a chat with you' },
accept: { id: 'chat_message_list_intro.actions.accept', defaultMessage: 'Accept' },

Wyświetl plik

@ -14,7 +14,7 @@ import PlaceholderChatMessage from 'soapbox/features/placeholder/components/plac
import Bundle from 'soapbox/features/ui/components/bundle';
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { IChat, IChatMessage, useChat, useChatMessages } from 'soapbox/queries/chats';
import { chatKeys, IChat, IChatMessage, useChat, useChatMessages } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import { onlyEmoji } from 'soapbox/utils/rich_content';
@ -30,6 +30,8 @@ const messages = defineMessages({
more: { id: 'chats.actions.more', defaultMessage: 'More' },
delete: { id: 'chats.actions.delete', defaultMessage: 'Delete for both' },
copy: { id: 'chats.actions.copy', defaultMessage: 'Copy' },
report: { id: 'chats.actions.report', defaultMessage: 'Report' },
deleteForMe: { id: 'chats.actions.deleteForMe', defaultMessage: 'Delete for me' },
blockedBy: { id: 'chat_message_list.blockedBy', defaultMessage: 'You are blocked by' },
networkFailureTitle: { id: 'chat_message_list.network_failure.title', defaultMessage: 'Whoops!' },
networkFailureSubtitle: { id: 'chat_message_list.network_failure.subtitle', defaultMessage: 'We encountered a network failure.' },
@ -94,7 +96,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, autosize }) => {
const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), {
onSettled: () => {
queryClient.invalidateQueries(['chats', 'messages', chat.id]);
queryClient.invalidateQueries(chatKeys.chatMessages(chat.id));
},
});
@ -241,6 +243,18 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat, autosize }) => {
icon: require('@tabler/icons/trash.svg'),
destructive: true,
});
} else {
menu.push({
text: intl.formatMessage(messages.report),
action: () => null, // TODO: implement once API is available
icon: require('@tabler/icons/flag.svg'),
});
menu.push({
text: intl.formatMessage(messages.deleteForMe),
action: () => null, // TODO: implement once API is available
icon: require('@tabler/icons/trash.svg'),
destructive: true,
});
}
return (

Wyświetl plik

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
@ -19,7 +19,7 @@ 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' },
leaveMessage: { id: 'chat_settings.leave.message', defaultMessage: 'Are you sure you want to leave this chat? This conversation will be removed from your inbox.' },
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}' },
@ -38,7 +38,7 @@ const ChatPageMain: React.FC<IChatPageMain> = ({ chatId }) => {
const account = useOwnAccount();
const { chat, deleteChat } = useChat(chatId);
const { isSilenced, handleSilence } = useChatSilence(chat?.data);
const { isSilenced, handleSilence, fetchChatSilence } = useChatSilence(chat?.data);
const handleBlockUser = () => {
dispatch(openModal('CONFIRM', {
@ -62,6 +62,12 @@ const ChatPageMain: React.FC<IChatPageMain> = ({ chatId }) => {
const handleReportChat = () => dispatch(initReport(chat?.data?.account as any));
useEffect(() => {
if (chatId) {
fetchChatSilence();
}
}, [chatId]);
if (!chat && !account?.chats_onboarded) {
return (
<Welcome />

Wyświetl plik

@ -19,7 +19,7 @@ const Blankslate = ({ onSearch }: IBlankslate) => {
return (
<Stack alignItems='center' justifyContent='center' className='h-full flex-grow'>
<Stack space={4}>
<Stack space={1} className='max-w-[85%] mx-auto'>
<Stack space={1} className='max-w-[80%] mx-auto'>
<Text size='lg' weight='bold' align='center'>
{intl.formatMessage(messages.title)}
</Text>

Wyświetl plik

@ -3,6 +3,7 @@ import React, { useState } from 'react';
import { Stack } from 'soapbox/components/ui';
import { 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';
@ -19,14 +20,13 @@ import Blankslate from './blankslate';
const ChatPane = () => {
const features = useFeatures();
const debounce = useDebounce;
const { unreadChatsCount } = useStatContext();
const [value, setValue] = useState<string>();
const debouncedValue = debounce(value as string, 300);
const { chat, setChat, isOpen, isSearching, setSearching, toggleChatPane } = useChatContext();
const { chatsQuery: { data: chats } } = useChats(debouncedValue);
const unreadCount = sumBy(chats, (chat) => chat.unread);
const { chatsQuery: { data: chats, isLoading } } = useChats(debouncedValue);
const hasSearchValue = Number(debouncedValue?.length) > 0;
@ -42,7 +42,7 @@ const ChatPane = () => {
};
const renderBody = () => {
if (hasSearchValue || Number(chats?.length) > 0) {
if (hasSearchValue || Number(chats?.length) > 0 || isLoading) {
return (
<Stack space={4} className='flex-grow h-full'>
{features.chatsSearch && (
@ -55,11 +55,10 @@ const ChatPane = () => {
</div>
)}
{Number(chats?.length) > 0 ? (
{(Number(chats?.length) > 0 || isLoading) ? (
<ChatList
searchValue={debouncedValue}
onClickChat={handleClickChat}
fade
/>
) : (
<EmptyResultsBlankslate />
@ -90,12 +89,16 @@ const ChatPane = () => {
<Pane isOpen={isOpen} index={0} main>
<ChatPaneHeader
title='Messages'
unreadCount={unreadCount}
unreadCount={unreadChatsCount}
isOpen={isOpen}
onToggle={toggleChatPane}
secondaryAction={() => {
setSearching(true);
setValue(undefined);
if (!isOpen) {
toggleChatPane();
}
}}
secondaryActionIcon={require('@tabler/icons/edit.svg')}
/>

Wyświetl plik

@ -11,6 +11,7 @@ import { useChats } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import useAccountSearch from 'soapbox/queries/search';
import { chatKeys } from '../../../../queries/chats';
import ChatPaneHeader from '../chat-pane-header';
import { Pane } from '../ui';
@ -47,7 +48,7 @@ const ChatSearch = () => {
},
onSuccess: (response) => {
setChat(response.data);
queryClient.invalidateQueries(['chats', 'search']);
queryClient.invalidateQueries(chatKeys.chatSearch());
},
});

Wyświetl plik

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { blockAccount } from 'soapbox/actions/accounts';
@ -16,7 +16,7 @@ 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' },
leaveMessage: { id: 'chat_settings.leave.message', defaultMessage: 'Are you sure you want to leave this chat? This conversation will be removed from your inbox.' },
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' },
@ -30,7 +30,7 @@ const ChatSettings = () => {
const intl = useIntl();
const { chat, setEditing, toggleChatPane } = useChatContext();
const { isSilenced, handleSilence } = useChatSilence(chat);
const { isSilenced, handleSilence, fetchChatSilence } = useChatSilence(chat);
const { deleteChat } = useChat(chat?.id as string);
@ -63,6 +63,12 @@ const ChatSettings = () => {
const handleReportChat = () => dispatch(initReport(chat?.account as any));
useEffect(() => {
if (chat?.id) {
fetchChatSilence();
}
}, [chat?.id]);
if (!chat) {
return null;
}

Wyświetl plik

@ -8,7 +8,7 @@ import { HStack, IconButton, Stack, Text, Textarea } from 'soapbox/components/ui
import UploadProgress from 'soapbox/components/upload-progress';
import UploadButton from 'soapbox/features/compose/components/upload_button';
import { useAppDispatch, useOwnAccount } from 'soapbox/hooks';
import { IChat, useChat } from 'soapbox/queries/chats';
import { chatKeys, IChat, useChat } from 'soapbox/queries/chats';
import { queryClient } from 'soapbox/queries/client';
import { truncateFilename } from 'soapbox/utils/media';
@ -97,7 +97,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, autosize, inputRef, className })
},
// Always refetch after error or success:
onSuccess: () => {
queryClient.invalidateQueries(['chats', 'messages', chat.id]);
queryClient.invalidateQueries(chatKeys.chatMessages(chat.id));
},
});

Wyświetl plik

@ -36,7 +36,9 @@ const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
dispatch(openModal('REPLY_MENTIONS'));
dispatch(openModal('REPLY_MENTIONS', {
composeId,
}));
};
if (!parentTo || (parentTo.size === 0)) {

Wyświetl plik

@ -11,7 +11,7 @@ import ActionButton from '../ui/components/action-button';
import type { Account } from 'soapbox/types/entities';
const messages = defineMessages({
heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested profiles' },
heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested Profiles' },
viewAll: { id: 'feed_suggestions.view_all', defaultMessage: 'View all' },
});
@ -65,7 +65,7 @@ const FeedSuggestions = () => {
if (!isLoading && suggestedProfiles.size === 0) return null;
return (
<Card size='lg' variant='rounded' className='space-y-4'>
<Card size='lg' variant='rounded' className='space-y-6'>
<HStack justifyContent='between' alignItems='center'>
<CardTitle title={intl.formatMessage(messages.heading)} />

Wyświetl plik

@ -10,7 +10,7 @@ import Column from 'soapbox/features/ui/components/column';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested profiles' },
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested Profiles' },
});
const FollowRecommendations: React.FC = () => {

Wyświetl plik

@ -17,10 +17,6 @@ const BioStep = ({ onNext }: { onNext: () => void }) => {
const [isSubmitting, setSubmitting] = React.useState<boolean>(false);
const [errors, setErrors] = React.useState<string[]>([]);
const trimmedValue = value.trim();
const isValid = trimmedValue.length > 0;
const isDisabled = !isValid;
const handleSubmit = () => {
setSubmitting(true);
@ -79,7 +75,7 @@ const BioStep = ({ onNext }: { onNext: () => void }) => {
block
theme='primary'
type='submit'
disabled={isDisabled || isSubmitting}
disabled={isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? (

Wyświetl plik

@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import useOnboardingSuggestions from 'soapbox/queries/suggestions';
import { useOnboardingSuggestions } from 'soapbox/queries/suggestions';
const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const { data, fetchNextPage, hasNextPage, isFetching } = useOnboardingSuggestions();

Wyświetl plik

@ -0,0 +1,29 @@
import React from 'react';
import { HStack, Stack } from 'soapbox/components/ui';
import { randomIntFromInterval, generateText } from '../utils';
export default ({ limit }: { limit: number }) => {
const length = randomIntFromInterval(15, 3);
const acctLength = randomIntFromInterval(15, 3);
return (
<>
{new Array(limit).fill(undefined).map((_, idx) => (
<HStack key={idx} alignItems='center' space={2} className='animate-pulse'>
<Stack space={3} className='text-center'>
<div
className='w-9 h-9 block mx-auto rounded-full bg-primary-200 dark:bg-primary-700'
/>
</Stack>
<Stack className='text-primary-200 dark:text-primary-700'>
<p>{generateText(length)}</p>
<p>{generateText(acctLength)}</p>
</Stack>
</HStack>
))}
</>
);
};

Wyświetl plik

@ -19,7 +19,7 @@ const Share = () => {
.join('\n\n');
if (text) {
dispatch(openComposeWithText(text));
dispatch(openComposeWithText('compose-modal', text));
}
return (
@ -27,4 +27,4 @@ const Share = () => {
);
};
export default Share;
export default Share;

Wyświetl plik

@ -1,123 +1,201 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import { normalizeAccount } from '../../../../normalizers';
import { __stub } from 'soapbox/api';
import { render, rootState, screen, waitFor } from '../../../../jest/test-helpers';
import { normalizeInstance } from '../../../../normalizers';
import WhoToFollowPanel from '../who-to-follow-panel';
const buildTruthSuggestion = (id: string) => ({
account_avatar: 'avatar',
account_id: id,
acct: 'acct',
display_name: 'my name',
note: 'hello',
verified: true,
});
const buildSuggestion = (id: string) => ({
source: 'staff',
account: {
username: 'username',
verified: true,
id,
acct: 'acct',
avatar: 'avatar',
avatar_static: 'avatar',
display_name: 'my name',
},
});
describe('<WhoToFollow />', () => {
it('renders suggested accounts', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([{
source: 'staff',
account: '1',
}]),
},
};
let store: any;
render(<WhoToFollowPanel limit={1} />, undefined, store);
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
describe('using Truth Social software', () => {
beforeEach(() => {
store = rootState
.set('me', '1234')
.set('instance', normalizeInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
}));
});
describe('with a single suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [buildTruthSuggestion('1')], {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
});
});
});
describe('with a multiple suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [buildTruthSuggestion('1'), buildTruthSuggestion('2')], {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={2} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(2);
});
});
});
describe('with a set limit', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [buildTruthSuggestion('1'), buildTruthSuggestion('2')], {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('respects the limit prop', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
});
});
describe('when the API returns an empty list', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [], {
link: '',
});
});
});
it('renders empty', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(0);
});
});
});
});
it('renders multiple accounts', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
'2': normalizeAccount({
id: '1',
acct: 'username2',
display_name: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([
{
source: 'staff',
account: '1',
},
{
source: 'staff',
account: '2',
},
]),
},
};
describe('using Pleroma software', () => {
beforeEach(() => {
store = rootState.set('me', '1234');
});
render(<WhoToFollowPanel limit={3} />, undefined, store);
expect(screen.queryAllByTestId('account')).toHaveLength(2);
});
describe('with a single suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [buildSuggestion('1')], {
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('respects the limit prop', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
'2': normalizeAccount({
id: '1',
acct: 'username2',
display_name: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([
{
source: 'staff',
account: '1',
},
{
source: 'staff',
account: '2',
},
]),
},
};
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
render(<WhoToFollowPanel limit={1} />, undefined, store);
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
await waitFor(() => {
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
});
});
});
it('renders empty', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
'2': normalizeAccount({
id: '1',
acct: 'username2',
display_name: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([]),
},
};
describe('with a multiple suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [buildSuggestion('1'), buildSuggestion('2')], {
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
render(<WhoToFollowPanel limit={1} />, undefined, store);
expect(screen.queryAllByTestId('account')).toHaveLength(0);
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={2} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(2);
});
});
});
describe('with a set limit', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [buildSuggestion('1'), buildSuggestion('2')], {
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('respects the limit prop', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
});
});
describe('when the API returns an empty list', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [], {
link: '',
});
});
});
it('renders empty', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(0);
});
});
});
});
});

Wyświetl plik

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Modal } from 'soapbox/components/ui';
@ -18,7 +18,8 @@ interface IReplyMentionsModal {
const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ composeId, onClose }) => {
const compose = useCompose(composeId);
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: compose.in_reply_to! }));
const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector<StatusEntity | null>(state => getStatus(state, { id: compose.in_reply_to! }));
const account = useAppSelector((state) => state.accounts.get(state.me));
const mentions = statusToMentionsAccountIdsArray(status!, account!);

Wyświetl plik

@ -1,11 +1,11 @@
import * as React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { Link } from 'react-router-dom';
import { fetchSuggestions, dismissSuggestion } from 'soapbox/actions/suggestions';
import { Widget } from 'soapbox/components/ui';
import { Text, Widget } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import { useAppSelector } from 'soapbox/hooks';
import PlaceholderSidebarSuggestions from 'soapbox/features/placeholder/components/placeholder-sidebar-suggestions';
import { useDismissSuggestion, useSuggestions } from 'soapbox/queries/suggestions';
import type { Account as AccountEntity } from 'soapbox/types/entities';
@ -18,44 +18,40 @@ interface IWhoToFollowPanel {
}
const WhoToFollowPanel = ({ limit }: IWhoToFollowPanel) => {
const dispatch = useDispatch();
const intl = useIntl();
const suggestions = useAppSelector((state) => state.suggestions.items);
const { data: suggestions, isFetching } = useSuggestions();
const dismissSuggestion = useDismissSuggestion();
const suggestionsToRender = suggestions.slice(0, limit);
const handleDismiss = (account: AccountEntity) => {
dispatch(dismissSuggestion(account.id));
dismissSuggestion.mutate(account.id);
};
React.useEffect(() => {
dispatch(fetchSuggestions());
}, []);
if (suggestionsToRender.isEmpty()) {
return null;
}
// FIXME: This page actually doesn't look good right now
// const handleAction = () => {
// history.push('/suggestions');
// };
return (
<Widget
title={<FormattedMessage id='who_to_follow.title' defaultMessage='People To Follow' />}
// onAction={handleAction}
action={
<Link to='/suggestions'>
<Text tag='span' theme='primary' size='sm' className='hover:underline'>View all</Text>
</Link>
}
>
{suggestionsToRender.map((suggestion) => (
<AccountContainer
key={suggestion.account}
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.account}
actionIcon={require('@tabler/icons/x.svg')}
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
onActionClick={handleDismiss}
/>
))}
{isFetching ? (
<PlaceholderSidebarSuggestions limit={limit} />
) : (
suggestionsToRender.map((suggestion: any) => (
<AccountContainer
key={suggestion.account}
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.account}
actionIcon={require('@tabler/icons/x.svg')}
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
onActionClick={handleDismiss}
/>
))
)}
</Widget>
);
};

Wyświetl plik

@ -117,6 +117,7 @@ import { WrappedRoute } from './util/react_router_helpers';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
import 'soapbox/components/status';
import { StatProvider } from '../../contexts/stat-context';
const EmptyPage = HomePage;
@ -650,52 +651,54 @@ const UI: React.FC = ({ children }) => {
};
return (
<HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused>
<div ref={node} style={style}>
<BackgroundShapes />
<StatProvider>
<HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused>
<div ref={node} style={style}>
<BackgroundShapes />
<div className='z-10 flex flex-col'>
<Navbar />
<div className='z-10 flex flex-col'>
<Navbar />
<Layout>
<Layout.Sidebar>
{!standalone && <SidebarNavigation />}
</Layout.Sidebar>
<Layout>
<Layout.Sidebar>
{!standalone && <SidebarNavigation />}
</Layout.Sidebar>
<SwitchingColumnsArea>
{children}
</SwitchingColumnsArea>
</Layout>
<SwitchingColumnsArea>
{children}
</SwitchingColumnsArea>
</Layout>
{me && floatingActionButton}
{me && floatingActionButton}
<BundleContainer fetchComponent={UploadArea}>
{Component => <Component active={draggingOver} onClose={closeUploadModal} />}
</BundleContainer>
<BundleContainer fetchComponent={UploadArea}>
{Component => <Component active={draggingOver} onClose={closeUploadModal} />}
</BundleContainer>
{me && (
<BundleContainer fetchComponent={SidebarMenu}>
{me && (
<BundleContainer fetchComponent={SidebarMenu}>
{Component => <Component />}
</BundleContainer>
)}
{me && features.chats && !mobile && (
<BundleContainer fetchComponent={ChatWidget}>
{Component => <Component />}
</BundleContainer>
)}
<ThumbNavigation />
<BundleContainer fetchComponent={ProfileHoverCard}>
{Component => <Component />}
</BundleContainer>
)}
{me && features.chats && !mobile && (
<BundleContainer fetchComponent={ChatWidget}>
<BundleContainer fetchComponent={StatusHoverCard}>
{Component => <Component />}
</BundleContainer>
)}
<ThumbNavigation />
<BundleContainer fetchComponent={ProfileHoverCard}>
{Component => <Component />}
</BundleContainer>
<BundleContainer fetchComponent={StatusHoverCard}>
{Component => <Component />}
</BundleContainer>
</div>
</div>
</div>
</HotKeys>
</HotKeys>
</StatProvider>
);
};

Wyświetl plik

@ -41,7 +41,7 @@ const DefaultPage: React.FC = ({ children }) => {
)}
{features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={5} key='wtf-panel' />}
{Component => <Component limit={3} key='wtf-panel' />}
</BundleContainer>
)}
<LinkFooter key='link-footer' />

Wyświetl plik

@ -105,7 +105,7 @@ const HomePage: React.FC = ({ children }) => {
)}
{features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={5} />}
{Component => <Component limit={3} />}
</BundleContainer>
)}
<LinkFooter key='link-footer' />

Wyświetl plik

@ -139,7 +139,7 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
</BundleContainer>
) : features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={5} key='wtf-panel' />}
{Component => <Component limit={3} key='wtf-panel' />}
</BundleContainer>
)}
<LinkFooter key='link-footer' />

Wyświetl plik

@ -45,7 +45,7 @@ const StatusPage: React.FC<IStatusPage> = ({ children }) => {
)}
{features.suggestions && (
<BundleContainer fetchComponent={WhoToFollowPanel}>
{Component => <Component limit={5} key='wtf-panel' />}
{Component => <Component limit={3} key='wtf-panel' />}
</BundleContainer>
)}
<LinkFooter key='link-footer' />

Wyświetl plik

@ -1,7 +1,7 @@
import { __stub } from 'soapbox/api';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import useOnboardingSuggestions from '../suggestions';
import { useOnboardingSuggestions } from '../suggestions';
describe('useCarouselAvatars', () => {
describe('with a successful query', () => {
@ -17,7 +17,7 @@ describe('useCarouselAvatars', () => {
});
});
it('is successful', async() => {
it('is successful', async () => {
const { result } = renderHook(() => useOnboardingSuggestions());
await waitFor(() => expect(result.current.isFetching).toBe(false));
@ -33,7 +33,7 @@ describe('useCarouselAvatars', () => {
});
});
it('is successful', async() => {
it('is successful', async () => {
const { result } = renderHook(() => useOnboardingSuggestions());
await waitFor(() => expect(result.current.isFetching).toBe(false));

Wyświetl plik

@ -4,7 +4,11 @@ import { Ad, getProvider } from 'soapbox/features/ads/providers';
import { useAppDispatch } from 'soapbox/hooks';
import { isExpired } from 'soapbox/utils/ads';
export default function useAds() {
const adKeys = {
ads: ['ads'] as const,
};
function useAds() {
const dispatch = useAppDispatch();
const getAds = async() => {
@ -18,7 +22,7 @@ export default function useAds() {
});
};
const result = useQuery<Ad[]>(['ads'], getAds, {
const result = useQuery<Ad[]>(adKeys.ads, getAds, {
placeholderData: [],
});
@ -30,3 +34,5 @@ export default function useAds() {
data,
};
}
export { useAds as default, adKeys };

Wyświetl plik

@ -1,14 +1,16 @@
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import sumBy from 'lodash/sumBy';
import { useState } from 'react';
import { fetchRelationships } from 'soapbox/actions/accounts';
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 { useStatContext } from 'soapbox/contexts/stat-context';
import { useApi, useAppDispatch, useFeatures } from 'soapbox/hooks';
import { normalizeChatMessage } from 'soapbox/normalizers';
import { flattenPages, updatePageItem } from 'soapbox/utils/queries';
import { flattenPages, PaginatedResult, updatePageItem } from 'soapbox/utils/queries';
import { queryClient } from './client';
@ -50,18 +52,18 @@ export interface IChatSilence {
target_account_id: number
}
export interface PaginatedResult<T> {
result: T[],
hasMore: boolean,
link?: string,
}
const chatKeys = {
chatMessages: (chatId: string) => ['chats', 'messages', chatId] as const,
chatSearch: (searchQuery?: string) => ['chats', 'search', searchQuery] as const,
chatSilences: ['chatSilences'] as const,
};
const reverseOrder = (a: IChat, b: IChat): number => compareId(a.id, b.id);
const useChatMessages = (chatId: string) => {
const api = useApi();
const getChatMessages = async(chatId: string, pageParam?: any): Promise<PaginatedResult<IChatMessage>> => {
const getChatMessages = async (chatId: string, pageParam?: any): Promise<PaginatedResult<IChatMessage>> => {
const nextPageLink = pageParam?.link;
const uri = nextPageLink || `/api/v1/pleroma/chats/${chatId}/messages`;
const response = await api.get(uri);
@ -78,7 +80,7 @@ const useChatMessages = (chatId: string) => {
};
};
const queryInfo = useInfiniteQuery(['chats', 'messages', chatId], ({ pageParam }) => getChatMessages(chatId, pageParam), {
const queryInfo = useInfiniteQuery(chatKeys.chatMessages(chatId), ({ pageParam }) => getChatMessages(chatId, pageParam), {
keepPreviousData: true,
getNextPageParam: (config) => {
if (config.hasMore) {
@ -101,8 +103,9 @@ const useChats = (search?: string) => {
const api = useApi();
const dispatch = useAppDispatch();
const features = useFeatures();
const { setUnreadChatsCount } = useStatContext();
const getChats = async(pageParam?: any): Promise<PaginatedResult<IChat>> => {
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 || endpoint;
@ -116,6 +119,9 @@ const useChats = (search?: string) => {
const link = getNextLink(response);
const hasMore = !!link;
// TODO: change to response header
setUnreadChatsCount(sumBy(data, (chat) => chat.unread));
// Set the relationships to these users in the redux store.
dispatch(fetchRelationships(data.map((item) => item.account.id)));
@ -126,7 +132,7 @@ const useChats = (search?: string) => {
};
};
const queryInfo = useInfiniteQuery(['chats', 'search', search], ({ pageParam }) => getChats(pageParam), {
const queryInfo = useInfiniteQuery(chatKeys.chatSearch(search), ({ pageParam }) => getChats(pageParam), {
keepPreviousData: true,
getNextPageParam: (config) => {
if (config.hasMore) {
@ -181,8 +187,8 @@ const useChat = (chatId?: string) => {
const acceptChat = useMutation(() => api.post<IChat>(`/api/v1/pleroma/chats/${chatId}/accept`), {
onSuccess(response) {
setChat(response.data);
queryClient.invalidateQueries(['chats', 'messages', chatId]);
queryClient.invalidateQueries(['chats', 'search']);
queryClient.invalidateQueries(chatKeys.chatMessages(chatId));
queryClient.invalidateQueries(chatKeys.chatSearch());
},
});
@ -190,8 +196,8 @@ const useChat = (chatId?: string) => {
onSuccess(response) {
setChat(null);
setEditing(false);
queryClient.invalidateQueries(['chats', 'messages', chatId]);
queryClient.invalidateQueries(['chats', 'search']);
queryClient.invalidateQueries(chatKeys.chatMessages(chatId));
queryClient.invalidateQueries(chatKeys.chatSearch());
},
});
@ -201,13 +207,13 @@ const useChat = (chatId?: string) => {
const useChatSilences = () => {
const api = useApi();
const getChatSilences = async() => {
const getChatSilences = async () => {
const { data } = await api.get<IChatSilence[]>('/api/v1/pleroma/chats/silences');
return data;
};
return useQuery<IChatSilence[]>(['chatSilences'], getChatSilences, {
return useQuery<IChatSilence[]>(chatKeys.chatSilences, getChatSilences, {
placeholderData: [],
});
};
@ -218,12 +224,12 @@ const useChatSilence = (chat?: IChat) => {
const [isSilenced, setSilenced] = useState<boolean>(false);
const getChatSilences = async() => {
const getChatSilences = async () => {
const { data } = await api.get(`api/v1/pleroma/chats/silence?account_id=${chat?.account.id}`);
return data;
};
const fetchChatSilence = async() => {
const fetchChatSilence = async () => {
const data = await getChatSilences();
if (data) {
setSilenced(true);
@ -246,6 +252,7 @@ const useChatSilence = (chat?: IChat) => {
api.post(`api/v1/pleroma/chats/silence?account_id=${chat?.account.id}`)
.then(() => {
dispatch(snackbar.success('Successfully silenced this chat.'));
queryClient.invalidateQueries(chatKeys.chatSilences);
})
.catch(() => {
dispatch(snackbar.error('Something went wrong trying to silence this chat. Please try again.'));
@ -259,6 +266,7 @@ const useChatSilence = (chat?: IChat) => {
api.delete(`api/v1/pleroma/chats/silence?account_id=${chat?.account.id}`)
.then(() => {
dispatch(snackbar.success('Successfully unsilenced this chat.'));
queryClient.invalidateQueries(chatKeys.chatSilences);
})
.catch(() => {
dispatch(snackbar.error('Something went wrong trying to unsilence this chat. Please try again.'));
@ -266,13 +274,7 @@ const useChatSilence = (chat?: IChat) => {
});
};
useEffect(() => {
if (chat?.id) {
fetchChatSilence();
}
}, [chat?.id]);
return { isSilenced, handleSilence };
return { isSilenced, handleSilence, fetchChatSilence };
};
export { useChat, useChats, useChatMessages, useChatSilences, useChatSilence };
export { chatKeys, useChat, useChats, useChatMessages, useChatSilences, useChatSilence };

Wyświetl plik

@ -1,9 +1,12 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { fetchRelationships } from 'soapbox/actions/accounts';
import { importFetchedAccounts } from 'soapbox/actions/importer';
import { SuggestedProfile } from 'soapbox/actions/suggestions';
import { getLinks } from 'soapbox/api';
import { useApi, useAppDispatch } from 'soapbox/hooks';
import { useApi, useAppDispatch, useFeatures } from 'soapbox/hooks';
import { PaginatedResult, removePageItem } from '../utils/queries';
import type { IAccount } from './accounts';
@ -12,11 +15,124 @@ type Suggestion = {
account: IAccount
}
export default function useOnboardingSuggestions() {
type TruthSuggestion = {
account_avatar: string
account_id: string
acct: string
display_name: string
note: string
verified: boolean
}
type Result = TruthSuggestion | {
account: string
}
type PageParam = {
link?: string
}
const suggestionKeys = {
suggestions: ['suggestions'] as const,
};
const mapSuggestedProfileToAccount = (suggestedProfile: SuggestedProfile) => ({
id: suggestedProfile.account_id,
avatar: suggestedProfile.account_avatar,
avatar_static: suggestedProfile.account_avatar,
acct: suggestedProfile.acct,
display_name: suggestedProfile.display_name,
note: suggestedProfile.note,
verified: suggestedProfile.verified,
});
const useSuggestions = () => {
const api = useApi();
const dispatch = useAppDispatch();
const features = useFeatures();
const getV2Suggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
const endpoint = pageParam?.link || '/api/v2/suggestions';
const response = await api.get<Suggestion[]>(endpoint);
const hasMore = !!response.headers.link;
const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri;
const accounts = response.data.map(({ account }) => account);
const accountIds = accounts.map((account) => account.id);
dispatch(importFetchedAccounts(accounts));
dispatch(fetchRelationships(accountIds));
return {
result: response.data.map(x => ({ ...x, account: x.account.id })),
link: nextLink,
hasMore,
};
};
const getTruthSuggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
const endpoint = pageParam?.link || '/api/v1/truth/carousels/suggestions';
const response = await api.get<TruthSuggestion[]>(endpoint);
const hasMore = !!response.headers.link;
const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri;
const accounts = response.data.map(mapSuggestedProfileToAccount);
dispatch(importFetchedAccounts(accounts, { should_refetch: true }));
return {
result: response.data.map((x) => ({ ...x, account: x.account_id })),
link: nextLink,
hasMore,
};
};
const getSuggestions = (pageParam: PageParam) => {
if (features.truthSuggestions) {
return getTruthSuggestions(pageParam);
} else {
return getV2Suggestions(pageParam);
}
};
const result = useInfiniteQuery(
suggestionKeys.suggestions,
({ pageParam }: any) => getSuggestions(pageParam),
{
keepPreviousData: true,
getNextPageParam: (config) => {
if (config?.hasMore) {
return { nextLink: config?.link };
}
return undefined;
},
});
const data: any = result.data?.pages.reduce<Suggestion[]>(
(prev: any, curr: any) => [...prev, ...curr.result],
[],
);
return {
...result,
data: data || [],
};
};
const useDismissSuggestion = () => {
const api = useApi();
return useMutation((accountId: string) => api.delete(`/api/v1/suggestions/${accountId}`), {
onMutate(accountId: string) {
removePageItem(suggestionKeys.suggestions, accountId, (o: any, n: any) => o.account_id === n);
},
});
};
function useOnboardingSuggestions() {
const api = useApi();
const dispatch = useAppDispatch();
const getV2Suggestions = async(pageParam: any): Promise<{ data: Suggestion[], link: string | undefined, hasMore: boolean }> => {
const getV2Suggestions = async (pageParam: any): Promise<{ data: Suggestion[], link: string | undefined, hasMore: boolean }> => {
const link = pageParam?.link || '/api/v2/suggestions';
const response = await api.get<Suggestion[]>(link);
const hasMore = !!response.headers.link;
@ -55,3 +171,5 @@ export default function useOnboardingSuggestions() {
data,
};
}
export { useOnboardingSuggestions, useSuggestions, useDismissSuggestion };

Wyświetl plik

@ -1,7 +1,9 @@
/** List of supported E164 country codes. */
const COUNTRY_CODES = [
'1',
'351',
'44',
'55',
] as const;
/** Supported E164 country code. */

Wyświetl plik

@ -1,7 +1,12 @@
import { queryClient } from 'soapbox/queries/client';
import type { InfiniteData, QueryKey, UseInfiniteQueryResult } from '@tanstack/react-query';
import type { PaginatedResult } from 'soapbox/queries/chats';
export interface PaginatedResult<T> {
result: T[],
hasMore: boolean,
link?: string,
}
/** Flatten paginated results into a single array. */
const flattenPages = <T>(queryInfo: UseInfiniteQueryResult<PaginatedResult<T>>) => {
@ -35,8 +40,22 @@ const appendPageItem = <T>(queryKey: QueryKey, newItem: T) => {
});
};
/** Remove an item inside if found. */
const removePageItem = <T>(queryKey: QueryKey, itemToRemove: 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.filter(item => !isItem(item, itemToRemove));
return { ...page, result };
});
return { ...data, pages };
}
});
};
export {
flattenPages,
updatePageItem,
appendPageItem,
};
removePageItem,
};