d
This commit is contained in:
@ -5,6 +5,8 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
import AutosuggestAccountInput from 'soapbox/components/autosuggest_account_input';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
|
||||
import SvgIcon from './ui/icon/svg-icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'account_search.placeholder', defaultMessage: 'Search for an account' },
|
||||
});
|
||||
@ -16,10 +18,14 @@ interface IAccountSearch {
|
||||
placeholder?: string,
|
||||
/** Position of results relative to the input. */
|
||||
resultsPosition?: 'above' | 'below',
|
||||
/** Optional class for the input */
|
||||
className?: string,
|
||||
autoFocus?: boolean,
|
||||
hidePortal?: boolean,
|
||||
}
|
||||
|
||||
/** Input to search for accounts. */
|
||||
const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
|
||||
const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, className, ...rest }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
@ -56,11 +62,12 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='search search--account'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
|
||||
<div className='w-full'>
|
||||
<label className='sr-only'>{intl.formatMessage(messages.placeholder)}</label>
|
||||
|
||||
<div className='relative'>
|
||||
<AutosuggestAccountInput
|
||||
className='rounded-full'
|
||||
className={classNames('rounded-full', className)}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
@ -68,10 +75,24 @@ const AccountSearch: React.FC<IAccountSearch> = ({ onSelected, ...rest }) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
</label>
|
||||
<div role='button' tabIndex={0} className='search__icon' onClick={handleClear}>
|
||||
<Icon src={require('@tabler/icons/search.svg')} className={classNames('svg-icon--search', { active: isEmpty() })} />
|
||||
<Icon src={require('@tabler/icons/backspace.svg')} className={classNames('svg-icon--backspace', { active: !isEmpty() })} aria-label={intl.formatMessage(messages.placeholder)} />
|
||||
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='absolute inset-y-0 right-0 px-3 flex items-center cursor-pointer'
|
||||
onClick={handleClear}
|
||||
>
|
||||
<SvgIcon
|
||||
src={require('@tabler/icons/search.svg')}
|
||||
className={classNames('h-4 w-4 text-gray-400', { hidden: !isEmpty() })}
|
||||
/>
|
||||
|
||||
<SvgIcon
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
className={classNames('h-4 w-4 text-gray-400', { hidden: isEmpty() })}
|
||||
aria-label={intl.formatMessage(messages.placeholder)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -8,11 +8,12 @@ import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown_menu';
|
||||
|
||||
const noOp = () => {};
|
||||
const noOp = () => { };
|
||||
|
||||
interface IAutosuggestAccountInput {
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>,
|
||||
onSelected: (accountId: string) => void,
|
||||
autoFocus?: boolean,
|
||||
value: string,
|
||||
limit?: number,
|
||||
className?: string,
|
||||
|
||||
@ -59,6 +59,7 @@ interface IAutosuggestInput extends Pick<React.HTMLAttributes<HTMLInputElement>,
|
||||
maxLength?: number,
|
||||
menu?: Menu,
|
||||
resultsPosition: string,
|
||||
hidePortal?: boolean,
|
||||
}
|
||||
|
||||
export default class AutosuggestInput extends ImmutablePureComponent<IAutosuggestInput> {
|
||||
@ -284,11 +285,11 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
||||
}
|
||||
|
||||
render() {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu } = this.props;
|
||||
const { hidePortal, value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style: React.CSSProperties = { direction: 'ltr' };
|
||||
|
||||
const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
|
||||
const visible = !hidePortal && !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
|
||||
|
||||
if (isRtl(value)) {
|
||||
style.direction = 'rtl';
|
||||
|
||||
@ -58,7 +58,7 @@ const ChatRoom: React.FC<IChatRoom> = ({ params }) => {
|
||||
return (
|
||||
<Column label={`@${getAcct(chat.account as any, displayFqn)}`}>
|
||||
<ChatBox
|
||||
chatId={chat.id}
|
||||
chat={chat as any}
|
||||
onSetInputRef={handleInputRef}
|
||||
autosize
|
||||
/>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
@ -7,23 +8,25 @@ import {
|
||||
markChatRead,
|
||||
} from 'soapbox/actions/chats';
|
||||
import { uploadMedia } from 'soapbox/actions/media';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { HStack, Icon, IconButton, Input, Stack, Textarea } from 'soapbox/components/ui';
|
||||
import UploadProgress from 'soapbox/components/upload-progress';
|
||||
import UploadButton from 'soapbox/features/compose/components/upload_button';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
||||
import { IChat, useChat } from 'soapbox/queries/chats';
|
||||
import { queryClient } from 'soapbox/queries/client';
|
||||
import { truncateFilename } from 'soapbox/utils/media';
|
||||
|
||||
import ChatMessageList from './chat-message-list';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'chat_box.input.placeholder', defaultMessage: 'Send a message…' },
|
||||
placeholder: { id: 'chat_box.input.placeholder', defaultMessage: 'Type a message' },
|
||||
send: { id: 'chat_box.actions.send', defaultMessage: 'Send' },
|
||||
});
|
||||
|
||||
const fileKeyGen = (): number => Math.floor((Math.random() * 0x10000));
|
||||
|
||||
interface IChatBox {
|
||||
chatId: string,
|
||||
chat: IChat,
|
||||
onSetInputRef: (el: HTMLTextAreaElement) => void,
|
||||
autosize?: boolean,
|
||||
}
|
||||
@ -32,12 +35,15 @@ interface IChatBox {
|
||||
* Chat UI with just the messages and textarea.
|
||||
* Reused between floating desktop chats and fullscreen/mobile chats.
|
||||
*/
|
||||
const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef, autosize }) => {
|
||||
const ChatBox: React.FC<IChatBox> = ({ chat, onSetInputRef, autosize }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const chatMessageIds = useAppSelector(state => state.chat_message_lists.get(chatId, ImmutableOrderedSet<string>()));
|
||||
const chatMessageIds = useAppSelector(state => state.chat_message_lists.get(chat.id, ImmutableOrderedSet<string>()));
|
||||
const account = useOwnAccount();
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const { createChatMessage, markChatAsRead } = useChat(chat.id);
|
||||
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [attachment, setAttachment] = useState<any>(undefined);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
@ -45,6 +51,61 @@ const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef, autosize }) => {
|
||||
|
||||
const inputElem = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// TODO: needs last_read_id param
|
||||
const markAsRead = useMutation(() => markChatAsRead(), {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['chats']);
|
||||
},
|
||||
});
|
||||
|
||||
const submitMessage = useMutation(({ chatId, content }: any) => {
|
||||
return createChatMessage(chatId, content);
|
||||
}, {
|
||||
onMutate: async (newMessage) => {
|
||||
clearState();
|
||||
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries(['chats', 'messages', chat.id]);
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousChatMessages = queryClient.getQueryData(['chats', 'messages', chat.id]);
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData(['chats', 'messages', chat.id], (prevResult: any) => {
|
||||
const newResult = prevResult;
|
||||
newResult.pages = prevResult.pages.map((page: any, idx: number) => {
|
||||
if (idx === 0) {
|
||||
return {
|
||||
...page,
|
||||
result: [...page.result, {
|
||||
...newMessage,
|
||||
id: String(Number(new Date())),
|
||||
created_at: new Date(),
|
||||
account_id: account?.id,
|
||||
pending: true,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
return page;
|
||||
});
|
||||
|
||||
return newResult;
|
||||
});
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { previousChatMessages };
|
||||
},
|
||||
// If the mutation fails, use the context returned from onMutate to roll back
|
||||
onError: (err, newTodo, context: any) => {
|
||||
queryClient.setQueryData(['chats', 'messages', chat.id], context.previousChatMessages);
|
||||
},
|
||||
// Always refetch after error or success:
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(['chats', 'messages', chat.id]);
|
||||
},
|
||||
});
|
||||
|
||||
const clearState = () => {
|
||||
setContent('');
|
||||
setAttachment(undefined);
|
||||
@ -53,13 +114,6 @@ const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef, autosize }) => {
|
||||
setResetFileKey(fileKeyGen());
|
||||
};
|
||||
|
||||
const getParams = () => {
|
||||
return {
|
||||
content,
|
||||
media_id: attachment && attachment.id,
|
||||
};
|
||||
};
|
||||
|
||||
const canSubmit = () => {
|
||||
const conds = [
|
||||
content.length > 0,
|
||||
@ -70,31 +124,32 @@ const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef, autosize }) => {
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
if (canSubmit() && !isUploading) {
|
||||
const params = getParams();
|
||||
if (canSubmit() && !submitMessage.isLoading) {
|
||||
const params = {
|
||||
content,
|
||||
media_id: attachment && attachment.id,
|
||||
};
|
||||
|
||||
dispatch(sendChatMessage(chatId, params));
|
||||
clearState();
|
||||
submitMessage.mutate({ chatId: chat.id, content });
|
||||
}
|
||||
};
|
||||
|
||||
const insertLine = () => {
|
||||
setContent(content + '\n');
|
||||
};
|
||||
const insertLine = () => setContent(content + '\n');
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler = (e) => {
|
||||
const handleKeyDown: React.KeyboardEventHandler = (event) => {
|
||||
markRead();
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
|
||||
if (event.key === 'Enter' && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
insertLine();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Enter') {
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
setContent(e.target.value);
|
||||
const handleContentChange: React.ChangeEventHandler<HTMLTextAreaElement> = (event) => {
|
||||
setContent(event.target.value);
|
||||
};
|
||||
|
||||
const handlePaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
@ -104,17 +159,11 @@ const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef, autosize }) => {
|
||||
};
|
||||
|
||||
const markRead = () => {
|
||||
dispatch(markChatRead(chatId));
|
||||
// markAsRead.mutate();
|
||||
// dispatch(markChatRead(chatId));
|
||||
};
|
||||
|
||||
const handleHover = () => {
|
||||
markRead();
|
||||
};
|
||||
|
||||
const setInputRef = (el: HTMLTextAreaElement) => {
|
||||
inputElem.current = el;
|
||||
onSetInputRef(el);
|
||||
};
|
||||
const handleMouseOver = () => markRead();
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setAttachment(undefined);
|
||||
@ -173,27 +222,52 @@ const ChatBox: React.FC<IChatBox> = ({ chatId, onSetInputRef, autosize }) => {
|
||||
if (!chatMessageIds) return null;
|
||||
|
||||
return (
|
||||
<div className='chat-box' onMouseOver={handleHover}>
|
||||
<ChatMessageList chatMessageIds={chatMessageIds} chatId={chatId} autosize />
|
||||
{renderAttachment()}
|
||||
{isUploading && (
|
||||
<UploadProgress progress={uploadProgress * 100} />
|
||||
)}
|
||||
<div className='chat-box__actions simple_form'>
|
||||
<div className='chat-box__send'>
|
||||
{renderActionButton()}
|
||||
</div>
|
||||
<textarea
|
||||
rows={1}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleContentChange}
|
||||
onPaste={handlePaste}
|
||||
value={content}
|
||||
ref={setInputRef}
|
||||
/>
|
||||
<Stack className='overflow-hidden flex flex-grow' onMouseOver={handleMouseOver}>
|
||||
<div className='flex-grow h-full overflow-hidden'>
|
||||
<ChatMessageList chatMessageIds={chatMessageIds} chat={chat} autosize />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-auto p-4 shadow-3xl'>
|
||||
<HStack alignItems='center' justifyContent='between' space={4}>
|
||||
<div className='flex-grow'>
|
||||
<Textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
isResizeable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
src={require('@tabler/icons/send.svg')}
|
||||
iconClassName='w-5 h-5'
|
||||
className='text-primary-500'
|
||||
onClick={sendMessage}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</Stack>
|
||||
// {renderAttachment()}
|
||||
// {isUploading && (
|
||||
// <UploadProgress progress={uploadProgress * 100} />
|
||||
// )}
|
||||
// <div className='chat-box__actions simple_form'>
|
||||
// <div className='chat-box__send'>
|
||||
// {renderActionButton()}
|
||||
// </div>
|
||||
// <textarea
|
||||
// rows={1}
|
||||
//
|
||||
//
|
||||
// onPaste={handlePaste}
|
||||
// value={content}
|
||||
// ref={setInputRef}
|
||||
// />
|
||||
// </div>
|
||||
// </div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -13,12 +13,13 @@ import { createSelector } from 'reselect';
|
||||
import { fetchChatMessages, deleteChatMessage } from 'soapbox/actions/chats';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { initReportById } from 'soapbox/actions/reports';
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { Avatar, HStack, IconButton, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container';
|
||||
import emojify from 'soapbox/features/emoji/emoji';
|
||||
import Bundle from 'soapbox/features/ui/components/bundle';
|
||||
import { MediaGallery } from 'soapbox/features/ui/util/async-components';
|
||||
import { useAppSelector, useAppDispatch, useRefEventHandler } from 'soapbox/hooks';
|
||||
import { useAppSelector, useAppDispatch, useRefEventHandler, useOwnAccount } from 'soapbox/hooks';
|
||||
import { IChat, IChatMessage, useChatMessages } from 'soapbox/queries/chats';
|
||||
import { onlyEmoji } from 'soapbox/utils/rich_content';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown_menu';
|
||||
@ -35,10 +36,10 @@ const messages = defineMessages({
|
||||
|
||||
type TimeFormat = 'today' | 'date';
|
||||
|
||||
const timeChange = (prev: ChatMessageEntity, curr: ChatMessageEntity): TimeFormat | null => {
|
||||
const timeChange = (prev: IChatMessage, curr: IChatMessage): TimeFormat | null => {
|
||||
const prevDate = new Date(prev.created_at).getDate();
|
||||
const currDate = new Date(curr.created_at).getDate();
|
||||
const nowDate = new Date().getDate();
|
||||
const nowDate = new Date().getDate();
|
||||
|
||||
if (prevDate !== currDate) {
|
||||
return currDate === nowDate ? 'today' : 'date';
|
||||
@ -47,9 +48,9 @@ const timeChange = (prev: ChatMessageEntity, curr: ChatMessageEntity): TimeForma
|
||||
return null;
|
||||
};
|
||||
|
||||
const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
|
||||
return map.set(`:${emoji.get('shortcode')}:`, emoji);
|
||||
}, ImmutableMap());
|
||||
// const makeEmojiMap = (record: any) => record.get('emojis', ImmutableList()).reduce((map: ImmutableMap<string, any>, emoji: ImmutableMap<string, any>) => {
|
||||
// return map.set(`:${emoji.get('shortcode')}:`, emoji);
|
||||
// }, ImmutableMap());
|
||||
|
||||
const getChatMessages = createSelector(
|
||||
[(chatMessages: ImmutableMap<string, ChatMessageEntity>, chatMessageIds: ImmutableOrderedSet<string>) => (
|
||||
@ -63,7 +64,7 @@ const getChatMessages = createSelector(
|
||||
|
||||
interface IChatMessageList {
|
||||
/** Chat the messages are being rendered from. */
|
||||
chatId: string,
|
||||
chat: IChat,
|
||||
/** Message IDs to render. */
|
||||
chatMessageIds: ImmutableOrderedSet<string>,
|
||||
/** Whether to make the chatbox fill the height of the screen. */
|
||||
@ -71,22 +72,27 @@ interface IChatMessageList {
|
||||
}
|
||||
|
||||
/** Scrollable list of chat messages. */
|
||||
const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, autosize }) => {
|
||||
const ChatMessageList: React.FC<IChatMessageList> = ({ chat, chatMessageIds, autosize }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const me = useAppSelector(state => state.me);
|
||||
const chatMessages = useAppSelector(state => getChatMessages(state.chat_messages, chatMessageIds));
|
||||
const account = useOwnAccount();
|
||||
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
|
||||
const { data: chatMessages, isFetching, isFetched, fetchNextPage, isFetchingNextPage, isPlaceholderData } = useChatMessages(chat.id);
|
||||
const formattedChatMessages = chatMessages || [];
|
||||
|
||||
|
||||
const me = useAppSelector(state => state.me);
|
||||
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const messagesEnd = useRef<HTMLDivElement>(null);
|
||||
const lastComputedScroll = useRef<number | undefined>(undefined);
|
||||
const scrollBottom = useRef<number | undefined>(undefined);
|
||||
|
||||
const initialCount = useMemo(() => chatMessages.count(), []);
|
||||
const initialCount = useMemo(() => formattedChatMessages.length, []);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEnd.current?.scrollIntoView(false);
|
||||
@ -95,13 +101,13 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
|
||||
const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => {
|
||||
return intl.formatDate(
|
||||
new Date(chatMessage.created_at), {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
},
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@ -130,38 +136,46 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
|
||||
return scrollBottom < elem.offsetHeight * 1.5;
|
||||
};
|
||||
|
||||
const handleResize = throttle(() => {
|
||||
if (isNearBottom()) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, 150);
|
||||
|
||||
const restoreScrollPosition = () => {
|
||||
if (node.current && scrollBottom.current) {
|
||||
console.log('bottom', scrollBottom.current);
|
||||
|
||||
lastComputedScroll.current = node.current.scrollHeight - scrollBottom.current;
|
||||
node.current.scrollTop = lastComputedScroll.current;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
const maxId = chatMessages.getIn([0, 'id']) as string;
|
||||
dispatch(fetchChatMessages(chatId, maxId as any));
|
||||
setIsLoading(true);
|
||||
// const maxId = chatMessages.getIn([0, 'id']) as string;
|
||||
// dispatch(fetchChatMessages(chat.id, maxId as any));
|
||||
// setIsLoading(true);
|
||||
if (!isFetching) {
|
||||
// setMaxId(formattedChatMessages[0].id);
|
||||
fetchNextPage()
|
||||
.then(() => {
|
||||
if (node.current) {
|
||||
setScrollPosition(node.current.scrollHeight - node.current.scrollTop);
|
||||
}
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = useRefEventHandler(throttle(() => {
|
||||
const handleScroll = throttle(() => {
|
||||
if (node.current) {
|
||||
const { scrollTop, offsetHeight } = node.current;
|
||||
const computedScroll = lastComputedScroll.current === scrollTop;
|
||||
const nearTop = scrollTop < offsetHeight * 2;
|
||||
const nearTop = scrollTop < offsetHeight;
|
||||
|
||||
if (nearTop && !isLoading && !initialLoad && !computedScroll) {
|
||||
setScrollPosition(node.current.scrollHeight - node.current.scrollTop);
|
||||
|
||||
if (nearTop && !isFetching && !initialLoad && !computedScroll) {
|
||||
handleLoadMore();
|
||||
}
|
||||
}
|
||||
}, 150, {
|
||||
trailing: true,
|
||||
}));
|
||||
});
|
||||
|
||||
const onOpenMedia = (media: any, index: number) => {
|
||||
dispatch(openModal('MEDIA', { media, index }));
|
||||
@ -194,12 +208,20 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
|
||||
const pending = chatMessage.pending;
|
||||
const deleting = chatMessage.deleting;
|
||||
const formatted = (pending && !deleting) ? parsePendingContent(content) : content;
|
||||
const emojiMap = makeEmojiMap(chatMessage);
|
||||
return emojify(formatted, emojiMap.toJS());
|
||||
return formatted;
|
||||
// const emojiMap = makeEmojiMap(chatMessage);
|
||||
// return emojify(formatted, emojiMap.toJS());
|
||||
};
|
||||
|
||||
const renderDivider = (key: React.Key, text: string) => (
|
||||
<div className='chat-messages__divider' key={key}>{text}</div>
|
||||
<div className='relative' key={key}>
|
||||
<div className='absolute inset-0 flex items-center' aria-hidden='true'>
|
||||
<div className='w-full border-solid border-t border-gray-300' />
|
||||
</div>
|
||||
<div className='relative flex justify-center'>
|
||||
<Text theme='muted' size='xs' className='px-2 bg-white' tag='span'>{text}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleDeleteMessage = (chatId: string, messageId: string) => {
|
||||
@ -214,7 +236,9 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
|
||||
};
|
||||
};
|
||||
|
||||
const renderMessage = (chatMessage: ChatMessageEntity) => {
|
||||
const renderMessage = (chatMessage: any) => {
|
||||
const isMyMessage = chatMessage.account_id === me;
|
||||
|
||||
const menu: Menu = [
|
||||
{
|
||||
text: intl.formatMessage(messages.delete),
|
||||
@ -233,45 +257,97 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('chat-message', {
|
||||
'chat-message--me': chatMessage.account_id === me,
|
||||
'chat-message--pending': chatMessage.pending,
|
||||
})}
|
||||
<Stack
|
||||
key={chatMessage.id}
|
||||
space={1}
|
||||
className={classNames({
|
||||
'group max-w-[85%]': true,
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
title={getFormattedTimestamp(chatMessage)}
|
||||
className='chat-message__bubble'
|
||||
ref={setBubbleRef}
|
||||
tabIndex={0}
|
||||
<HStack
|
||||
alignItems='center'
|
||||
justifyContent={isMyMessage ? 'end' : 'start'}
|
||||
className={classNames({
|
||||
'opacity-50': chatMessage.pending,
|
||||
})}
|
||||
>
|
||||
{maybeRenderMedia(chatMessage)}
|
||||
<Text size='sm' dangerouslySetInnerHTML={{ __html: parseContent(chatMessage) }} />
|
||||
<div className='chat-message__menu'>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
{isMyMessage ? (
|
||||
// <IconButton
|
||||
// src={require('@tabler/icons/dots.svg')}
|
||||
// className='hidden group-hover:block text-gray-600 mr-2'
|
||||
// iconClassName='w-5 h-5'
|
||||
// />
|
||||
<div className='hidden group-hover:block mr-2 text-gray-500'>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
) : null}
|
||||
|
||||
<div
|
||||
title={getFormattedTimestamp(chatMessage)}
|
||||
className={
|
||||
classNames({
|
||||
'text-ellipsis break-words relative rounded-md p-2': true,
|
||||
'bg-primary-500 text-white mr-2': isMyMessage,
|
||||
'bg-gray-200 text-gray-900 order-2 ml-2': !isMyMessage,
|
||||
})
|
||||
}
|
||||
ref={setBubbleRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
{maybeRenderMedia(chatMessage)}
|
||||
<Text size='sm' theme='inherit' dangerouslySetInnerHTML={{ __html: parseContent(chatMessage) }} />
|
||||
<div className='chat-message__menu'>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
src={require('@tabler/icons/dots.svg')}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classNames({ 'order-1': !isMyMessage })}>
|
||||
<Avatar src={isMyMessage ? account?.avatar : chat.account.avatar} size={34} />
|
||||
</div>
|
||||
</HStack>
|
||||
|
||||
<HStack
|
||||
alignItems='center'
|
||||
space={2}
|
||||
className={classNames({
|
||||
'ml-auto': isMyMessage,
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
theme='muted'
|
||||
size='xs'
|
||||
className={classNames({
|
||||
'text-right': isMyMessage,
|
||||
'order-2': !isMyMessage,
|
||||
})}
|
||||
>
|
||||
{intl.formatTime(chatMessage.created_at)}
|
||||
</Text>
|
||||
|
||||
<div className={classNames({ 'order-1': !isMyMessage })}>
|
||||
<div className='w-[34px]' />
|
||||
</div>
|
||||
</HStack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchChatMessages(chatId));
|
||||
|
||||
node.current?.addEventListener('scroll', e => handleScroll.current(e));
|
||||
window.addEventListener('resize', handleResize);
|
||||
scrollToBottom();
|
||||
|
||||
return () => {
|
||||
node.current?.removeEventListener('scroll', e => handleScroll.current(e));
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
if (isFetched) {
|
||||
setInitialLoad(false);
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [isFetched]);
|
||||
|
||||
// Store the scroll position.
|
||||
useLayoutEffect(() => {
|
||||
@ -282,35 +358,49 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
|
||||
});
|
||||
|
||||
// Stick scrollbar to bottom.
|
||||
useEffect(() => {
|
||||
if (isNearBottom()) {
|
||||
scrollToBottom();
|
||||
}
|
||||
// useEffect(() => {
|
||||
// if (isNearBottom()) {
|
||||
// scrollToBottom();
|
||||
// }
|
||||
|
||||
// First load.
|
||||
if (chatMessages.count() !== initialCount) {
|
||||
setInitialLoad(false);
|
||||
setIsLoading(false);
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [chatMessages.count()]);
|
||||
// // First load.
|
||||
// // if (chatMessages.count() !== initialCount) {
|
||||
// // setInitialLoad(false);
|
||||
// // setIsLoading(false);
|
||||
// // scrollToBottom();
|
||||
// // }
|
||||
// }, [formattedChatMessages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messagesEnd.current]);
|
||||
// useEffect(() => {
|
||||
// scrollToBottom();
|
||||
// }, [messagesEnd.current]);
|
||||
|
||||
// History added.
|
||||
const lastChatId = Number(chatMessages && chatMessages[0]?.id);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Restore scroll bar position when loading old messages.
|
||||
console.log('hii');
|
||||
if (!initialLoad) {
|
||||
|
||||
restoreScrollPosition();
|
||||
}
|
||||
}, [chatMessageIds.first()]);
|
||||
}, [formattedChatMessages.length, initialLoad]);
|
||||
|
||||
|
||||
if (isPlaceholderData) {
|
||||
return (
|
||||
<Stack alignItems='center' justifyContent='center' className='h-full flex-grow'>
|
||||
<Spinner withText={false} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='chat-messages' style={{ height: autosize ? 'calc(100vh - 16rem)' : undefined }} ref={node}>
|
||||
{chatMessages.reduce((acc, curr, idx) => {
|
||||
const lastMessage = chatMessages.get(idx - 1);
|
||||
<div className='h-full flex flex-col space-y-4 px-4 flex-grow overflow-y-scroll' onScroll={handleScroll} ref={node}> {/* style={{ height: autosize ? 'calc(100vh - 16rem)' : undefined }} */}
|
||||
{formattedChatMessages.reduce((acc: any, curr: any, idx: number) => {
|
||||
const lastMessage = formattedChatMessages[idx - 1];
|
||||
|
||||
if (lastMessage) {
|
||||
const key = `${curr.id}_divider`;
|
||||
@ -319,7 +409,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chatId, chatMessageIds, a
|
||||
acc.push(renderDivider(key, intl.formatMessage(messages.today)));
|
||||
break;
|
||||
case 'date':
|
||||
acc.push(renderDivider(key, new Date(curr.created_at).toDateString()));
|
||||
acc.push(renderDivider(key, intl.formatDate(new Date(curr.created_at), { weekday: 'short', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' })));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
54
app/soapbox/features/chats/components/chat-pane-header.tsx
Normal file
54
app/soapbox/features/chats/components/chat-pane-header.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React, { HTMLAttributes } from 'react';
|
||||
|
||||
import { HStack, IconButton, Text } from 'soapbox/components/ui';
|
||||
|
||||
interface IChatPaneHeader {
|
||||
isOpen: boolean
|
||||
isToggleable?: boolean
|
||||
onToggle(): void
|
||||
title: string | React.ReactNode
|
||||
unreadCount?: number
|
||||
}
|
||||
|
||||
const ChatPaneHeader = (props: IChatPaneHeader) => {
|
||||
const { onToggle, isOpen, isToggleable = true, title, unreadCount } = props;
|
||||
|
||||
const ButtonComp = isToggleable ? 'button' : 'div';
|
||||
const buttonProps: HTMLAttributes<HTMLButtonElement | HTMLDivElement> = {};
|
||||
if (isToggleable) {
|
||||
buttonProps.onClick = onToggle;
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack alignItems='center' justifyContent='between' className='rounded-t-xl h-16 py-3 px-4'>
|
||||
<ButtonComp
|
||||
className='flex-grow flex items-center flex-row space-x-1 h-16'
|
||||
{...buttonProps}
|
||||
>
|
||||
{typeof title === 'string' ? (
|
||||
<Text weight='semibold'>
|
||||
{title}
|
||||
</Text>
|
||||
) : (title)}
|
||||
|
||||
{(typeof unreadCount !== 'undefined' && unreadCount > 0) && (
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Text weight='semibold'>
|
||||
({unreadCount})
|
||||
</Text>
|
||||
|
||||
<div className='bg-accent-300 w-2.5 h-2.5 rounded-full' />
|
||||
</HStack>
|
||||
)}
|
||||
</ButtonComp>
|
||||
|
||||
<IconButton
|
||||
onClick={onToggle}
|
||||
src={isOpen ? require('@tabler/icons/chevron-down.svg') : require('@tabler/icons/chevron-up.svg')}
|
||||
iconClassName='w-5 h-5 text-gray-600'
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPaneHeader;
|
||||
@ -1,24 +1,33 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
import sumBy from 'lodash/sumBy';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { openChat, launchChat, toggleMainWindow } from 'soapbox/actions/chats';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import snackbar from 'soapbox/actions/snackbar';
|
||||
import AccountSearch from 'soapbox/components/account_search';
|
||||
import { Counter } from 'soapbox/components/ui';
|
||||
import { Avatar, Button, Counter, HStack, Icon, IconButton, Input, Spinner, Stack, Text } from 'soapbox/components/ui';
|
||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
||||
import AudioToggle from 'soapbox/features/chats/components/audio-toggle';
|
||||
import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks';
|
||||
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account';
|
||||
import { useAppDispatch, useAppSelector, useDebounce, useSettings } from 'soapbox/hooks';
|
||||
import { IChat, useChats } from 'soapbox/queries/chats';
|
||||
import useAccountSearch from 'soapbox/queries/search';
|
||||
import { RootState } from 'soapbox/store';
|
||||
import { Chat } from 'soapbox/types/entities';
|
||||
|
||||
import ChatList from './chat-list';
|
||||
import ChatPaneHeader from './chat-pane-header';
|
||||
import ChatWindow from './chat-window';
|
||||
import { Pane, WindowState } from './ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' },
|
||||
searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Type a name' },
|
||||
});
|
||||
|
||||
const getChatsUnreadCount = (state: RootState) => {
|
||||
@ -41,67 +50,162 @@ const normalizeChatPanes = makeNormalizeChatPanes();
|
||||
const ChatPanes = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const debounce = useDebounce;
|
||||
|
||||
const [chat, setChat] = useState<IChat | null>();
|
||||
const [value, setValue] = useState<string>();
|
||||
const debouncedValue = debounce(value as string, 300);
|
||||
|
||||
const { chatsQuery: { data: chats, isFetching }, getOrCreateChatByAccountId } = useChats();
|
||||
const { data: accounts } = useAccountSearch(debouncedValue);
|
||||
|
||||
const panes = useAppSelector((state) => normalizeChatPanes(state));
|
||||
const mainWindowState = useSettings().getIn(['chats', 'mainWindow']) as WindowState;
|
||||
const unreadCount = useAppSelector((state) => getChatsUnreadCount(state));
|
||||
const unreadCount = sumBy(chats, (chat) => chat.unread);
|
||||
|
||||
const handleClickChat = ((chat: Chat) => {
|
||||
dispatch(openChat(chat.id));
|
||||
const open = mainWindowState === 'open';
|
||||
const isSearching = accounts && accounts.length > 0;
|
||||
const hasSearchValue = value && value.length > 0;
|
||||
|
||||
const handleClickOnSearchResult = useMutation((accountId: string) => {
|
||||
return getOrCreateChatByAccountId(accountId);
|
||||
}, {
|
||||
onError: (error: AxiosError) => {
|
||||
const data = error.response?.data as any;
|
||||
dispatch(snackbar.error(data?.error));
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
setChat(response.data);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSuggestion = (accountId: string) => {
|
||||
dispatch(launchChat(accountId, history));
|
||||
|
||||
const clearValue = () => {
|
||||
if (hasSearchValue) {
|
||||
setValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMainWindowToggle = () => {
|
||||
if (mainWindowState === 'open') {
|
||||
setChat(null);
|
||||
}
|
||||
dispatch(toggleMainWindow());
|
||||
};
|
||||
|
||||
const open = mainWindowState === 'open';
|
||||
const renderBody = () => {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className='flex flex-grow h-full items-center justify-center'>
|
||||
<Spinner withText={false} />
|
||||
</div>
|
||||
);
|
||||
} else if (isSearching) {
|
||||
return (
|
||||
<Stack className='overflow-y-scroll flex-grow h-full' space={2}>
|
||||
{accounts.map((account: any) => (
|
||||
<button key={account.id} type='button' className='px-4 py-2 w-full flex flex-col hover:bg-gray-100' onClick={() => handleClickOnSearchResult.mutate(account.id)}>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Avatar src={account.avatar} size={40} />
|
||||
|
||||
const mainWindowPane = (
|
||||
<Pane windowState={mainWindowState} index={0} main>
|
||||
<div className='pane__header'>
|
||||
{unreadCount > 0 && (
|
||||
<div className='mr-2 flex-none'>
|
||||
<Counter count={unreadCount} />
|
||||
</div>
|
||||
)}
|
||||
<button className='pane__title' onClick={handleMainWindowToggle}>
|
||||
<FormattedMessage id='chat_panels.main_window.title' defaultMessage='Chats' />
|
||||
</button>
|
||||
<AudioToggle />
|
||||
</div>
|
||||
<div className='pane__content'>
|
||||
{open && (
|
||||
<>
|
||||
<ChatList
|
||||
onClickChat={handleClickChat}
|
||||
/>
|
||||
<AccountSearch
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
onSelected={handleSuggestion}
|
||||
resultsPosition='above'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Pane>
|
||||
);
|
||||
<Stack alignItems='start'>
|
||||
<div className='flex items-center space-x-1 flex-grow'>
|
||||
<Text weight='semibold' truncate>{account.display_name}</Text>
|
||||
{account.verified && <VerificationBadge />}
|
||||
</div>
|
||||
<Text theme='muted' truncate>{account.acct}</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
</button>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
} else if (chats && chats.length > 0) {
|
||||
return (
|
||||
<Stack className='overflow-y-scroll flex-grow h-full' space={2}>
|
||||
{chats.map((chat) => (
|
||||
<button
|
||||
key={chat.id}
|
||||
type='button'
|
||||
onClick={() => setChat(chat)}
|
||||
className='px-4 py-2 w-full flex flex-col hover:bg-gray-100'
|
||||
>
|
||||
<HStack alignItems='center' space={2}>
|
||||
<Avatar src={chat.account.avatar} size={40} />
|
||||
|
||||
<Stack alignItems='start'>
|
||||
<div className='flex items-center space-x-1 flex-grow'>
|
||||
<Text weight='semibold' truncate>{chat.account.display_name}</Text>
|
||||
{chat.account.verified && <VerificationBadge />}
|
||||
</div>
|
||||
<Text theme='muted' truncate>{chat.account.acct}</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
</button>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Stack justifyContent='center' alignItems='center' space={4} className='px-4 flex-grow'>
|
||||
<Stack space={2}>
|
||||
<Text weight='semibold' size='xl' align='center'>No messages yet</Text>
|
||||
<Text theme='muted' align='center'>You can start a conversation with anyone that follows you.</Text>
|
||||
</Stack>
|
||||
|
||||
<Button theme='primary'>Message someone</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='chat-panes'>
|
||||
{mainWindowPane}
|
||||
{panes.map((pane, i) => (
|
||||
<div>
|
||||
<Pane windowState={mainWindowState} index={0} main>
|
||||
{chat?.id ? (
|
||||
<ChatWindow chat={chat} closeChat={() => setChat(null)} closePane={handleMainWindowToggle} />
|
||||
) : (
|
||||
<>
|
||||
<ChatPaneHeader title='Messages' unreadCount={unreadCount} isOpen={open} onToggle={handleMainWindowToggle} />
|
||||
|
||||
{open ? (
|
||||
<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>
|
||||
|
||||
{renderBody()}
|
||||
</Stack>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Pane>
|
||||
|
||||
{/* {panes.map((pane, i) => (
|
||||
<ChatWindow
|
||||
idx={i + 1}
|
||||
key={pane.get('chat_id')}
|
||||
chatId={pane.get('chat_id')}
|
||||
windowState={pane.get('state')}
|
||||
/>
|
||||
))}
|
||||
))} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,16 +5,18 @@ import {
|
||||
closeChat,
|
||||
toggleChat,
|
||||
} from 'soapbox/actions/chats';
|
||||
import Avatar from 'soapbox/components/avatar';
|
||||
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { HStack, Counter } from 'soapbox/components/ui';
|
||||
import { Avatar, HStack, Counter, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
import { IChat } from 'soapbox/queries/chats';
|
||||
import { makeGetChat } from 'soapbox/selectors';
|
||||
import { getAcct } from 'soapbox/utils/accounts';
|
||||
import { displayFqn as getDisplayFqn } from 'soapbox/utils/state';
|
||||
|
||||
import ChatBox from './chat-box';
|
||||
import ChatPaneHeader from './chat-pane-header';
|
||||
import { Pane, WindowState } from './ui';
|
||||
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
@ -22,89 +24,66 @@ import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
const getChat = makeGetChat();
|
||||
|
||||
interface IChatWindow {
|
||||
/** Position of the chat window on the screen, where 0 is rightmost. */
|
||||
idx: number,
|
||||
/** ID of the chat entity. */
|
||||
chatId: string,
|
||||
/** Whether the window is open or minimized. */
|
||||
windowState: WindowState,
|
||||
chat: IChat
|
||||
closeChat(): void
|
||||
closePane(): void
|
||||
}
|
||||
|
||||
/** Floating desktop chat window. */
|
||||
const ChatWindow: React.FC<IChatWindow> = ({ idx, chatId, windowState }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const displayFqn = useAppSelector(getDisplayFqn);
|
||||
|
||||
const chat = useAppSelector(state => {
|
||||
const chat = state.chats.items.get(chatId);
|
||||
return chat ? getChat(state, chat.toJS() as any) : undefined;
|
||||
});
|
||||
|
||||
const inputElem = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const handleChatClose = (chatId: string) => {
|
||||
return () => {
|
||||
dispatch(closeChat(chatId));
|
||||
};
|
||||
};
|
||||
|
||||
const handleChatToggle = (chatId: string) => {
|
||||
return () => {
|
||||
dispatch(toggleChat(chatId));
|
||||
};
|
||||
};
|
||||
|
||||
const handleInputRef = (el: HTMLTextAreaElement) => {
|
||||
inputElem.current = el;
|
||||
};
|
||||
|
||||
const focusInput = () => {
|
||||
inputElem.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (windowState === 'open') {
|
||||
focusInput();
|
||||
}
|
||||
}, [windowState]);
|
||||
|
||||
const ChatWindow: React.FC<IChatWindow> = ({ chat, closeChat, closePane }) => {
|
||||
if (!chat) return null;
|
||||
const account = chat.account as unknown as AccountEntity;
|
||||
const unreadCount = chat.unread;
|
||||
|
||||
const unreadIcon = (
|
||||
<div className='mr-2 flex-none'>
|
||||
<Counter count={unreadCount} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const avatar = (
|
||||
<HoverRefWrapper accountId={account.id}>
|
||||
<Link to={`/@${account.acct}`}>
|
||||
<Avatar account={account} size={18} />
|
||||
</Link>
|
||||
</HoverRefWrapper>
|
||||
);
|
||||
|
||||
return (
|
||||
<Pane windowState={windowState} index={idx}>
|
||||
<HStack space={2} className='pane__header'>
|
||||
{unreadCount > 0 ? unreadIcon : avatar }
|
||||
<button className='pane__title' onClick={handleChatToggle(chat.id)}>
|
||||
@{getAcct(account, displayFqn)}
|
||||
</button>
|
||||
<div className='pane__close'>
|
||||
<IconButton src={require('@tabler/icons/x.svg')} title='Close chat' onClick={handleChatClose(chat.id)} />
|
||||
</div>
|
||||
</HStack>
|
||||
<div className='pane__content'>
|
||||
<ChatBox
|
||||
chatId={chat.id}
|
||||
onSetInputRef={handleInputRef}
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
<>
|
||||
<ChatPaneHeader
|
||||
title={
|
||||
<HStack alignItems='center' space={2}>
|
||||
<button onClick={closeChat}>
|
||||
<Icon
|
||||
src={require('@tabler/icons/arrow-left.svg')}
|
||||
className='h-6 w-6 text-gray-600 dark:text-gray-400'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<HStack alignItems='center' space={3}>
|
||||
<Avatar src={chat.account.avatar} size={40} />
|
||||
|
||||
<Stack alignItems='start'>
|
||||
<div className='flex items-center space-x-1 flex-grow'>
|
||||
<Text weight='semibold' truncate>{chat.account.display_name}</Text>
|
||||
{chat.account.verified && <VerificationBadge />}
|
||||
</div>
|
||||
<Text theme='muted' truncate>{chat.account.acct}</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
}
|
||||
isToggleable={false}
|
||||
isOpen
|
||||
onToggle={closePane}
|
||||
/>
|
||||
|
||||
<Stack className='overflow-hidden flex-grow h-full' space={2}>
|
||||
<ChatBox chat={chat} onSetInputRef={() => null} />
|
||||
</Stack>
|
||||
</>
|
||||
// <Pane windowState={windowState} index={idx}>
|
||||
// <HStack space={2} className='pane__header'>
|
||||
// {unreadCount > 0 ? unreadIcon : avatar }
|
||||
// <button className='pane__title' onClick={handleChatToggle(chat.id)}>
|
||||
// @{getAcct(account, displayFqn)}
|
||||
// </button>
|
||||
// <div className='pane__close'>
|
||||
// <IconButton src={require('@tabler/icons/x.svg')} title='Close chat' onClick={handleChatClose(chat.id)} />
|
||||
// </div>
|
||||
// </HStack>
|
||||
// <div className='pane__content'>
|
||||
// <ChatBox
|
||||
// chatId={chat.id}
|
||||
//
|
||||
// />
|
||||
// </div>
|
||||
// </Pane>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -21,9 +21,9 @@ const Pane: React.FC<IPane> = ({ windowState, index, children, main = false }) =
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('pane', {
|
||||
'pane--main': main,
|
||||
'h-14': windowState === 'minimized',
|
||||
className={classNames('flex flex-col shadow-3xl bg-white dark:bg-gray-900 rounded-t-lg fixed bottom-0 right-1 w-96 z-[1000]', {
|
||||
'h-[550px] max-h-[100vh]': windowState !== 'minimized',
|
||||
'h-16': windowState === 'minimized',
|
||||
})}
|
||||
style={{ right: `${right}px` }}
|
||||
>
|
||||
|
||||
@ -457,7 +457,7 @@ const UI: React.FC = ({ children }) => {
|
||||
dispatch(fetchAnnouncements());
|
||||
|
||||
if (features.chats) {
|
||||
dispatch(fetchChats());
|
||||
// dispatch(fetchChats());
|
||||
}
|
||||
|
||||
if (account.staff) {
|
||||
|
||||
103
app/soapbox/queries/chats.ts
Normal file
103
app/soapbox/queries/chats.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
|
||||
export interface IChat {
|
||||
id: string
|
||||
unread: number
|
||||
created_by_account: number
|
||||
last_message: null | string
|
||||
created_at: Date
|
||||
updated_at: Date
|
||||
accepted: boolean
|
||||
discarded_at: null | string
|
||||
account: any
|
||||
}
|
||||
|
||||
export interface IChatMessage {
|
||||
account_id: string
|
||||
chat_id: string
|
||||
content: string
|
||||
created_at: Date
|
||||
id: string
|
||||
unread: boolean
|
||||
}
|
||||
|
||||
const reverseOrder = (a: IChat, b: IChat): number => {
|
||||
if (Number(a.id) < Number(b.id)) return -1;
|
||||
if (Number(a.id) > Number(b.id)) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const useChatMessages = (chatId: string) => {
|
||||
const api = useApi();
|
||||
|
||||
const getChatMessages = async (chatId: string, pageParam?: any): Promise<{ result: IChatMessage[], maxId: string, hasMore: boolean }> => {
|
||||
const { data, headers } = await api.get(`/api/v1/pleroma/chats/${chatId}/messages`, {
|
||||
params: {
|
||||
max_id: pageParam?.maxId,
|
||||
},
|
||||
});
|
||||
|
||||
const hasMore = !!headers.link;
|
||||
const result = data.sort(reverseOrder);
|
||||
const nextMaxId = result[0]?.id;
|
||||
|
||||
return {
|
||||
result,
|
||||
maxId: nextMaxId,
|
||||
hasMore,
|
||||
};
|
||||
};
|
||||
|
||||
const queryInfo = useInfiniteQuery(['chats', 'messages', chatId], ({ pageParam }) => getChatMessages(chatId, pageParam), {
|
||||
keepPreviousData: true,
|
||||
getNextPageParam: (config) => {
|
||||
if (config.hasMore) {
|
||||
return { maxId: config.maxId };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const data = queryInfo.data?.pages.reduce<IChatMessage[]>(
|
||||
(prev: IChatMessage[], curr) => [...curr.result, ...prev],
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...queryInfo,
|
||||
data,
|
||||
};
|
||||
};
|
||||
const useChats = () => {
|
||||
const api = useApi();
|
||||
|
||||
const getChats = async () => {
|
||||
const { data } = await api.get('/api/v1/pleroma/chats');
|
||||
return data;
|
||||
};
|
||||
|
||||
const chatsQuery = useQuery<IChat[]>(['chats'], getChats, {
|
||||
placeholderData: [],
|
||||
});
|
||||
|
||||
const getOrCreateChatByAccountId = (accountId: string) => api.post<IChat>(`/api/v1/pleroma/chats/by-account-id/${accountId}`);
|
||||
|
||||
return { chatsQuery, getOrCreateChatByAccountId };
|
||||
};
|
||||
|
||||
const useChat = (chatId: string) => {
|
||||
const api = useApi();
|
||||
|
||||
const markChatAsRead = () => api.post<IChat>(`/api/v1/pleroma/chats/${chatId}/read`);
|
||||
|
||||
const createChatMessage = (chatId: string, content: string) => {
|
||||
return api.post<IChat>(`/api/v1/pleroma/chats/${chatId}/messages`, { content });
|
||||
};
|
||||
|
||||
return { createChatMessage, markChatAsRead };
|
||||
};
|
||||
|
||||
export { useChat, useChats, useChatMessages };
|
||||
27
app/soapbox/queries/search.ts
Normal file
27
app/soapbox/queries/search.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'soapbox/hooks';
|
||||
|
||||
export default function useAccountSearch(q: string) {
|
||||
const api = useApi();
|
||||
|
||||
const getAccountSearch = async(q: string) => {
|
||||
if (typeof q === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data } = await api.get('/api/v1/accounts/search', {
|
||||
params: {
|
||||
q,
|
||||
followers: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return useQuery(['search', 'accounts', q], () => getAccountSearch(q), {
|
||||
keepPreviousData: true,
|
||||
placeholderData: [],
|
||||
});
|
||||
}
|
||||
@ -196,7 +196,7 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||
* Pleroma chats API.
|
||||
* @see {@link https://docs.pleroma.social/backend/development/API/chats/}
|
||||
*/
|
||||
chats: v.software === PLEROMA && gte(v.version, '2.1.0'),
|
||||
chats: v.software === TRUTHSOCIAL || (v.software === PLEROMA && gte(v.version, '2.1.0')),
|
||||
|
||||
/**
|
||||
* Paginated chats API.
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
.dropdown-menu {
|
||||
@apply absolute bg-white dark:bg-gray-900 z-40 rounded-md shadow-lg py-1 w-56 dark:ring-2 dark:ring-primary-700 focus:outline-none;
|
||||
@apply absolute bg-white dark:bg-gray-900 z-[1001] rounded-md shadow-lg py-1 w-56 dark:ring-2 dark:ring-primary-700 focus:outline-none;
|
||||
|
||||
&.left { transform-origin: 100% 50%; }
|
||||
&.top { transform-origin: 50% 100%; }
|
||||
|
||||
Reference in New Issue
Block a user