From b5ae9adf63f46908c4e6bee47455c8f1925f7c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 12 Apr 2022 18:52:56 +0200 Subject: [PATCH] Chats: typescript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../features/chats/components/audio_toggle.js | 61 ------------ .../chats/components/audio_toggle.tsx | 46 +++++++++ app/soapbox/features/chats/components/chat.js | 87 ---------------- .../features/chats/components/chat.tsx | 69 +++++++++++++ .../features/chats/components/chat_list.js | 99 ------------------- .../features/chats/components/chat_list.tsx | 84 ++++++++++++++++ app/soapbox/features/chats/index.js | 66 ------------- app/soapbox/features/chats/index.tsx | 55 +++++++++++ .../compose/containers/upload_container.js | 2 +- app/soapbox/normalizers/chat.ts | 18 ++++ app/soapbox/normalizers/chat_message.ts | 29 ++++++ app/soapbox/normalizers/index.ts | 2 + ...message_lists.js => chat_message_lists.ts} | 30 +++--- .../{chat_messages.js => chat_messages.ts} | 32 +++--- app/soapbox/reducers/chats.js | 58 ----------- app/soapbox/reducers/chats.ts | 76 ++++++++++++++ app/soapbox/selectors/index.ts | 11 ++- app/soapbox/types/entities.ts | 6 ++ 18 files changed, 431 insertions(+), 400 deletions(-) delete mode 100644 app/soapbox/features/chats/components/audio_toggle.js create mode 100644 app/soapbox/features/chats/components/audio_toggle.tsx delete mode 100644 app/soapbox/features/chats/components/chat.js create mode 100644 app/soapbox/features/chats/components/chat.tsx delete mode 100644 app/soapbox/features/chats/components/chat_list.js create mode 100644 app/soapbox/features/chats/components/chat_list.tsx delete mode 100644 app/soapbox/features/chats/index.js create mode 100644 app/soapbox/features/chats/index.tsx create mode 100644 app/soapbox/normalizers/chat.ts create mode 100644 app/soapbox/normalizers/chat_message.ts rename app/soapbox/reducers/{chat_message_lists.js => chat_message_lists.ts} (63%) rename app/soapbox/reducers/{chat_messages.js => chat_messages.ts} (60%) delete mode 100644 app/soapbox/reducers/chats.js create mode 100644 app/soapbox/reducers/chats.ts diff --git a/app/soapbox/features/chats/components/audio_toggle.js b/app/soapbox/features/chats/components/audio_toggle.js deleted file mode 100644 index f0068d1f3..000000000 --- a/app/soapbox/features/chats/components/audio_toggle.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { injectIntl, defineMessages } from 'react-intl'; -import { connect } from 'react-redux'; -import Toggle from 'react-toggle'; - -import { changeSetting, getSettings } from 'soapbox/actions/settings'; - -const messages = defineMessages({ - switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' }, - switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' }, -}); - -const mapStateToProps = state => { - return { - checked: getSettings(state).getIn(['chats', 'sound'], false), - }; -}; - -const mapDispatchToProps = (dispatch) => ({ - toggleAudio(setting) { - dispatch(changeSetting(['chats', 'sound'], setting)); - }, -}); - -export default @connect(mapStateToProps, mapDispatchToProps) -@injectIntl -class AudioToggle extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - checked: PropTypes.bool.isRequired, - toggleAudio: PropTypes.func, - showLabel: PropTypes.bool, - }; - - handleToggleAudio = () => { - this.props.toggleAudio(!this.props.checked); - } - - render() { - const { intl, checked, showLabel } = this.props; - const id = 'chats-audio-toggle'; - const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn); - - return ( -
-
- - {showLabel && ()} -
-
- ); - } - -} diff --git a/app/soapbox/features/chats/components/audio_toggle.tsx b/app/soapbox/features/chats/components/audio_toggle.tsx new file mode 100644 index 000000000..96ddf6211 --- /dev/null +++ b/app/soapbox/features/chats/components/audio_toggle.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import Toggle from 'react-toggle'; + +import { changeSetting, getSettings } from 'soapbox/actions/settings'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' }, + switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' }, +}); + +interface IAudioToggle { + showLabel?: boolean +} + +const AudioToggle: React.FC = ({ showLabel }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const checked = useAppSelector(state => !!getSettings(state).getIn(['chats', 'sound'])); + + const handleToggleAudio = () => { + dispatch(changeSetting(['chats', 'sound'], !checked)); + }; + + const id = 'chats-audio-toggle'; + const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn); + + return ( +
+
+ + {showLabel && ()} +
+
+ ); +}; + +export default AudioToggle; diff --git a/app/soapbox/features/chats/components/chat.js b/app/soapbox/features/chats/components/chat.js deleted file mode 100644 index f19190bed..000000000 --- a/app/soapbox/features/chats/components/chat.js +++ /dev/null @@ -1,87 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import Icon from 'soapbox/components/icon'; -import emojify from 'soapbox/features/emoji/emoji'; -import { makeGetChat } from 'soapbox/selectors'; -import { shortNumberFormat } from 'soapbox/utils/numbers'; - -import Avatar from '../../../components/avatar'; -import DisplayName from '../../../components/display_name'; - -const makeMapStateToProps = () => { - const getChat = makeGetChat(); - - const mapStateToProps = (state, { chatId }) => { - const chat = state.getIn(['chats', 'items', chatId]); - - return { - chat: chat ? getChat(state, chat.toJS()) : undefined, - }; - }; - - return mapStateToProps; -}; - -export default @connect(makeMapStateToProps) -class Chat extends ImmutablePureComponent { - - static propTypes = { - chatId: PropTypes.string.isRequired, - chat: ImmutablePropTypes.map, - onClick: PropTypes.func, - }; - - handleClick = () => { - this.props.onClick(this.props.chat); - } - - render() { - const { chat } = this.props; - if (!chat) return null; - const account = chat.get('account'); - const unreadCount = chat.get('unread'); - const content = chat.getIn(['last_message', 'content']); - const attachment = chat.getIn(['last_message', 'attachment']); - const image = attachment && attachment.getIn(['pleroma', 'mime_type'], '').startsWith('image/'); - const parsedContent = content ? emojify(content) : ''; - - return ( -
-
- ); - } - -} diff --git a/app/soapbox/features/chats/components/chat.tsx b/app/soapbox/features/chats/components/chat.tsx new file mode 100644 index 000000000..afb1d9df8 --- /dev/null +++ b/app/soapbox/features/chats/components/chat.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Avatar from 'soapbox/components/avatar'; +import DisplayName from 'soapbox/components/display_name'; +import Icon from 'soapbox/components/icon'; +import emojify from 'soapbox/features/emoji/emoji'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetChat } from 'soapbox/selectors'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +import type { Account as AccountEntity, Chat as ChatEntity } from 'soapbox/types/entities'; + +const getChat = makeGetChat(); + +interface IChat { + chatId: string, + onClick: (chat: any) => void, +} + +const Chat: React.FC = ({ chatId, onClick }) => { + const chat = useAppSelector((state) => { + const chat = state.chats.getIn(['items', chatId]); + return chat ? getChat(state, (chat as any).toJS()) : undefined; + }) as ChatEntity; + + const account = chat.account as AccountEntity; + if (!chat || !account) return null; + const unreadCount = chat.unread; + const content = chat.getIn(['last_message', 'content']); + const attachment = chat.getIn(['last_message', 'attachment']); + const image = attachment && (attachment as any).getIn(['pleroma', 'mime_type'], '').startsWith('image/'); + const parsedContent = content ? emojify(content) : ''; + + return ( +
+
+ ); +}; + +export default Chat; diff --git a/app/soapbox/features/chats/components/chat_list.js b/app/soapbox/features/chats/components/chat_list.js deleted file mode 100644 index ffb0c1720..000000000 --- a/app/soapbox/features/chats/components/chat_list.js +++ /dev/null @@ -1,99 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; - -import { expandChats } from 'soapbox/actions/chats'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat'; - -import Chat from './chat'; - -const messages = defineMessages({ - emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' }, -}); - -const getSortedChatIds = chats => ( - chats - .toList() - .sort(chatDateComparator) - .map(chat => chat.get('id')) -); - -const chatDateComparator = (chatA, chatB) => { - // Sort most recently updated chats at the top - const a = new Date(chatA.get('updated_at')); - const b = new Date(chatB.get('updated_at')); - - if (a === b) return 0; - if (a > b) return -1; - if (a < b) return 1; - return 0; -}; - -const sortedChatIdsSelector = createSelector( - [getSortedChatIds], - chats => chats, -); - -const makeMapStateToProps = () => { - const mapStateToProps = state => ({ - chatIds: sortedChatIdsSelector(state.getIn(['chats', 'items'])), - hasMore: !!state.getIn(['chats', 'next']), - isLoading: state.getIn(['chats', 'loading']), - }); - - return mapStateToProps; -}; - -export default @connect(makeMapStateToProps) -@injectIntl -class ChatList extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - chatIds: ImmutablePropTypes.list, - onClickChat: PropTypes.func, - onRefresh: PropTypes.func, - hasMore: PropTypes.func, - isLoading: PropTypes.bool, - }; - - handleLoadMore = debounce(() => { - this.props.dispatch(expandChats()); - }, 300, { leading: true }); - - render() { - const { intl, chatIds, hasMore, isLoading } = this.props; - - return ( - - {chatIds.map(chatId => ( -
- -
- ))} -
- ); - } - -} diff --git a/app/soapbox/features/chats/components/chat_list.tsx b/app/soapbox/features/chats/components/chat_list.tsx new file mode 100644 index 000000000..874c71041 --- /dev/null +++ b/app/soapbox/features/chats/components/chat_list.tsx @@ -0,0 +1,84 @@ +import { Map as ImmutableMap } from 'immutable'; +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { expandChats } from 'soapbox/actions/chats'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat'; +import { useAppSelector } from 'soapbox/hooks'; + +import Chat from './chat'; + +const messages = defineMessages({ + emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandChats()); +}, 300, { leading: true }); + +const getSortedChatIds = (chats: ImmutableMap) => ( + chats + .toList() + .sort(chatDateComparator) + .map(chat => chat.id) +); + +const chatDateComparator = (chatA: { updated_at: string }, chatB: { updated_at: string }) => { + // Sort most recently updated chats at the top + const a = new Date(chatA.updated_at); + const b = new Date(chatB.updated_at); + + if (a === b) return 0; + if (a > b) return -1; + if (a < b) return 1; + return 0; +}; + +const sortedChatIdsSelector = createSelector( + [getSortedChatIds], + chats => chats, +); + +interface IChatList { + onClickChat: (chat: any) => void, + onRefresh: () => void, +} + +const ChatList: React.FC = ({ onClickChat, onRefresh }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const chatIds = useAppSelector(state => sortedChatIdsSelector(state.chats.get('items'))); + const hasMore = useAppSelector(state => !!state.chats.get('next')); + const isLoading = useAppSelector(state => state.chats.get('isLoading')); + + return ( + handleLoadMore(dispatch)} + onRefresh={onRefresh} + placeholderComponent={PlaceholderChat} + placeholderCount={20} + > + {chatIds.map((chatId: string) => ( +
+ +
+ ))} +
+ ); +}; + +export default ChatList; diff --git a/app/soapbox/features/chats/index.js b/app/soapbox/features/chats/index.js deleted file mode 100644 index ed8c35e60..000000000 --- a/app/soapbox/features/chats/index.js +++ /dev/null @@ -1,66 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; - -import { fetchChats, launchChat } from 'soapbox/actions/chats'; -import AccountSearch from 'soapbox/components/account_search'; -import AudioToggle from 'soapbox/features/chats/components/audio_toggle'; - -import { Column } from '../../components/ui'; - -import ChatList from './components/chat_list'; - -const messages = defineMessages({ - title: { id: 'column.chats', defaultMessage: 'Chats' }, - searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' }, -}); - -export default @connect() -@injectIntl -@withRouter -class ChatIndex extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - history: PropTypes.object, - }; - - handleSuggestion = accountId => { - this.props.dispatch(launchChat(accountId, this.props.history, true)); - } - - handleClickChat = (chat) => { - this.props.history.push(`/chats/${chat.get('id')}`); - } - - handleRefresh = () => { - const { dispatch } = this.props; - return dispatch(fetchChats()); - } - - render() { - const { intl } = this.props; - - return ( - -
- -
- - - - -
- ); - } - -} diff --git a/app/soapbox/features/chats/index.tsx b/app/soapbox/features/chats/index.tsx new file mode 100644 index 000000000..c13335ff3 --- /dev/null +++ b/app/soapbox/features/chats/index.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; + +import { fetchChats, launchChat } from 'soapbox/actions/chats'; +import AccountSearch from 'soapbox/components/account_search'; +import AudioToggle from 'soapbox/features/chats/components/audio_toggle'; + +import { Column } from '../../components/ui'; + +import ChatList from './components/chat_list'; + +const messages = defineMessages({ + title: { id: 'column.chats', defaultMessage: 'Chats' }, + searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' }, +}); + +const ChatIndex: React.FC = () => { + const intl = useIntl(); + const dispatch = useDispatch(); + const history = useHistory(); + + const handleSuggestion = (accountId: string) => { + dispatch(launchChat(accountId, history, true)); + }; + + const handleClickChat = (chat: { id: string }) => { + history.push(`/chats/${chat.id}`); + }; + + const handleRefresh = () => { + return dispatch(fetchChats()); + }; + + return ( + +
+ +
+ + + + +
+ ); +}; + +export default ChatIndex; diff --git a/app/soapbox/features/compose/containers/upload_container.js b/app/soapbox/features/compose/containers/upload_container.js index 9c77c6df7..332f206ee 100644 --- a/app/soapbox/features/compose/containers/upload_container.js +++ b/app/soapbox/features/compose/containers/upload_container.js @@ -26,7 +26,7 @@ const mapDispatchToProps = dispatch => ({ }, onOpenModal: media => { - dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0, onClose: console.log })); + dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 })); }, onSubmit(router) { diff --git a/app/soapbox/normalizers/chat.ts b/app/soapbox/normalizers/chat.ts new file mode 100644 index 000000000..04149c682 --- /dev/null +++ b/app/soapbox/normalizers/chat.ts @@ -0,0 +1,18 @@ +import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; + +import type { ReducerAccount } from 'soapbox/reducers/accounts'; +import type { Account, EmbeddedEntity } from 'soapbox/types/entities'; + +export const ChatRecord = ImmutableRecord({ + account: null as EmbeddedEntity, + id: '', + unread: 0, + last_message: '' as string || null, + updated_at: new Date(), +}); + +export const normalizeChat = (chat: Record) => { + return ChatRecord( + ImmutableMap(fromJS(chat)), + ); +}; diff --git a/app/soapbox/normalizers/chat_message.ts b/app/soapbox/normalizers/chat_message.ts new file mode 100644 index 000000000..71536acb5 --- /dev/null +++ b/app/soapbox/normalizers/chat_message.ts @@ -0,0 +1,29 @@ +import { + List as ImmutableList, + Map as ImmutableMap, + Record as ImmutableRecord, + fromJS, +} from 'immutable'; + +import type { Attachment, Card, Emoji } from 'soapbox/types/entities'; + +export const ChatMessageRecord = ImmutableRecord({ + account_id: '', + attachment: null as Attachment | null, + card: null as Card | null, + chat_id: '', + content: '', + created_at: new Date(), + emojis: ImmutableList(), + id: '', + unread: false, + + deleting: false, + pending: false, +}); + +export const normalizeChatMessage = (chatMessage: Record) => { + return ChatMessageRecord( + ImmutableMap(fromJS(chatMessage)), + ); +}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 613de5331..3251669de 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -1,6 +1,8 @@ export { AccountRecord, FieldRecord, normalizeAccount } from './account'; export { AttachmentRecord, normalizeAttachment } from './attachment'; export { CardRecord, normalizeCard } from './card'; +export { ChatRecord, normalizeChat } from './chat'; +export { ChatMessageRecord, normalizeChatMessage } from './chat_message'; export { EmojiRecord, normalizeEmoji } from './emoji'; export { InstanceRecord, normalizeInstance } from './instance'; export { MentionRecord, normalizeMention } from './mention'; diff --git a/app/soapbox/reducers/chat_message_lists.js b/app/soapbox/reducers/chat_message_lists.ts similarity index 63% rename from app/soapbox/reducers/chat_message_lists.js rename to app/soapbox/reducers/chat_message_lists.ts index 9939885e6..79a595801 100644 --- a/app/soapbox/reducers/chat_message_lists.js +++ b/app/soapbox/reducers/chat_message_lists.ts @@ -1,4 +1,5 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; +import { AnyAction } from 'redux'; import { CHATS_FETCH_SUCCESS, @@ -10,41 +11,46 @@ import { } from 'soapbox/actions/chats'; import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; -const initialState = ImmutableMap(); +type APIEntity = Record; +type APIEntities = Array; -const idComparator = (a, b) => { +type State = ImmutableMap>; + +const initialState: State = ImmutableMap(); + +const idComparator = (a: string, b: string) => { if (a < b) return -1; if (a > b) return 1; return 0; }; -const updateList = (state, chatId, messageIds) => { +const updateList = (state: State, chatId: string, messageIds: string[]) => { const ids = state.get(chatId, ImmutableOrderedSet()); - const newIds = ids.union(messageIds).sort(idComparator); + const newIds = (ids.union(messageIds) as ImmutableOrderedSet).sort(idComparator); return state.set(chatId, newIds); }; -const importMessage = (state, chatMessage) => { +const importMessage = (state: State, chatMessage: APIEntity) => { return updateList(state, chatMessage.chat_id, [chatMessage.id]); }; -const importMessages = (state, chatMessages) => ( +const importMessages = (state: State, chatMessages: APIEntities) => ( state.withMutations(map => chatMessages.forEach(chatMessage => importMessage(map, chatMessage))) ); -const importLastMessages = (state, chats) => +const importLastMessages = (state: State, chats: APIEntities) => state.withMutations(mutable => chats.forEach(chat => { if (chat.last_message) importMessage(mutable, chat.last_message); })); -const replaceMessage = (state, chatId, oldId, newId) => { - return state.update(chatId, chat => chat.delete(oldId).add(newId).sort(idComparator)); +const replaceMessage = (state: State, chatId: string, oldId: string, newId: string) => { + return state.update(chatId, chat => chat!.delete(oldId).add(newId).sort(idComparator)); }; -export default function chatMessageLists(state = initialState, action) { +export default function chatMessageLists(state = initialState, action: AnyAction) { switch(action.type) { case CHAT_MESSAGE_SEND_REQUEST: return updateList(state, action.chatId, [action.uuid]); @@ -58,11 +64,11 @@ export default function chatMessageLists(state = initialState, action) { else return state; case CHAT_MESSAGES_FETCH_SUCCESS: - return updateList(state, action.chatId, action.chatMessages.map(chat => chat.id)); + return updateList(state, action.chatId, action.chatMessages.map((chat: APIEntity) => chat.id)); case CHAT_MESSAGE_SEND_SUCCESS: return replaceMessage(state, action.chatId, action.uuid, action.chatMessage.id); case CHAT_MESSAGE_DELETE_SUCCESS: - return state.update(action.chatId, chat => chat.delete(action.messageId)); + return state.update(action.chatId, chat => chat!.delete(action.messageId)); default: return state; } diff --git a/app/soapbox/reducers/chat_messages.js b/app/soapbox/reducers/chat_messages.ts similarity index 60% rename from app/soapbox/reducers/chat_messages.js rename to app/soapbox/reducers/chat_messages.ts index a0787d077..d6b4f4fb9 100644 --- a/app/soapbox/reducers/chat_messages.js +++ b/app/soapbox/reducers/chat_messages.ts @@ -1,4 +1,5 @@ import { Map as ImmutableMap, fromJS } from 'immutable'; +import { AnyAction } from 'redux'; import { CHATS_FETCH_SUCCESS, @@ -10,25 +11,32 @@ import { CHAT_MESSAGE_DELETE_SUCCESS, } from 'soapbox/actions/chats'; import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; +import { normalizeChatMessage } from 'soapbox/normalizers'; -const initialState = ImmutableMap(); +type ChatMessageRecord = ReturnType; +type APIEntity = Record; +type APIEntities = Array; -const importMessage = (state, message) => { - return state.set(message.get('id'), message); +type State = ImmutableMap; + +const importMessage = (state: State, message: APIEntity) => { + return state.set(message.id, normalizeChatMessage(message)); }; -const importMessages = (state, messages) => +const importMessages = (state: State, messages: APIEntities) => state.withMutations(mutable => messages.forEach(message => importMessage(mutable, message))); -const importLastMessages = (state, chats) => +const importLastMessages = (state: State, chats: APIEntities) => state.withMutations(mutable => chats.forEach(chat => { - if (chat.get('last_message')) - importMessage(mutable, chat.get('last_message')); + if (chat.last_message) + importMessage(mutable, chat.last_message); })); -export default function chatMessages(state = initialState, action) { +const initialState: State = ImmutableMap(); + +export default function chatMessages(state = initialState, action: AnyAction) { switch(action.type) { case CHAT_MESSAGE_SEND_REQUEST: return importMessage(state, fromJS({ @@ -41,16 +49,16 @@ export default function chatMessages(state = initialState, action) { })); case CHATS_FETCH_SUCCESS: case CHATS_EXPAND_SUCCESS: - return importLastMessages(state, fromJS(action.chats)); + return importLastMessages(state, action.chats); case CHAT_MESSAGES_FETCH_SUCCESS: - return importMessages(state, fromJS(action.chatMessages)); + return importMessages(state, action.chatMessages); case CHAT_MESSAGE_SEND_SUCCESS: return importMessage(state, fromJS(action.chatMessage)).delete(action.uuid); case STREAMING_CHAT_UPDATE: - return importLastMessages(state, fromJS([action.chat])); + return importLastMessages(state, [action.chat]); case CHAT_MESSAGE_DELETE_REQUEST: return state.update(action.messageId, chatMessage => - chatMessage.set('pending', true).set('deleting', true)); + chatMessage!.set('pending', true).set('deleting', true)); case CHAT_MESSAGE_DELETE_SUCCESS: return state.delete(action.messageId); default: diff --git a/app/soapbox/reducers/chats.js b/app/soapbox/reducers/chats.js deleted file mode 100644 index 430251210..000000000 --- a/app/soapbox/reducers/chats.js +++ /dev/null @@ -1,58 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { - CHATS_FETCH_SUCCESS, - CHATS_FETCH_REQUEST, - CHATS_EXPAND_SUCCESS, - CHATS_EXPAND_REQUEST, - CHAT_FETCH_SUCCESS, - CHAT_READ_SUCCESS, - CHAT_READ_REQUEST, -} from 'soapbox/actions/chats'; -import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; - -const normalizeChat = (chat, normalOldChat) => { - const normalChat = { ...chat }; - const { account, last_message: lastMessage } = chat; - - if (account) normalChat.account = account.id; - if (lastMessage) normalChat.last_message = lastMessage.id; - - return normalChat; -}; - -const importChat = (state, chat) => state.setIn(['items', chat.id], fromJS(normalizeChat(chat))); - -const importChats = (state, chats, next) => - state.withMutations(mutable => { - if (next !== undefined) mutable.set('next', next); - chats.forEach(chat => importChat(mutable, chat)); - mutable.set('loading', false); - }); - -const initialState = ImmutableMap({ - next: null, - isLoading: false, - items: ImmutableMap({}), -}); - -export default function chats(state = initialState, action) { - switch(action.type) { - case CHATS_FETCH_REQUEST: - case CHATS_EXPAND_REQUEST: - return state.set('loading', true); - case CHATS_FETCH_SUCCESS: - case CHATS_EXPAND_SUCCESS: - return importChats(state, action.chats, action.next); - case STREAMING_CHAT_UPDATE: - return importChats(state, [action.chat]); - case CHAT_FETCH_SUCCESS: - return importChats(state, [action.chat]); - case CHAT_READ_REQUEST: - return state.setIn([action.chatId, 'unread'], 0); - case CHAT_READ_SUCCESS: - return importChats(state, [action.chat]); - default: - return state; - } -} diff --git a/app/soapbox/reducers/chats.ts b/app/soapbox/reducers/chats.ts new file mode 100644 index 000000000..956da3c39 --- /dev/null +++ b/app/soapbox/reducers/chats.ts @@ -0,0 +1,76 @@ +import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable'; + +import { + CHATS_FETCH_SUCCESS, + CHATS_FETCH_REQUEST, + CHATS_EXPAND_SUCCESS, + CHATS_EXPAND_REQUEST, + CHAT_FETCH_SUCCESS, + CHAT_READ_SUCCESS, + CHAT_READ_REQUEST, +} from 'soapbox/actions/chats'; +import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; +import { normalizeChat } from 'soapbox/normalizers'; +import { normalizeId } from 'soapbox/utils/normalizers'; + +import type { AnyAction } from 'redux'; + +type ChatRecord = ReturnType; +type APIEntity = Record; +type APIEntities = Array; + +export interface ReducerChat extends ChatRecord { + account: string | null, + last_message: string | null, +} + +const ReducerRecord = ImmutableRecord({ + next: null as string | null, + isLoading: false, + items: ImmutableMap({}), +}); + +type State = ReturnType; + +const minifyChat = (chat: ChatRecord): ReducerChat => { + return chat.mergeWith((o, n) => n || o, { + account: normalizeId(chat.getIn(['account', 'id'])), + last_message: normalizeId(chat.getIn(['last_message', 'id'])), + }) as ReducerChat; +}; + +const fixChat = (chat: APIEntity): ReducerChat => { + return normalizeChat(chat).withMutations(chat => { + minifyChat(chat); + }) as ReducerChat; +}; + +const importChat = (state: State, chat: APIEntity) => state.setIn(['items', chat.id], fixChat(chat)); + +const importChats = (state: State, chats: APIEntities, next?: string) => + state.withMutations(mutable => { + if (next !== undefined) mutable.set('next', next); + chats.forEach(chat => importChat(mutable, chat)); + mutable.set('isLoading', false); + }); + +export default function chats(state: State = ReducerRecord(), action: AnyAction): State { + switch(action.type) { + case CHATS_FETCH_REQUEST: + case CHATS_EXPAND_REQUEST: + return state.set('isLoading', true); + case CHATS_FETCH_SUCCESS: + case CHATS_EXPAND_SUCCESS: + return importChats(state, action.chats, action.next); + case STREAMING_CHAT_UPDATE: + return importChats(state, [action.chat]); + case CHAT_FETCH_SUCCESS: + return importChats(state, [action.chat]); + case CHAT_READ_REQUEST: + return state.setIn([action.chatId, 'unread'], 0); + case CHAT_READ_SUCCESS: + return importChats(state, [action.chat]); + default: + return state; + } +} diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts index ddac93b24..40f8105e3 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -12,6 +12,7 @@ import { validId } from 'soapbox/utils/auth'; import ConfigDB from 'soapbox/utils/config_db'; import { shouldFilter } from 'soapbox/utils/timelines'; +import type { ReducerChat } from 'soapbox/reducers/chats'; import type { RootState } from 'soapbox/store'; import type { Notification } from 'soapbox/types/entities'; @@ -241,16 +242,18 @@ type APIChat = { id: string, last_message: string }; export const makeGetChat = () => { return createSelector( [ - (state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]), + (state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]) as ReducerChat, (state: RootState, { id }: APIChat) => state.accounts.get(state.chats.getIn(['items', id, 'account'])), (state: RootState, { last_message }: APIChat) => state.chat_messages.get(last_message), ], - (chat, account, lastMessage: string) => { - if (!chat) return null; + (chat, account, lastMessage) => { + if (!chat || !account) return null; - return chat.withMutations((map: ImmutableMap) => { + return chat.withMutations((map) => { + // @ts-ignore map.set('account', account); + // @ts-ignore map.set('last_message', lastMessage); }); }, diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index 942e5f4f8..80caf1ea1 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -2,6 +2,8 @@ import { AccountRecord, AttachmentRecord, CardRecord, + ChatRecord, + ChatMessageRecord, EmojiRecord, FieldRecord, InstanceRecord, @@ -16,6 +18,8 @@ import type { Record as ImmutableRecord } from 'immutable'; type Attachment = ReturnType; type Card = ReturnType; +type Chat = ReturnType; +type ChatMessage = ReturnType; type Emoji = ReturnType; type Field = ReturnType; type Instance = ReturnType; @@ -44,6 +48,8 @@ export { Account, Attachment, Card, + Chat, + ChatMessage, Emoji, Field, Instance,