diff --git a/app/soapbox/actions/chats.js b/app/soapbox/actions/chats.js new file mode 100644 index 000000000..a3e743c87 --- /dev/null +++ b/app/soapbox/actions/chats.js @@ -0,0 +1,152 @@ +import api from '../api'; +import { getSettings, changeSetting } from 'soapbox/actions/settings'; +import { v4 as uuidv4 } from 'uuid'; +import { Map as ImmutableMap } from 'immutable'; + +export const CHATS_FETCH_REQUEST = 'CHATS_FETCH_REQUEST'; +export const CHATS_FETCH_SUCCESS = 'CHATS_FETCH_SUCCESS'; +export const CHATS_FETCH_FAIL = 'CHATS_FETCH_FAIL'; + +export const CHAT_MESSAGES_FETCH_REQUEST = 'CHAT_MESSAGES_FETCH_REQUEST'; +export const CHAT_MESSAGES_FETCH_SUCCESS = 'CHAT_MESSAGES_FETCH_SUCCESS'; +export const CHAT_MESSAGES_FETCH_FAIL = 'CHAT_MESSAGES_FETCH_FAIL'; + +export const CHAT_MESSAGE_SEND_REQUEST = 'CHAT_MESSAGE_SEND_REQUEST'; +export const CHAT_MESSAGE_SEND_SUCCESS = 'CHAT_MESSAGE_SEND_SUCCESS'; +export const CHAT_MESSAGE_SEND_FAIL = 'CHAT_MESSAGE_SEND_FAIL'; + +export const CHAT_FETCH_REQUEST = 'CHAT_FETCH_REQUEST'; +export const CHAT_FETCH_SUCCESS = 'CHAT_FETCH_SUCCESS'; +export const CHAT_FETCH_FAIL = 'CHAT_FETCH_FAIL'; + +export const CHAT_READ_REQUEST = 'CHAT_READ_REQUEST'; +export const CHAT_READ_SUCCESS = 'CHAT_READ_SUCCESS'; +export const CHAT_READ_FAIL = 'CHAT_READ_FAIL'; + +export function fetchChats() { + return (dispatch, getState) => { + dispatch({ type: CHATS_FETCH_REQUEST }); + return api(getState).get('/api/v1/pleroma/chats').then(({ data }) => { + dispatch({ type: CHATS_FETCH_SUCCESS, chats: data }); + }).catch(error => { + dispatch({ type: CHATS_FETCH_FAIL, error }); + }); + }; +} + +export function fetchChatMessages(chatId) { + return (dispatch, getState) => { + dispatch({ type: CHAT_MESSAGES_FETCH_REQUEST, chatId }); + return api(getState).get(`/api/v1/pleroma/chats/${chatId}/messages`).then(({ data }) => { + dispatch({ type: CHAT_MESSAGES_FETCH_SUCCESS, chatId, chatMessages: data }); + }).catch(error => { + dispatch({ type: CHAT_MESSAGES_FETCH_FAIL, chatId, error }); + }); + }; +} + +export function sendChatMessage(chatId, params) { + return (dispatch, getState) => { + const uuid = uuidv4(); + const me = getState().get('me'); + dispatch({ type: CHAT_MESSAGE_SEND_REQUEST, chatId, params, uuid, me }); + return api(getState).post(`/api/v1/pleroma/chats/${chatId}/messages`, params).then(({ data }) => { + dispatch({ type: CHAT_MESSAGE_SEND_SUCCESS, chatId, chatMessage: data, uuid }); + }).catch(error => { + dispatch({ type: CHAT_MESSAGE_SEND_FAIL, chatId, error, uuid }); + }); + }; +} + +export function openChat(chatId) { + return (dispatch, getState) => { + const state = getState(); + const panes = getSettings(state).getIn(['chats', 'panes']); + const idx = panes.findIndex(pane => pane.get('chat_id') === chatId); + + dispatch(markChatRead(chatId)); + + if (idx > -1) { + return dispatch(changeSetting(['chats', 'panes', idx, 'state'], 'open')); + } else { + const newPane = ImmutableMap({ chat_id: chatId, state: 'open' }); + return dispatch(changeSetting(['chats', 'panes'], panes.push(newPane))); + } + }; +} + +export function closeChat(chatId) { + return (dispatch, getState) => { + const panes = getSettings(getState()).getIn(['chats', 'panes']); + const idx = panes.findIndex(pane => pane.get('chat_id') === chatId); + + if (idx > -1) { + return dispatch(changeSetting(['chats', 'panes'], panes.delete(idx))); + } else { + return false; + } + }; +} + +export function toggleChat(chatId) { + return (dispatch, getState) => { + const panes = getSettings(getState()).getIn(['chats', 'panes']); + const [idx, pane] = panes.findEntry(pane => pane.get('chat_id') === chatId); + + if (idx > -1) { + const state = pane.get('state') === 'minimized' ? 'open' : 'minimized'; + if (state === 'open') dispatch(markChatRead(chatId)); + return dispatch(changeSetting(['chats', 'panes', idx, 'state'], state)); + } else { + return false; + } + }; +} + +export function toggleMainWindow() { + return (dispatch, getState) => { + const main = getSettings(getState()).getIn(['chats', 'mainWindow']); + const state = main === 'minimized' ? 'open' : 'minimized'; + return dispatch(changeSetting(['chats', 'mainWindow'], state)); + }; +} + +export function fetchChat(chatId) { + return (dispatch, getState) => { + dispatch({ type: CHAT_FETCH_REQUEST, chatId }); + return api(getState).get(`/api/v1/pleroma/chats/${chatId}`).then(({ data }) => { + dispatch({ type: CHAT_FETCH_SUCCESS, chat: data }); + }).catch(error => { + dispatch({ type: CHAT_FETCH_FAIL, chatId, error }); + }); + }; +} + +export function startChat(accountId) { + return (dispatch, getState) => { + dispatch({ type: CHAT_FETCH_REQUEST, accountId }); + return api(getState).post(`/api/v1/pleroma/chats/by-account-id/${accountId}`).then(({ data }) => { + dispatch({ type: CHAT_FETCH_SUCCESS, chat: data }); + return data; + }).catch(error => { + dispatch({ type: CHAT_FETCH_FAIL, accountId, error }); + }); + }; +} + +export function markChatRead(chatId, lastReadId) { + return (dispatch, getState) => { + const chat = getState().getIn(['chats', chatId]); + if (!lastReadId) lastReadId = chat.get('last_message'); + + if (chat.get('unread') < 1) return; + if (!lastReadId) return; + + dispatch({ type: CHAT_READ_REQUEST, chatId, lastReadId }); + api(getState).post(`/api/v1/pleroma/chats/${chatId}/read`, { last_read_id: lastReadId }).then(({ data }) => { + dispatch({ type: CHAT_READ_SUCCESS, chat: data, lastReadId }); + }).catch(error => { + dispatch({ type: CHAT_READ_FAIL, chatId, error, lastReadId }); + }); + }; +} diff --git a/app/soapbox/actions/importer/index.js b/app/soapbox/actions/importer/index.js index aaf603608..0736dd7ce 100644 --- a/app/soapbox/actions/importer/index.js +++ b/app/soapbox/actions/importer/index.js @@ -1,5 +1,9 @@ import { getSettings } from '../settings'; -import { normalizeAccount, normalizeStatus, normalizePoll } from './normalizer'; +import { + normalizeAccount, + normalizeStatus, + normalizePoll, +} from './normalizer'; export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT'; export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT'; diff --git a/app/soapbox/actions/importer/normalizer.js b/app/soapbox/actions/importer/normalizer.js index 0edac3e5c..bcf6c3aea 100644 --- a/app/soapbox/actions/importer/normalizer.js +++ b/app/soapbox/actions/importer/normalizer.js @@ -80,3 +80,13 @@ export function normalizePoll(poll) { return normalPoll; } + +export function 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; +} diff --git a/app/soapbox/actions/notifications.js b/app/soapbox/actions/notifications.js index 8f2b2f1c2..1346c36c0 100644 --- a/app/soapbox/actions/notifications.js +++ b/app/soapbox/actions/notifications.js @@ -13,7 +13,6 @@ import { defineMessages } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { unescapeHTML } from '../utils/html'; import { getFilters, regexFromFilters } from '../selectors'; -import { fetchMarkers } from './markers'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -71,6 +70,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) { export function updateNotificationsQueue(notification, intlMessages, intlLocale, curPath) { return (dispatch, getState) => { + if (notification.type === 'pleroma:chat_mention') return; // Drop chat notifications, handle them per-chat + const showAlert = getSettings(getState()).getIn(['notifications', 'alerts', notification.type]); const filters = getFilters(getState(), { contextType: 'notifications' }); const playSound = getSettings(getState()).getIn(['notifications', 'sounds', notification.type]); @@ -173,7 +174,6 @@ export function expandNotifications({ maxId } = {}, done = noOp) { params.since_id = notifications.getIn(['items', 0, 'id']); } - dispatch(fetchMarkers(['notifications'])); dispatch(expandNotificationsRequest(isLoadingMore)); api(getState).get('/api/v1/notifications', { params }).then(response => { diff --git a/app/soapbox/actions/settings.js b/app/soapbox/actions/settings.js index 677556dee..4432fa6e0 100644 --- a/app/soapbox/actions/settings.js +++ b/app/soapbox/actions/settings.js @@ -1,7 +1,7 @@ import { debounce } from 'lodash'; import { showAlertForError } from './alerts'; import { patchMe } from 'soapbox/actions/me'; -import { Map as ImmutableMap } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; export const SETTING_CHANGE = 'SETTING_CHANGE'; export const SETTING_SAVE = 'SETTING_SAVE'; @@ -29,6 +29,11 @@ const defaultSettings = ImmutableMap({ dyslexicFont: false, demetricator: false, + chats: ImmutableMap({ + panes: ImmutableList(), + mainWindow: 'minimized', + }), + home: ImmutableMap({ shows: ImmutableMap({ reblog: true, diff --git a/app/soapbox/actions/streaming.js b/app/soapbox/actions/streaming.js index 099cbab69..a581ae0fe 100644 --- a/app/soapbox/actions/streaming.js +++ b/app/soapbox/actions/streaming.js @@ -12,6 +12,8 @@ import { fetchFilters } from './filters'; import { getSettings } from 'soapbox/actions/settings'; import messages from 'soapbox/locales/messages'; +export const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE'; + const validLocale = locale => Object.keys(messages).includes(locale); const getLocale = state => { @@ -52,6 +54,9 @@ export function connectTimelineStream(timelineId, path, pollingRefresh = null, a case 'filters_changed': dispatch(fetchFilters()); break; + case 'pleroma:chat_update': + dispatch({ type: STREAMING_CHAT_UPDATE, chat: JSON.parse(data.payload), me: getState().get('me') }); + break; } }, }; diff --git a/app/soapbox/components/sidebar_menu.js b/app/soapbox/components/sidebar_menu.js index 1eb680c94..6cc615c65 100644 --- a/app/soapbox/components/sidebar_menu.js +++ b/app/soapbox/components/sidebar_menu.js @@ -21,7 +21,6 @@ const messages = defineMessages({ followers: { id: 'account.followers', defaultMessage: 'Followers' }, follows: { id: 'account.follows', defaultMessage: 'Follows' }, profile: { id: 'account.profile', defaultMessage: 'Profile' }, - messages: { id: 'navigation_bar.messages', defaultMessage: 'Messages' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, @@ -132,10 +131,6 @@ class SidebarMenu extends ImmutablePureComponent { {intl.formatMessage(messages.profile)} - - - {intl.formatMessage(messages.messages)} - {donateUrl ? diff --git a/app/soapbox/features/account/components/header.js b/app/soapbox/features/account/components/header.js index 3e9caae12..444cb5272 100644 --- a/app/soapbox/features/account/components/header.js +++ b/app/soapbox/features/account/components/header.js @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Icon from 'soapbox/components/icon'; import Button from 'soapbox/components/button'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { isStaff } from 'soapbox/utils/accounts'; @@ -291,13 +292,10 @@ class Header extends ImmutablePureComponent { me &&
- {account.get('id') !== me && - } diff --git a/app/soapbox/features/account_timeline/components/header.js b/app/soapbox/features/account_timeline/components/header.js index c7951ec66..534a12dbc 100644 --- a/app/soapbox/features/account_timeline/components/header.js +++ b/app/soapbox/features/account_timeline/components/header.js @@ -19,7 +19,7 @@ export default class Header extends ImmutablePureComponent { onMute: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired, onUnblockDomain: PropTypes.func.isRequired, - onEndorseToggle: PropTypes.func.isRequired, + // onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, username: PropTypes.string, }; @@ -72,6 +72,10 @@ export default class Header extends ImmutablePureComponent { this.props.onUnblockDomain(domain); } + handleChat = () => { + this.props.onChat(this.props.account, this.context.router.history); + } + // handleEndorseToggle = () => { // this.props.onEndorseToggle(this.props.account); // } @@ -95,6 +99,7 @@ export default class Header extends ImmutablePureComponent { onBlock={this.handleBlock} onMention={this.handleMention} onDirect={this.handleDirect} + onChat={this.handleChat} onReblogToggle={this.handleReblogToggle} onReport={this.handleReport} onMute={this.handleMute} diff --git a/app/soapbox/features/account_timeline/containers/header_container.js b/app/soapbox/features/account_timeline/containers/header_container.js index 7049ac917..6eebe0772 100644 --- a/app/soapbox/features/account_timeline/containers/header_container.js +++ b/app/soapbox/features/account_timeline/containers/header_container.js @@ -22,6 +22,8 @@ import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { getSettings } from 'soapbox/actions/settings'; +import { startChat, openChat } from 'soapbox/actions/chats'; +import { isMobile } from 'soapbox/is_mobile'; const messages = defineMessages({ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, @@ -127,12 +129,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(unblockDomain(domain)); }, - onAddToList(account){ + onAddToList(account) { dispatch(openModal('LIST_ADDER', { accountId: account.get('id'), })); }, + onChat(account, router) { + // TODO make this faster + dispatch(startChat(account.get('id'))).then(chat => { + if (isMobile(window.innerWidth)) { + router.push(`/chats/${chat.id}`); + } else { + dispatch(openChat(chat.id)); + } + }).catch(() => {}); + }, }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/soapbox/features/chats/chat_room.js b/app/soapbox/features/chats/chat_room.js new file mode 100644 index 000000000..e6df1953d --- /dev/null +++ b/app/soapbox/features/chats/chat_room.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Avatar from 'soapbox/components/avatar'; +import { acctFull } from 'soapbox/utils/accounts'; +import { fetchChat } from 'soapbox/actions/chats'; +import ChatBox from './components/chat_box'; +import Column from 'soapbox/components/column'; +import ColumnBackButton from 'soapbox/components/column_back_button'; +import { makeGetChat } from 'soapbox/selectors'; + +const mapStateToProps = (state, { params }) => { + const getChat = makeGetChat(); + + return { + me: state.get('me'), + chat: getChat(state, { id: params.chatId }), + }; +}; + +export default @connect(mapStateToProps) +@injectIntl +class ChatRoom extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + chat: ImmutablePropTypes.map, + me: PropTypes.node, + } + + handleInputRef = (el) => { + this.inputElem = el; + this.focusInput(); + }; + + focusInput = () => { + if (!this.inputElem) return; + this.inputElem.focus(); + } + + componentDidMount() { + const { dispatch, params } = this.props; + dispatch(fetchChat(params.chatId)); + } + + render() { + const { chat } = this.props; + if (!chat) return null; + const account = chat.get('account'); + + return ( + +
+ +
+ +
+ @{acctFull(account)} +
+
+
+ +
+ ); + } + +} diff --git a/app/soapbox/features/chats/components/chat.js b/app/soapbox/features/chats/components/chat.js new file mode 100644 index 000000000..d3a60f14d --- /dev/null +++ b/app/soapbox/features/chats/components/chat.js @@ -0,0 +1,42 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +export default class Chat extends ImmutablePureComponent { + + static propTypes = { + chat: ImmutablePropTypes.map.isRequired, + 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'); + + return ( +
+
+ ); + } + +} diff --git a/app/soapbox/features/chats/components/chat_box.js b/app/soapbox/features/chats/components/chat_box.js new file mode 100644 index 000000000..0f1de7dbb --- /dev/null +++ b/app/soapbox/features/chats/components/chat_box.js @@ -0,0 +1,108 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, defineMessages } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { + fetchChatMessages, + sendChatMessage, + markChatRead, +} from 'soapbox/actions/chats'; +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import ChatMessageList from './chat_message_list'; + +const messages = defineMessages({ + placeholder: { id: 'chat_box.input.placeholder', defaultMessage: 'Send a message…' }, +}); + +const mapStateToProps = (state, { chatId }) => ({ + me: state.get('me'), + chat: state.getIn(['chats', chatId]), + chatMessageIds: state.getIn(['chat_message_lists', chatId], ImmutableOrderedSet()), +}); + +export default @connect(mapStateToProps) +@injectIntl +class ChatBox extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + chatId: PropTypes.string.isRequired, + chatMessageIds: ImmutablePropTypes.orderedSet, + chat: ImmutablePropTypes.map, + onSetInputRef: PropTypes.func, + me: PropTypes.node, + } + + state = { + content: '', + } + + handleKeyDown = (e) => { + const { chatId } = this.props; + if (e.key === 'Enter') { + this.props.dispatch(sendChatMessage(chatId, this.state)); + this.setState({ content: '' }); + e.preventDefault(); + } + } + + handleContentChange = (e) => { + this.setState({ content: e.target.value }); + } + + markRead = () => { + const { dispatch, chatId } = this.props; + dispatch(markChatRead(chatId)); + } + + handleHover = () => { + this.markRead(); + } + + setInputRef = (el) => { + const { onSetInputRef } = this.props; + this.inputElem = el; + onSetInputRef(el); + }; + + componentDidMount() { + const { dispatch, chatId } = this.props; + dispatch(fetchChatMessages(chatId)); + } + + componentDidUpdate(prevProps) { + const markReadConditions = [ + () => this.props.chat !== undefined, + () => document.activeElement === this.inputElem, + () => this.props.chat.get('unread') > 0, + ]; + + if (markReadConditions.every(c => c() === true)) + this.markRead(); + } + + render() { + const { chatMessageIds, intl } = this.props; + if (!chatMessageIds) return null; + + return ( +
+ +
+