diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js index 21e4de7d6..6807cec4b 100644 --- a/app/soapbox/actions/compose.js +++ b/app/soapbox/actions/compose.js @@ -6,7 +6,6 @@ import { tagHistory } from '../settings'; import { useEmoji } from './emojis'; import resizeImage from '../utils/resize_image'; import { importFetchedAccounts } from './importer'; -import { updateTimeline, dequeueTimeline } from './timelines'; import { showAlert, showAlertForError } from './alerts'; import { defineMessages } from 'react-intl'; import { openModal, closeModal } from './modal'; @@ -142,25 +141,6 @@ export function handleComposeSubmit(dispatch, getState, data, status) { dispatch(insertIntoTagHistory(data.tags || [], status)); dispatch(submitComposeSuccess({ ...data })); - - // To make the app more responsive, immediately push the status into the columns - const insertIfOnline = timelineId => { - const timeline = getState().getIn(['timelines', timelineId]); - - if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) { - const dequeueArgs = {}; - if (timelineId === 'community') dequeueArgs.onlyMedia = getSettings(getState()).getIn(['community', 'other', 'onlyMedia']); - dispatch(dequeueTimeline(timelineId, null, dequeueArgs)); - dispatch(updateTimeline(timelineId, data.id)); - } - }; - - if (data.visibility !== 'direct') { - insertIfOnline('home'); - } else if (data.visibility === 'public') { - insertIfOnline('community'); - insertIfOnline('public'); - } } const needsDescriptions = state => { diff --git a/app/soapbox/actions/statuses.js b/app/soapbox/actions/statuses.js index 041696fa8..cdfc16d94 100644 --- a/app/soapbox/actions/statuses.js +++ b/app/soapbox/actions/statuses.js @@ -48,6 +48,7 @@ export function createStatus(params, idempotencyKey) { return api(getState).post('/api/v1/statuses', params, { headers: { 'Idempotency-Key': idempotencyKey }, }).then(({ data: status }) => { + dispatch(importFetchedStatus(status)); dispatch({ type: STATUS_CREATE_SUCCESS, status, params, idempotencyKey }); return status; }).catch(error => { diff --git a/app/soapbox/actions/timelines.js b/app/soapbox/actions/timelines.js index 137714a2a..6f93d74f0 100644 --- a/app/soapbox/actions/timelines.js +++ b/app/soapbox/actions/timelines.js @@ -22,15 +22,26 @@ export const MAX_QUEUED_ITEMS = 40; export function processTimelineUpdate(timeline, status, accept) { return (dispatch, getState) => { + const me = getState().get('me'); + const ownStatus = status.account && status.account.id === me; + const hasPendingStatuses = !getState().get('pending_statuses').isEmpty(); + const columnSettings = getSettings(getState()).get(timeline, ImmutableMap()); const shouldSkipQueue = shouldFilter(fromJS(status), columnSettings); dispatch(importFetchedStatus(status)); + if (ownStatus && hasPendingStatuses) { + // WebSockets push statuses without the Idempotency-Key, + // so if we have pending statuses, don't import it from here. + // We implement optimistic non-blocking statuses. + return; + } + if (shouldSkipQueue) { - return dispatch(updateTimeline(timeline, status.id, accept)); + dispatch(updateTimeline(timeline, status.id, accept)); } else { - return dispatch(updateTimelineQueue(timeline, status.id, accept)); + dispatch(updateTimelineQueue(timeline, status.id, accept)); } }; } diff --git a/app/soapbox/components/status_list.js b/app/soapbox/components/status_list.js index c843a4b64..e781391e6 100644 --- a/app/soapbox/components/status_list.js +++ b/app/soapbox/components/status_list.js @@ -4,6 +4,7 @@ import { FormattedMessage, defineMessages } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import MaterialStatus from 'soapbox/components/material_status'; +import PendingStatus from 'soapbox/features/ui/components/pending_status'; import ImmutablePureComponent from 'react-immutable-pure-component'; import LoadGap from './load_gap'; import ScrollableList from './scrollable_list'; @@ -91,6 +92,104 @@ export default class StatusList extends ImmutablePureComponent { this.node = c; } + renderLoadGap(index) { + const { statusIds, onLoadMore, isLoading } = this.props; + + return ( + 0 ? statusIds.get(index - 1) : null} + onClick={onLoadMore} + /> + ); + } + + renderStatus(statusId) { + const { timelineId, withGroupAdmin, group } = this.props; + + return ( + + ); + } + + renderPendingStatus(statusId) { + const { timelineId, withGroupAdmin, group } = this.props; + const idempotencyKey = statusId.replace(/^pending-/, ''); + + return ( +
+
+ +
+
+ ); + } + + renderFeaturedStatuses() { + const { featuredStatusIds, timelineId } = this.props; + if (!featuredStatusIds) return null; + + return featuredStatusIds.map(statusId => ( + + )); + } + + renderStatuses() { + const { statusIds, isLoading } = this.props; + + if (isLoading || statusIds.size > 0) { + return statusIds.map((statusId, index) => { + if (statusId === null) { + return this.renderLoadGap(index); + } else if (statusId.startsWith('pending-')) { + return this.renderPendingStatus(statusId); + } else { + return this.renderStatus(statusId); + } + }); + } else { + return null; + } + } + + renderScrollableContent() { + const featuredStatuses = this.renderFeaturedStatuses(); + const statuses = this.renderStatuses(); + + if (featuredStatuses) { + return featuredStatuses.concat(statuses); + } else { + return statuses; + } + } + render() { const { statusIds, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, ...other } = this.props; @@ -107,42 +206,6 @@ export default class StatusList extends ImmutablePureComponent { ); } - let scrollableContent = (isLoading || statusIds.size > 0) ? ( - statusIds.map((statusId, index) => statusId === null ? ( - 0 ? statusIds.get(index - 1) : null} - onClick={onLoadMore} - /> - ) : ( - - )) - ) : null; - - if (scrollableContent && featuredStatusIds) { - scrollableContent = featuredStatusIds.map(statusId => ( - - )).concat(scrollableContent); - } - return [ , - {scrollableContent} + {this.renderScrollableContent()} , ]; } diff --git a/app/soapbox/features/scheduled_statuses/components/scheduled_status.js b/app/soapbox/features/scheduled_statuses/components/scheduled_status.js index ecdc5f598..5226125fc 100644 --- a/app/soapbox/features/scheduled_statuses/components/scheduled_status.js +++ b/app/soapbox/features/scheduled_statuses/components/scheduled_status.js @@ -11,7 +11,7 @@ import { getDomain } from 'soapbox/utils/accounts'; import Avatar from 'soapbox/components/avatar'; import DisplayName from 'soapbox/components/display_name'; import AttachmentThumbs from 'soapbox/components/attachment_thumbs'; -import PollPreview from './poll_preview'; +import PollPreview from 'soapbox/features/ui/components/poll_preview'; import ScheduledStatusActionBar from './scheduled_status_action_bar'; const mapStateToProps = (state, props) => { diff --git a/app/soapbox/features/ui/components/pending_status.js b/app/soapbox/features/ui/components/pending_status.js new file mode 100644 index 000000000..93707d507 --- /dev/null +++ b/app/soapbox/features/ui/components/pending_status.js @@ -0,0 +1,91 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import StatusContent from 'soapbox/components/status_content'; +import { buildStatus } from '../util/pending_status_builder'; +import classNames from 'classnames'; +import RelativeTimestamp from 'soapbox/components/relative_timestamp'; +import { Link, NavLink } from 'react-router-dom'; +import { getDomain } from 'soapbox/utils/accounts'; +import Avatar from 'soapbox/components/avatar'; +import DisplayName from 'soapbox/components/display_name'; +import AttachmentThumbs from 'soapbox/components/attachment_thumbs'; +import PollPreview from './poll_preview'; + +const mapStateToProps = (state, props) => { + const { idempotencyKey } = props; + const pendingStatus = state.getIn(['pending_statuses', idempotencyKey]); + return { + status: pendingStatus ? buildStatus(state, pendingStatus, idempotencyKey) : null, + }; +}; + +export default @connect(mapStateToProps) +class PendingStatus extends ImmutablePureComponent { + + render() { + const { status, showThread } = this.props; + if (!status) return null; + if (!status.get('account')) return null; + + const favicon = status.getIn(['account', 'pleroma', 'favicon']); + const domain = getDomain(status.get('account')); + + return ( +
+
+
+
+
+ + + + + {favicon && +
+ + + +
} + +
+
+ + + +
+ + + +
+
+ + + + + + {status.get('poll') && } + + {showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && ( + + )} + + {/* TODO */} + {/* */} +
+
+
+ ); + } + +} diff --git a/app/soapbox/features/scheduled_statuses/components/poll_preview.js b/app/soapbox/features/ui/components/poll_preview.js similarity index 94% rename from app/soapbox/features/scheduled_statuses/components/poll_preview.js rename to app/soapbox/features/ui/components/poll_preview.js index 32b7231b2..edc3c9ed2 100644 --- a/app/soapbox/features/scheduled_statuses/components/poll_preview.js +++ b/app/soapbox/features/ui/components/poll_preview.js @@ -11,7 +11,7 @@ export default class PollPreview extends ImmutablePureComponent { renderOption(option) { const { poll } = this.props; - const showResults = poll.get('voted') || poll.get('expired'); + const showResults = poll.get('voted') || poll.get('expired'); return (
  • diff --git a/app/soapbox/features/ui/util/pending_status_builder.js b/app/soapbox/features/ui/util/pending_status_builder.js new file mode 100644 index 000000000..eb2548ab8 --- /dev/null +++ b/app/soapbox/features/ui/util/pending_status_builder.js @@ -0,0 +1,44 @@ +import { fromJS } from 'immutable'; +import { normalizeStatus } from 'soapbox/actions/importer/normalizer'; +import { makeGetAccount } from 'soapbox/selectors'; + +export const buildStatus = (state, pendingStatus, idempotencyKey) => { + const getAccount = makeGetAccount(); + + const me = state.get('me'); + const account = getAccount(state, me); + + const status = normalizeStatus({ + account, + application: null, + bookmarked: false, + card: null, + content: pendingStatus.get('status', '').replaceAll('\n', '
    '), + created_at: new Date(), + emojis: [], + favourited: false, + favourites_count: 0, + id: `pending-${idempotencyKey}`, + in_reply_to_account_id: null, + in_reply_to_id: pendingStatus.get('in_reply_to_id'), + language: null, + media_attachments: [], // TODO: render pending thumbs + mentions: [], + muted: false, + pinned: false, + poll: pendingStatus.get('poll', null), + reblog: null, + reblogged: false, + reblogs_count: 0, + replies_count: 0, + sensitive: pendingStatus.get('sensitive', false), + spoiler_text: '', + tags: [], + text: null, + uri: '', + url: '', + visibility: pendingStatus.get('visibility', 'public'), + }); + + return fromJS(status).set('account', account); +}; diff --git a/app/soapbox/reducers/index.js b/app/soapbox/reducers/index.js index 2b7ecd23c..d2c4b36ff 100644 --- a/app/soapbox/reducers/index.js +++ b/app/soapbox/reducers/index.js @@ -54,6 +54,7 @@ import security from './security'; import scheduled_statuses from './scheduled_statuses'; import aliases from './aliases'; import accounts_meta from './accounts_meta'; +import pending_statuses from './pending_statuses'; const appReducer = combineReducers({ dropdown_menu, @@ -107,6 +108,7 @@ const appReducer = combineReducers({ admin_log, security, scheduled_statuses, + pending_statuses, aliases, accounts_meta, }); diff --git a/app/soapbox/reducers/pending_statuses.js b/app/soapbox/reducers/pending_statuses.js new file mode 100644 index 000000000..445324ae5 --- /dev/null +++ b/app/soapbox/reducers/pending_statuses.js @@ -0,0 +1,24 @@ +import { + STATUS_CREATE_REQUEST, + STATUS_CREATE_SUCCESS, +} from 'soapbox/actions/statuses'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const importStatus = (state, params, idempotencyKey) => { + return state.set(idempotencyKey, params); +}; + +const deleteStatus = (state, idempotencyKey) => state.delete(idempotencyKey); + +const initialState = ImmutableMap(); + +export default function pending_statuses(state = initialState, action) { + switch(action.type) { + case STATUS_CREATE_REQUEST: + return importStatus(state, fromJS(action.params), action.idempotencyKey); + case STATUS_CREATE_SUCCESS: + return deleteStatus(state, action.idempotencyKey); + default: + return state; + } +} diff --git a/app/soapbox/reducers/scheduled_statuses.js b/app/soapbox/reducers/scheduled_statuses.js index f436968b6..20e591d1f 100644 --- a/app/soapbox/reducers/scheduled_statuses.js +++ b/app/soapbox/reducers/scheduled_statuses.js @@ -19,7 +19,7 @@ const deleteStatus = (state, id) => state.delete(id); const initialState = ImmutableMap(); -export default function statuses(state = initialState, action) { +export default function scheduled_statuses(state = initialState, action) { switch(action.type) { case STATUS_IMPORT: case STATUS_CREATE_SUCCESS: diff --git a/app/soapbox/reducers/timelines.js b/app/soapbox/reducers/timelines.js index 144923849..9e2f93c77 100644 --- a/app/soapbox/reducers/timelines.js +++ b/app/soapbox/reducers/timelines.js @@ -17,6 +17,10 @@ import { ACCOUNT_MUTE_SUCCESS, ACCOUNT_UNFOLLOW_SUCCESS, } from '../actions/accounts'; +import { + STATUS_CREATE_REQUEST, + STATUS_CREATE_SUCCESS, +} from '../actions/statuses'; import { Map as ImmutableMap, List as ImmutableList, @@ -221,8 +225,74 @@ const timelineDisconnect = (state, timelineId) => { })); }; +const getTimelinesByVisibility = visibility => { + switch(visibility) { + case 'direct': + return ['direct']; + case 'public': + return ['home', 'community', 'public']; + default: + return ['home']; + } +}; + +const insertIfOnline = (state, timelineId, statusId) => { + if (state.getIn([timelineId, 'online'])) { + updateTimeline(state, timelineId, statusId); + } +}; + +const replaceItem = (state, timelineId, oldId, newId) => { + return state.updateIn([timelineId, 'items'], ids => { + const list = ImmutableList(ids); + const index = list.indexOf(oldId); + + if (index > -1) { + return ImmutableOrderedSet(list.set(index, newId)); + } else { + return ids; + } + }); +}; + +const importPendingStatus = (state, params, idempotencyKey) => { + const statusId = `pending-${idempotencyKey}`; + + return state.withMutations(state => { + const timelineIds = getTimelinesByVisibility(params.visibility); + + timelineIds.forEach(timelineId => { + insertIfOnline(state, timelineId, statusId); + }); + }); +}; + +const replacePendingStatus = (state, idempotencyKey, newId) => { + const oldId = `pending-${idempotencyKey}`; + + state.keySeq().forEach(timelineId => { + return replaceItem(state, timelineId, oldId, newId); + }); +}; + +const importStatus = (state, status, idempotencyKey) => { + return state.withMutations(state => { + replacePendingStatus(state, idempotencyKey, status.id); + + const timelineIds = getTimelinesByVisibility(status.visibility); + + timelineIds.forEach(timelineId => { + insertIfOnline(state, timelineId, status.id); + }); + }); +}; + export default function timelines(state = initialState, action) { switch(action.type) { + case STATUS_CREATE_REQUEST: + return importPendingStatus(state, action.params, action.idempotencyKey); + case STATUS_CREATE_SUCCESS: + return importStatus(state, action.status, action.idempotencyKey); case TIMELINE_EXPAND_REQUEST: return setLoading(state, action.timeline, true); case TIMELINE_EXPAND_FAIL: diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index 230c514d8..c65539df2 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -729,3 +729,7 @@ a.status-card.compact:hover { bottom: 0; } } + +.pending-status { + opacity: 0.5; +}