diff --git a/app/soapbox/actions/chats.js b/app/soapbox/actions/chats.js index a3e743c87..729dc128e 100644 --- a/app/soapbox/actions/chats.js +++ b/app/soapbox/actions/chats.js @@ -34,20 +34,20 @@ export function fetchChats() { }; } -export function fetchChatMessages(chatId) { +export function fetchChatMessages(chatId, maxId = null) { 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 }); + dispatch({ type: CHAT_MESSAGES_FETCH_REQUEST, chatId, maxId }); + return api(getState).get(`/api/v1/pleroma/chats/${chatId}/messages`, { params: { max_id: maxId } }).then(({ data }) => { + dispatch({ type: CHAT_MESSAGES_FETCH_SUCCESS, chatId, maxId, chatMessages: data }); }).catch(error => { - dispatch({ type: CHAT_MESSAGES_FETCH_FAIL, chatId, error }); + dispatch({ type: CHAT_MESSAGES_FETCH_FAIL, chatId, maxId, error }); }); }; } export function sendChatMessage(chatId, params) { return (dispatch, getState) => { - const uuid = uuidv4(); + const uuid = `末_${Date.now()}_${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 }) => { diff --git a/app/soapbox/actions/importer/index.js b/app/soapbox/actions/importer/index.js index 0736dd7ce..44de245cf 100644 --- a/app/soapbox/actions/importer/index.js +++ b/app/soapbox/actions/importer/index.js @@ -46,6 +46,8 @@ export function importFetchedAccounts(accounts) { const normalAccounts = []; function processAccount(account) { + if (!account.id) return; + pushUnique(normalAccounts, normalizeAccount(account)); if (account.moved) { @@ -69,6 +71,8 @@ export function importFetchedStatuses(statuses) { const polls = []; function processStatus(status) { + if (!status.account.id) return; + const normalOldStatus = getState().getIn(['statuses', status.id]); const expandSpoilers = getSettings(getState()).get('expandSpoilers'); diff --git a/app/soapbox/actions/profile_hover_card.js b/app/soapbox/actions/profile_hover_card.js new file mode 100644 index 000000000..90543148d --- /dev/null +++ b/app/soapbox/actions/profile_hover_card.js @@ -0,0 +1,24 @@ +export const PROFILE_HOVER_CARD_OPEN = 'PROFILE_HOVER_CARD_OPEN'; +export const PROFILE_HOVER_CARD_UPDATE = 'PROFILE_HOVER_CARD_UPDATE'; +export const PROFILE_HOVER_CARD_CLOSE = 'PROFILE_HOVER_CARD_CLOSE'; + +export function openProfileHoverCard(ref, accountId) { + return { + type: PROFILE_HOVER_CARD_OPEN, + ref, + accountId, + }; +} + +export function updateProfileHoverCard() { + return { + type: PROFILE_HOVER_CARD_UPDATE, + }; +} + +export function closeProfileHoverCard(force = false) { + return { + type: PROFILE_HOVER_CARD_CLOSE, + force, + }; +} diff --git a/app/soapbox/actions/soapbox.js b/app/soapbox/actions/soapbox.js index eedd1787f..58a2417c7 100644 --- a/app/soapbox/actions/soapbox.js +++ b/app/soapbox/actions/soapbox.js @@ -7,7 +7,7 @@ export const SOAPBOX_CONFIG_REQUEST_FAIL = 'SOAPBOX_CONFIG_REQUEST_FAIL'; export const defaultConfig = ImmutableMap({ logo: '', banner: '', - brandColor: '#0482d8', // Azure + brandColor: '', // Empty customCss: ImmutableList(), promoPanel: ImmutableMap({ items: ImmutableList(), @@ -40,8 +40,9 @@ export function fetchSoapboxConfig() { export function fetchSoapboxJson() { return (dispatch, getState) => { - api(getState).get('/instance/soapbox.json').then(response => { - dispatch(importSoapboxConfig(response.data)); + api(getState).get('/instance/soapbox.json').then(({ data }) => { + if (!isObject(data)) throw 'soapbox.json failed'; + dispatch(importSoapboxConfig(data)); }).catch(error => { dispatch(soapboxConfigFail(error)); }); @@ -49,6 +50,9 @@ export function fetchSoapboxJson() { } export function importSoapboxConfig(soapboxConfig) { + if (!soapboxConfig.brandColor) { + soapboxConfig.brandColor = '#0482d8'; + }; return { type: SOAPBOX_CONFIG_REQUEST_SUCCESS, soapboxConfig, @@ -56,12 +60,14 @@ export function importSoapboxConfig(soapboxConfig) { } export function soapboxConfigFail(error) { - if (!error.response) { - console.error('Unable to obtain soapbox configuration: ' + error); - } return { type: SOAPBOX_CONFIG_REQUEST_FAIL, error, skipAlert: true, }; } + +// https://stackoverflow.com/a/46663081 +function isObject(o) { + return o instanceof Object && o.constructor === Object; +} diff --git a/app/soapbox/components/__tests__/__snapshots__/display_name-test.js.snap b/app/soapbox/components/__tests__/__snapshots__/display_name-test.js.snap index 59789099f..6d04016b5 100644 --- a/app/soapbox/components/__tests__/__snapshots__/display_name-test.js.snap +++ b/app/soapbox/components/__tests__/__snapshots__/display_name-test.js.snap @@ -4,16 +4,23 @@ exports[` renders display name + account name 1`] = ` - - Foo

", + + + Foo

", + } } - } - /> -
+ /> +
+
diff --git a/app/soapbox/components/__tests__/display_name-test.js b/app/soapbox/components/__tests__/display_name-test.js index 0d040c4cd..f626f94ca 100644 --- a/app/soapbox/components/__tests__/display_name-test.js +++ b/app/soapbox/components/__tests__/display_name-test.js @@ -1,7 +1,7 @@ import React from 'react'; -import renderer from 'react-test-renderer'; import { fromJS } from 'immutable'; import DisplayName from '../display_name'; +import { createComponent } from 'soapbox/test_helpers'; describe('', () => { it('renders display name + account name', () => { @@ -10,7 +10,7 @@ describe('', () => { acct: 'bar@baz', display_name_html: '

Foo

', }); - const component = renderer.create(); + const component = createComponent(); const tree = component.toJSON(); expect(tree).toMatchSnapshot(); diff --git a/app/soapbox/components/display_name.js b/app/soapbox/components/display_name.js index 7ab8b9e60..7d848a902 100644 --- a/app/soapbox/components/display_name.js +++ b/app/soapbox/components/display_name.js @@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import VerificationBadge from './verification_badge'; import { acctFull } from '../utils/accounts'; import { List as ImmutableList } from 'immutable'; +import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper'; export default class DisplayName extends React.PureComponent { @@ -42,7 +43,9 @@ export default class DisplayName extends React.PureComponent { return ( - {displayName} + + {displayName} + {suffix} {children} diff --git a/app/soapbox/components/helmet.js b/app/soapbox/components/helmet.js index 27bdb6591..f3bff8740 100644 --- a/app/soapbox/components/helmet.js +++ b/app/soapbox/components/helmet.js @@ -3,9 +3,16 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { Helmet } from'react-helmet'; +const getNotifTotals = state => { + const normNotif = state.getIn(['notifications', 'unread']); + const chatNotif = state.get('chats').reduce((acc, curr) => acc + curr.get('unread'), 0); + const notifTotals = normNotif + chatNotif; + return notifTotals; +}; + const mapStateToProps = state => ({ siteTitle: state.getIn(['instance', 'title']), - unreadCount: state.getIn(['notifications', 'unread']), + unreadCount: getNotifTotals(state), }); class SoapboxHelmet extends React.Component { diff --git a/app/soapbox/components/hover_ref_wrapper.js b/app/soapbox/components/hover_ref_wrapper.js new file mode 100644 index 000000000..652e4a387 --- /dev/null +++ b/app/soapbox/components/hover_ref_wrapper.js @@ -0,0 +1,64 @@ +import React, { useRef } from 'react'; +import PropTypes from 'prop-types'; +import { + openProfileHoverCard, + closeProfileHoverCard, +} from 'soapbox/actions/profile_hover_card'; +import { useDispatch } from 'react-redux'; +import { debounce } from 'lodash'; +import { isMobile } from 'soapbox/is_mobile'; + +const showProfileHoverCard = debounce((dispatch, ref, accountId) => { + dispatch(openProfileHoverCard(ref, accountId)); +}, 1200); + +const handleMouseEnter = (dispatch, ref, accountId) => { + return e => { + if (!isMobile(window.innerWidth)) + showProfileHoverCard(dispatch, ref, accountId); + }; +}; + +const handleMouseLeave = (dispatch) => { + return e => { + showProfileHoverCard.cancel(); + setTimeout(() => dispatch(closeProfileHoverCard()), 300); + }; +}; + +const handleClick = (dispatch) => { + return e => { + showProfileHoverCard.cancel(); + dispatch(closeProfileHoverCard(true)); + }; +}; + +export const HoverRefWrapper = ({ accountId, children, inline }) => { + const dispatch = useDispatch(); + const ref = useRef(); + const Elem = inline ? 'span' : 'div'; + + return ( + + {children} + + ); +}; + +HoverRefWrapper.propTypes = { + accountId: PropTypes.string, + children: PropTypes.node, + inline: PropTypes.bool, +}; + +HoverRefWrapper.defaultProps = { + inline: false, +}; + +export default HoverRefWrapper; diff --git a/app/soapbox/components/media_gallery.js b/app/soapbox/components/media_gallery.js index f848a396f..3eda57795 100644 --- a/app/soapbox/components/media_gallery.js +++ b/app/soapbox/components/media_gallery.js @@ -6,6 +6,7 @@ import { is } from 'immutable'; import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; +import { truncateFilename } from 'soapbox/utils/media'; import classNames from 'classnames'; import { decode } from 'blurhash'; import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio'; @@ -14,6 +15,8 @@ import { getSettings } from 'soapbox/actions/settings'; import Icon from 'soapbox/components/icon'; import StillImage from 'soapbox/components/still_image'; +const MAX_FILENAME_LENGTH = 45; + const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, }); @@ -143,10 +146,13 @@ class Item extends React.PureComponent { let thumbnail = ''; if (attachment.get('type') === 'unknown') { + const filename = truncateFilename(attachment.get('remote_url'), MAX_FILENAME_LENGTH); return ( ); @@ -214,7 +220,7 @@ class Item extends React.PureComponent { } return ( -
+
{visible && thumbnail}
diff --git a/app/soapbox/components/profile_hover_card.js b/app/soapbox/components/profile_hover_card.js new file mode 100644 index 000000000..8d234aa4b --- /dev/null +++ b/app/soapbox/components/profile_hover_card.js @@ -0,0 +1,92 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector, useDispatch } from 'react-redux'; +import { makeGetAccount } from 'soapbox/selectors'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import UserPanel from 'soapbox/features/ui/components/user_panel'; +import ActionButton from 'soapbox/features/ui/components/action_button'; +import { isAdmin, isModerator } from 'soapbox/utils/accounts'; +import Badge from 'soapbox/components/badge'; +import classNames from 'classnames'; +import { fetchRelationships } from 'soapbox/actions/accounts'; +import { usePopper } from 'react-popper'; +import { + closeProfileHoverCard, + updateProfileHoverCard, +} from 'soapbox/actions/profile_hover_card'; + +const getAccount = makeGetAccount(); + +const getBadges = (account) => { + let badges = []; + if (isAdmin(account)) badges.push(); + if (isModerator(account)) badges.push(); + if (account.getIn(['patron', 'is_patron'])) badges.push(); + return badges; +}; + +const handleMouseEnter = (dispatch) => { + return e => { + dispatch(updateProfileHoverCard()); + }; +}; + +const handleMouseLeave = (dispatch) => { + return e => { + dispatch(closeProfileHoverCard(true)); + }; +}; + +export const ProfileHoverCard = ({ visible }) => { + const dispatch = useDispatch(); + + const [popperElement, setPopperElement] = useState(null); + + const accountId = useSelector(state => state.getIn(['profile_hover_card', 'accountId'])); + const account = useSelector(state => accountId && getAccount(state, accountId)); + const targetRef = useSelector(state => state.getIn(['profile_hover_card', 'ref', 'current'])); + const badges = account ? getBadges(account) : []; + + useEffect(() => { + if (accountId) dispatch(fetchRelationships([accountId])); + }, [dispatch, accountId]); + + const { styles, attributes } = usePopper(targetRef, popperElement); + + if (!account) return null; + const accountBio = { __html: account.get('note_emojified') }; + const followedBy = account.getIn(['relationship', 'followed_by']); + + return ( +
+
+ {followedBy && + + + } +
+ + {badges.length > 0 && +
+ {badges} +
} + {account.getIn(['source', 'note'], '').length > 0 && +
} +
+
+ ); +}; + +ProfileHoverCard.propTypes = { + visible: PropTypes.bool, + accountId: PropTypes.string, + account: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, +}; + +ProfileHoverCard.defaultProps = { + visible: true, +}; + +export default injectIntl(ProfileHoverCard); diff --git a/app/soapbox/components/status.js b/app/soapbox/components/status.js index 4e76a622d..9e8a2d684 100644 --- a/app/soapbox/components/status.js +++ b/app/soapbox/components/status.js @@ -18,9 +18,8 @@ import classNames from 'classnames'; import Icon from 'soapbox/components/icon'; import PollContainer from 'soapbox/containers/poll_container'; import { NavLink } from 'react-router-dom'; -import ProfileHoverCardContainer from '../features/profile_hover_card/profile_hover_card_container'; -import { isMobile } from '../../../app/soapbox/is_mobile'; -import { debounce } from 'lodash'; +import { getDomain } from 'soapbox/utils/accounts'; +import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -81,6 +80,7 @@ class Status extends ImmutablePureComponent { onEmbed: PropTypes.func, onHeightChange: PropTypes.func, onToggleHidden: PropTypes.func, + onShowHoverProfileCard: PropTypes.func, muted: PropTypes.bool, hidden: PropTypes.bool, unread: PropTypes.bool, @@ -107,7 +107,6 @@ class Status extends ImmutablePureComponent { state = { showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia), statusId: undefined, - profileCardVisible: false, }; // Track height changes we know about to compensate scrolling @@ -150,14 +149,16 @@ class Status extends ImmutablePureComponent { } componentWillUnmount() { - if (this.node && this.props.getScrollPosition) { - const position = this.props.getScrollPosition(); - if (position !== null && this.node.offsetTop < position.top) { - requestAnimationFrame(() => { - this.props.updateScrollBottom(position.height - position.top); - }); - } - } + // FIXME: Run this code only when a status is being deleted. + // + // if (this.node && this.props.getScrollPosition) { + // const position = this.props.getScrollPosition(); + // if (position !== null && this.node.offsetTop < position.top) { + // requestAnimationFrame(() => { + // this.props.updateScrollBottom(position.height - position.top); + // }); + // } + // } } handleToggleMediaVisibility = () => { @@ -253,19 +254,6 @@ class Status extends ImmutablePureComponent { this.handleToggleMediaVisibility(); } - showProfileCard = debounce(() => { - this.setState({ profileCardVisible: true }); - }, 1200); - - handleProfileHover = e => { - if (!isMobile(window.innerWidth)) this.showProfileCard(); - } - - handleProfileLeave = e => { - this.showProfileCard.cancel(); - this.setState({ profileCardVisible: false }); - } - _properStatus() { const { status } = this.props; @@ -454,7 +442,8 @@ class Status extends ImmutablePureComponent { }; const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`; - const { profileCardVisible } = this.state; + const favicon = status.getIn(['account', 'pleroma', 'favicon']); + const domain = getDomain(status.get('account')); return ( @@ -468,17 +457,22 @@ class Status extends ImmutablePureComponent { -
+ {favicon && +
+ +
} + +
- - {statusAvatar} + + + {statusAvatar} + +
- { profileCardVisible && - - }
diff --git a/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_form-test.js.snap b/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_form-test.js.snap index 646a96d49..e78d07cab 100644 --- a/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_form-test.js.snap +++ b/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_form-test.js.snap @@ -3,6 +3,7 @@ exports[` renders correctly 1`] = `
renders correctly on load 1`] = `
+
diff --git a/app/soapbox/features/auth_login/components/otp_auth_form.js b/app/soapbox/features/auth_login/components/otp_auth_form.js index 21655f492..49aa3c735 100644 --- a/app/soapbox/features/auth_login/components/otp_auth_form.js +++ b/app/soapbox/features/auth_login/components/otp_auth_form.js @@ -54,7 +54,7 @@ class OtpAuthForm extends ImmutablePureComponent { const { code_error } = this.state; return ( - +
diff --git a/app/soapbox/features/blocks/index.js b/app/soapbox/features/blocks/index.js index ef406e3be..5b34287dd 100644 --- a/app/soapbox/features/blocks/index.js +++ b/app/soapbox/features/blocks/index.js @@ -27,7 +27,7 @@ class Blocks extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.list, + accountIds: ImmutablePropTypes.orderedSet, hasMore: PropTypes.bool, intl: PropTypes.object.isRequired, }; diff --git a/app/soapbox/features/chats/chat_room.js b/app/soapbox/features/chats/chat_room.js index e6df1953d..a9f0ccd5d 100644 --- a/app/soapbox/features/chats/chat_room.js +++ b/app/soapbox/features/chats/chat_room.js @@ -3,21 +3,24 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { injectIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; 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 { fetchChat, markChatRead } 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 { Map as ImmutableMap } from 'immutable'; import { makeGetChat } from 'soapbox/selectors'; const mapStateToProps = (state, { params }) => { const getChat = makeGetChat(); + const chat = state.getIn(['chats', params.chatId], ImmutableMap()).toJS(); return { me: state.get('me'), - chat: getChat(state, { id: params.chatId }), + chat: getChat(state, chat), }; }; @@ -42,9 +45,26 @@ class ChatRoom extends ImmutablePureComponent { this.inputElem.focus(); } + markRead = () => { + const { dispatch, chat } = this.props; + if (!chat) return; + dispatch(markChatRead(chat.get('id'))); + } + componentDidMount() { const { dispatch, params } = this.props; dispatch(fetchChat(params.chatId)); + this.markRead(); + } + + componentDidUpdate(prevProps) { + const markReadConditions = [ + () => this.props.chat, + () => this.props.chat.get('unread') > 0, + ]; + + if (markReadConditions.every(c => c())) + this.markRead(); } render() { @@ -56,12 +76,12 @@ class ChatRoom extends ImmutablePureComponent {
-
+
@{acctFull(account)}
-
+
({ chatMessageIds: state.getIn(['chat_message_lists', chatId], ImmutableOrderedSet()), }); +const fileKeyGen = () => Math.floor((Math.random() * 0x10000)); + export default @connect(mapStateToProps) @injectIntl class ChatBox extends ImmutablePureComponent { @@ -36,15 +42,63 @@ class ChatBox extends ImmutablePureComponent { me: PropTypes.node, } - state = { + initialState = () => ({ content: '', + attachment: undefined, + isUploading: false, + uploadProgress: 0, + resetFileKey: fileKeyGen(), + }) + + state = this.initialState() + + clearState = () => { + this.setState(this.initialState()); + } + + getParams = () => { + const { content, attachment } = this.state; + + return { + content, + media_id: attachment && attachment.id, + }; + } + + canSubmit = () => { + const { content, attachment } = this.state; + + const conds = [ + content.length > 0, + attachment, + ]; + + return conds.some(c => c); + } + + sendMessage = () => { + const { dispatch, chatId } = this.props; + const { isUploading } = this.state; + + if (this.canSubmit() && !isUploading) { + const params = this.getParams(); + + dispatch(sendChatMessage(chatId, params)); + this.clearState(); + } + } + + insertLine = () => { + const { content } = this.state; + this.setState({ content: content + '\n' }); } handleKeyDown = (e) => { - const { chatId } = this.props; - if (e.key === 'Enter') { - this.props.dispatch(sendChatMessage(chatId, this.state)); - this.setState({ content: '' }); + if (e.key === 'Enter' && e.shiftKey) { + this.insertLine(); + e.preventDefault(); + } else if (e.key === 'Enter') { + this.sendMessage(); e.preventDefault(); } } @@ -68,11 +122,6 @@ class ChatBox extends ImmutablePureComponent { onSetInputRef(el); }; - componentDidMount() { - const { dispatch, chatId } = this.props; - dispatch(fetchChatMessages(chatId)); - } - componentDidUpdate(prevProps) { const markReadConditions = [ () => this.props.chat !== undefined, @@ -84,20 +133,76 @@ class ChatBox extends ImmutablePureComponent { this.markRead(); } + handleRemoveFile = (e) => { + this.setState({ attachment: undefined, resetFileKey: fileKeyGen() }); + } + + onUploadProgress = (e) => { + const { loaded, total } = e; + this.setState({ uploadProgress: loaded/total }); + } + + handleFiles = (files) => { + const { dispatch } = this.props; + + this.setState({ isUploading: true }); + + const data = new FormData(); + data.append('file', files[0]); + + dispatch(uploadMedia(data, this.onUploadProgress)).then(response => { + this.setState({ attachment: response.data, isUploading: false }); + }).catch(() => { + this.setState({ isUploading: false }); + }); + } + + renderAttachment = () => { + const { attachment } = this.state; + if (!attachment) return null; + + return ( +
+
+ {truncateFilename(attachment.preview_url, 20)} +
+
+ +
+
+ ); + } + + renderActionButton = () => { + const { resetFileKey } = this.state; + + return this.canSubmit() ? ( +
+ +
+ ) : ( + + ); + } + render() { - const { chatMessageIds, intl } = this.props; + const { chatMessageIds, chatId, intl } = this.props; + const { content, isUploading, uploadProgress } = this.state; if (!chatMessageIds) return null; return (
- + + {this.renderAttachment()} +
+ {this.renderActionButton()}