diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index 9df408bcc..1a7516396 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -2,7 +2,7 @@ import { InfiniteData } from '@tanstack/react-query'; import { getSettings } from 'soapbox/actions/settings'; import messages from 'soapbox/locales/messages'; -import { ChatKeys, isLastMessage } from 'soapbox/queries/chats'; +import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats'; import { queryClient } from 'soapbox/queries/client'; import { updatePageItem, appendPageItem, removePageItem, flattenPages, PaginatedResult } from 'soapbox/utils/queries'; import { play, soundCache } from 'soapbox/utils/sounds'; @@ -91,6 +91,21 @@ const removeChatMessage = (payload: string) => { removePageItem(ChatKeys.chatMessages(chatId), chatMessageId, (o: any, n: any) => String(o.id) === String(n)); }; +// Update the specific Chat query data. +const updateChatQuery = (chat: IChat) => { + const cachedChat = queryClient.getQueryData(ChatKeys.chat(chat.id)); + if (!cachedChat) { + return; + } + + const newChat = { + ...cachedChat, + latest_read_message_by_account: chat.latest_read_message_by_account, + latest_read_message_created_at: chat.latest_read_message_created_at, + }; + queryClient.setQueryData(ChatKeys.chat(chat.id), newChat as any); +}; + const connectTimelineStream = ( timelineId: string, path: string, @@ -152,6 +167,9 @@ const connectTimelineStream = ( case 'chat_message.deleted': // TruthSocial removeChatMessage(data.payload); break; + case 'chat_message.read': // TruthSocial + updateChatQuery(JSON.parse(data.payload)); + break; case 'pleroma:follow_relationships_update': dispatch(updateFollowRelationships(JSON.parse(data.payload))); break; diff --git a/app/soapbox/contexts/chat-context.tsx b/app/soapbox/contexts/chat-context.tsx index cc380a280..bffad93e8 100644 --- a/app/soapbox/contexts/chat-context.tsx +++ b/app/soapbox/contexts/chat-context.tsx @@ -3,44 +3,52 @@ import { useDispatch } from 'react-redux'; import { toggleMainWindow } from 'soapbox/actions/chats'; import { useOwnAccount, useSettings } from 'soapbox/hooks'; - -import type { IChat } from 'soapbox/queries/chats'; +import { IChat, useChat } from 'soapbox/queries/chats'; type WindowState = 'open' | 'minimized'; const ChatContext = createContext({ - chat: null, isOpen: false, - isEditing: false, needsAcceptance: false, }); +enum ChatWidgetScreens { + INBOX = 'INBOX', + SEARCH = 'SEARCH', + CHAT = 'CHAT', + CHAT_SETTINGS = 'CHAT_SETTINGS' +} + const ChatProvider: React.FC = ({ children }) => { const dispatch = useDispatch(); const settings = useSettings(); const account = useOwnAccount(); - const [chat, setChat] = useState(null); - const [isEditing, setEditing] = useState(false); - const [isSearching, setSearching] = useState(false); + const [screen, setScreen] = useState(ChatWidgetScreens.INBOX); + const [currentChatId, setCurrentChatId] = useState(null); + + const { data: chat } = useChat(currentChatId as string); const mainWindowState = settings.getIn(['chats', 'mainWindow']) as WindowState; const needsAcceptance = !chat?.accepted && chat?.created_by_account !== account?.id; const isOpen = mainWindowState === 'open'; + const changeScreen = (screen: ChatWidgetScreens, currentChatId?: string | null) => { + setCurrentChatId(currentChatId || null); + setScreen(screen); + }; + const toggleChatPane = () => dispatch(toggleMainWindow()); const value = useMemo(() => ({ chat, - setChat, needsAcceptance, isOpen, - isEditing, - isSearching, - setEditing, - setSearching, toggleChatPane, - }), [chat, needsAcceptance, isOpen, isEditing, isSearching]); + screen, + changeScreen, + currentChatId, + }), [chat, currentChatId, needsAcceptance, isOpen, screen, changeScreen]); return ( @@ -51,16 +59,14 @@ const ChatProvider: React.FC = ({ children }) => { interface IChatContext { chat: IChat | null - isEditing: boolean isOpen: boolean - isSearching: boolean needsAcceptance: boolean - setChat: React.Dispatch> - setEditing: React.Dispatch> - setSearching: React.Dispatch> toggleChatPane(): void + screen: ChatWidgetScreens + currentChatId: string | null + changeScreen(screen: ChatWidgetScreens, currentChatId?: string | null): void } const useChatContext = (): IChatContext => useContext(ChatContext); -export { ChatContext, ChatProvider, useChatContext }; +export { ChatContext, ChatProvider, useChatContext, ChatWidgetScreens }; diff --git a/app/soapbox/features/chats/components/chat-composer.tsx b/app/soapbox/features/chats/components/chat-composer.tsx index 3243e2182..0db5ed158 100644 --- a/app/soapbox/features/chats/components/chat-composer.tsx +++ b/app/soapbox/features/chats/components/chat-composer.tsx @@ -38,6 +38,7 @@ const ChatComposer = React.forwardRef const intl = useIntl(); const { chat } = useChatContext(); + const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocked_by'])); const isBlocking = useAppSelector((state) => state.getIn(['relationships', chat?.account?.id, 'blocking'])); diff --git a/app/soapbox/features/chats/components/chat-list.tsx b/app/soapbox/features/chats/components/chat-list.tsx index 31f40dc26..9de6d5c2d 100644 --- a/app/soapbox/features/chats/components/chat-list.tsx +++ b/app/soapbox/features/chats/components/chat-list.tsx @@ -1,11 +1,10 @@ import classNames from 'clsx'; import React, { useRef, useState } from 'react'; -import { FormattedMessage } from 'react-intl'; import { Virtuoso } from 'react-virtuoso'; import { fetchChats } from 'soapbox/actions/chats'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; -import { Spinner, Stack, Text } from 'soapbox/components/ui'; +import { Spinner, Stack } from 'soapbox/components/ui'; import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder-chat'; import { useAppDispatch } from 'soapbox/hooks'; import { useChats } from 'soapbox/queries/chats'; diff --git a/app/soapbox/features/chats/components/chat-message-list.tsx b/app/soapbox/features/chats/components/chat-message-list.tsx index 635f6c014..674416a31 100644 --- a/app/soapbox/features/chats/components/chat-message-list.tsx +++ b/app/soapbox/features/chats/components/chat-message-list.tsx @@ -73,6 +73,8 @@ const ChatMessageList: React.FC = ({ chat, autosize }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const account = useOwnAccount(); + const lastReadMessageDateString = chat.latest_read_message_by_account.find((latest) => latest.id === chat.account.id)?.date; + const lastReadMessageTimestamp = lastReadMessageDateString ? new Date(lastReadMessageDateString) : null; const node = useRef(null); const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20); @@ -211,15 +213,19 @@ const ChatMessageList: React.FC = ({ chat, autosize }) => { const renderDivider = (key: React.Key, text: string) => ; - const handleCopyText = (chatMessage: IChatMessage) => { + const handleCopyText = (chatMessage: ChatMessageEntity) => { if (navigator.clipboard) { const text = stripHTML(chatMessage.content); navigator.clipboard.writeText(text); } }; - const renderMessage = (chatMessage: any) => { + const renderMessage = (chatMessage: ChatMessageEntity) => { const isMyMessage = chatMessage.account_id === me; + // did this occur before this time? + const isRead = isMyMessage + && lastReadMessageTimestamp + && lastReadMessageTimestamp >= new Date(chatMessage.created_at); const menu: Menu = []; @@ -241,7 +247,7 @@ const ChatMessageList: React.FC = ({ chat, autosize }) => { } else { menu.push({ text: intl.formatMessage(messages.report), - action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage })), + action: () => dispatch(initReport(normalizeAccount(chat.account) as any, { chatMessage } as any)), icon: require('@tabler/icons/flag.svg'), }); menu.push({ @@ -336,7 +342,7 @@ const ChatMessageList: React.FC = ({ chat, autosize }) => { {intl.formatTime(chatMessage.created_at)} - {isMyMessage && !chatMessage.unread ? ( + {isRead ? ( diff --git a/app/soapbox/features/chats/components/chat-page/chat-page.tsx b/app/soapbox/features/chats/components/chat-page/chat-page.tsx index 32a69ee0d..d7502ea54 100644 --- a/app/soapbox/features/chats/components/chat-page/chat-page.tsx +++ b/app/soapbox/features/chats/components/chat-page/chat-page.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { Route, Switch } from 'react-router-dom'; import { Stack } from 'soapbox/components/ui'; -import { useChatContext } from 'soapbox/contexts/chat-context'; import { useOwnAccount } from 'soapbox/hooks'; import { useChat } from 'soapbox/queries/chats'; @@ -21,8 +20,7 @@ const ChatPage: React.FC = ({ chatId }) => { const account = useOwnAccount(); const isOnboarded = account?.chats_onboarded; - const { chat, setChat } = useChatContext(); - const { chat: chatQueryResult } = useChat(chatId); + const { data: chat } = useChat(chatId); const containerRef = useRef(null); const [height, setHeight] = useState('100%'); @@ -41,14 +39,6 @@ const ChatPage: React.FC = ({ chatId }) => { setHeight(fullHeight - top + offset); }; - useEffect(() => { - const data = chatQueryResult?.data; - - if (data) { - setChat(data); - } - }, [chatQueryResult?.isLoading]); - useEffect(() => { calculateHeight(); }, [containerRef.current]); diff --git a/app/soapbox/features/chats/components/chat-page/components/chat-page-main.tsx b/app/soapbox/features/chats/components/chat-page/components/chat-page-main.tsx index cd0c2dc2e..ba897b350 100644 --- a/app/soapbox/features/chats/components/chat-page/components/chat-page-main.tsx +++ b/app/soapbox/features/chats/components/chat-page/components/chat-page-main.tsx @@ -1,14 +1,14 @@ import React, { useRef } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { useHistory, useParams } from 'react-router-dom'; import { blockAccount, unblockAccount } from 'soapbox/actions/accounts'; import { openModal } from 'soapbox/actions/modals'; import List, { ListItem } from 'soapbox/components/list'; import { Avatar, HStack, Icon, IconButton, Menu, MenuButton, MenuItem, MenuList, Stack, Text } from 'soapbox/components/ui'; import VerificationBadge from 'soapbox/components/verification_badge'; -import { useChatContext } from 'soapbox/contexts/chat-context'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; -import { MessageExpirationValues, useChatActions } from 'soapbox/queries/chats'; +import { MessageExpirationValues, useChat, useChatActions } from 'soapbox/queries/chats'; import { secondsToDays } from 'soapbox/utils/numbers'; import Chat from '../../chat'; @@ -41,10 +41,14 @@ const messages = defineMessages({ const ChatPageMain = () => { const dispatch = useAppDispatch(); const intl = useIntl(); + const history = useHistory(); + + const { chatId } = useParams<{ chatId: string }>(); + + const { data: chat } = useChat(chatId); const inputRef = useRef(null); - const { chat, setChat } = useChatContext(); const { deleteChat, updateChat } = useChatActions(chat?.id as string); const handleUpdateChat = (value: MessageExpirationValues) => updateChat.mutate({ message_expiration: value }); @@ -93,7 +97,7 @@ const ChatPageMain = () => { setChat(null)} + onClick={() => history.push('/chats')} /> diff --git a/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx b/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx index 3c11258ff..44c1ea0c2 100644 --- a/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx +++ b/app/soapbox/features/chats/components/chat-page/components/chat-page-new.tsx @@ -4,7 +4,6 @@ import { useHistory } from 'react-router-dom'; import AccountSearch from 'soapbox/components/account_search'; import { CardTitle, HStack, Stack, Text } from 'soapbox/components/ui'; -import { useChatContext } from 'soapbox/contexts/chat-context'; import { ChatKeys, useChats } from 'soapbox/queries/chats'; import { queryClient } from 'soapbox/queries/client'; @@ -14,12 +13,10 @@ interface IChatPageNew { /** New message form to create a chat. */ const ChatPageNew: React.FC = () => { const history = useHistory(); - const { setChat } = useChatContext(); const { getOrCreateChatByAccountId } = useChats(); const handleAccountSelected = async (accountId: string) => { const { data } = await getOrCreateChatByAccountId(accountId); - setChat(data); history.push(`/chats/${data.id}`); queryClient.invalidateQueries(ChatKeys.chatSearch()); }; diff --git a/app/soapbox/features/chats/components/chat-page/components/chat-page-sidebar.tsx b/app/soapbox/features/chats/components/chat-page/components/chat-page-sidebar.tsx index ac5b3c472..c70e03fb0 100644 --- a/app/soapbox/features/chats/components/chat-page/components/chat-page-sidebar.tsx +++ b/app/soapbox/features/chats/components/chat-page/components/chat-page-sidebar.tsx @@ -3,7 +3,6 @@ import { defineMessages, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; import { CardTitle, HStack, IconButton, Stack } from 'soapbox/components/ui'; -import { useChatContext } from 'soapbox/contexts/chat-context'; import { useDebounce, useFeatures } from 'soapbox/hooks'; import { IChat } from 'soapbox/queries/chats'; @@ -20,12 +19,10 @@ const ChatPageSidebar = () => { const features = useFeatures(); const [search, setSearch] = useState(''); - const { setChat } = useChatContext(); const debouncedSearch = useDebounce(search, 300); const handleClickChat = (chat: IChat) => { - setChat(chat); history.push(`/chats/${chat.id}`); }; diff --git a/app/soapbox/features/chats/components/chat-pane/chat-pane.tsx b/app/soapbox/features/chats/components/chat-pane/chat-pane.tsx index 5659f363e..97fd3db0e 100644 --- a/app/soapbox/features/chats/components/chat-pane/chat-pane.tsx +++ b/app/soapbox/features/chats/components/chat-pane/chat-pane.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Stack } from 'soapbox/components/ui'; -import { useChatContext } from 'soapbox/contexts/chat-context'; +import { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context'; import { useStatContext } from 'soapbox/contexts/stat-context'; import { useDebounce, useFeatures } from 'soapbox/hooks'; import { IChat, useChats } from 'soapbox/queries/chats'; @@ -24,13 +24,13 @@ const ChatPane = () => { const [value, setValue] = useState(); const debouncedValue = debounce(value as string, 300); - const { chat, setChat, isOpen, isSearching, setSearching, toggleChatPane } = useChatContext(); + const { screen, changeScreen, isOpen, toggleChatPane } = useChatContext(); const { chatsQuery: { data: chats, isLoading } } = useChats(debouncedValue); const hasSearchValue = Number(debouncedValue?.length) > 0; const handleClickChat = (nextChat: IChat) => { - setChat(nextChat); + changeScreen(ChatWidgetScreens.CHAT, nextChat.id); setValue(undefined); }; @@ -66,13 +66,17 @@ const ChatPane = () => { ); } else if (chats?.length === 0) { return ( - setSearching(true)} /> + { + changeScreen(ChatWidgetScreens.SEARCH); + }} + /> ); } }; // Active chat - if (chat?.id) { + if (screen === ChatWidgetScreens.CHAT || screen === ChatWidgetScreens.CHAT_SETTINGS) { return ( @@ -80,7 +84,7 @@ const ChatPane = () => { ); } - if (isSearching) { + if (screen === ChatWidgetScreens.SEARCH) { return ; } @@ -92,7 +96,7 @@ const ChatPane = () => { isOpen={isOpen} onToggle={toggleChatPane} secondaryAction={() => { - setSearching(true); + changeScreen(ChatWidgetScreens.SEARCH); setValue(undefined); if (!isOpen) { diff --git a/app/soapbox/features/chats/components/chat-search/chat-search.tsx b/app/soapbox/features/chats/components/chat-search/chat-search.tsx index 94135ae62..43fa95361 100644 --- a/app/soapbox/features/chats/components/chat-search/chat-search.tsx +++ b/app/soapbox/features/chats/components/chat-search/chat-search.tsx @@ -5,7 +5,7 @@ import { defineMessages, useIntl } from 'react-intl'; import snackbar from 'soapbox/actions/snackbar'; import { HStack, Icon, Input, Stack, Text } from 'soapbox/components/ui'; -import { useChatContext } from 'soapbox/contexts/chat-context'; +import { ChatWidgetScreens, useChatContext } from 'soapbox/contexts/chat-context'; import { useAppDispatch, useDebounce } from 'soapbox/hooks'; import { useChats } from 'soapbox/queries/chats'; import { queryClient } from 'soapbox/queries/client'; @@ -28,7 +28,7 @@ const ChatSearch = () => { const dispatch = useAppDispatch(); const intl = useIntl(); - const { isOpen, setChat, setSearching, toggleChatPane } = useChatContext(); + const { isOpen, changeScreen, toggleChatPane } = useChatContext(); const { getOrCreateChatByAccountId } = useChats(); const [value, setValue] = useState(); @@ -47,7 +47,7 @@ const ChatSearch = () => { dispatch(snackbar.error(data?.error)); }, onSuccess: (response) => { - setChat(response.data); + changeScreen(ChatWidgetScreens.CHAT, response.data.id); queryClient.invalidateQueries(ChatKeys.chatSearch()); }, }); @@ -82,7 +82,11 @@ const ChatSearch = () => { data-testid='pane-header' title={ -