sforkowany z mirror/soapbox
Optimistic, nonblocking statuses
rodzic
8c758d7577
commit
50feacbd6b
|
@ -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 => {
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<LoadGap
|
||||
key={'gap:' + statusIds.get(index + 1)}
|
||||
disabled={isLoading}
|
||||
maxId={index > 0 ? statusIds.get(index - 1) : null}
|
||||
onClick={onLoadMore}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderStatus(statusId) {
|
||||
const { timelineId, withGroupAdmin, group } = this.props;
|
||||
|
||||
return (
|
||||
<MaterialStatus
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
group={group}
|
||||
withGroupAdmin={withGroupAdmin}
|
||||
showThread
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderPendingStatus(statusId) {
|
||||
const { timelineId, withGroupAdmin, group } = this.props;
|
||||
const idempotencyKey = statusId.replace(/^pending-/, '');
|
||||
|
||||
return (
|
||||
<div className='material-status' key={statusId}>
|
||||
<div className='material-status__status focusable'>
|
||||
<PendingStatus
|
||||
key={statusId}
|
||||
idempotencyKey={idempotencyKey}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
group={group}
|
||||
withGroupAdmin={withGroupAdmin}
|
||||
showThread
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderFeaturedStatuses() {
|
||||
const { featuredStatusIds, timelineId } = this.props;
|
||||
if (!featuredStatusIds) return null;
|
||||
|
||||
return featuredStatusIds.map(statusId => (
|
||||
<MaterialStatus
|
||||
key={`f-${statusId}`}
|
||||
id={statusId}
|
||||
featured
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showThread
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<LoadGap
|
||||
key={'gap:' + statusIds.get(index + 1)}
|
||||
disabled={isLoading}
|
||||
maxId={index > 0 ? statusIds.get(index - 1) : null}
|
||||
onClick={onLoadMore}
|
||||
/>
|
||||
) : (
|
||||
<MaterialStatus
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
group={group}
|
||||
withGroupAdmin={withGroupAdmin}
|
||||
showThread
|
||||
/>
|
||||
))
|
||||
) : null;
|
||||
|
||||
if (scrollableContent && featuredStatusIds) {
|
||||
scrollableContent = featuredStatusIds.map(statusId => (
|
||||
<MaterialStatus
|
||||
key={`f-${statusId}`}
|
||||
id={statusId}
|
||||
featured
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showThread
|
||||
/>
|
||||
)).concat(scrollableContent);
|
||||
}
|
||||
|
||||
return [
|
||||
<TimelineQueueButtonHeader
|
||||
key='timeline-queue-button-header'
|
||||
|
@ -151,7 +214,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
message={messages.queue}
|
||||
/>,
|
||||
<ScrollableList key='scrollable-list' {...other} isLoading={isLoading} showLoading={isLoading && statusIds.size === 0} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
|
||||
{scrollableContent}
|
||||
{this.renderScrollableContent()}
|
||||
</ScrollableList>,
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 (
|
||||
<div className='pending-status'>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id') })} tabIndex={this.props.muted ? null : 0}>
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
|
||||
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
|
||||
<div className='status__info'>
|
||||
<span className='status__relative-time'>
|
||||
<RelativeTimestamp timestamp={status.get('created_at')} />
|
||||
</span>
|
||||
|
||||
{favicon &&
|
||||
<div className='status__favicon'>
|
||||
<Link to={`/timeline/${domain}`}>
|
||||
<img src={favicon} alt='' title={domain} />
|
||||
</Link>
|
||||
</div>}
|
||||
|
||||
<div className='status__profile'>
|
||||
<div className='status__avatar'>
|
||||
<NavLink to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])}>
|
||||
<Avatar account={status.get('account')} size={48} />
|
||||
</NavLink>
|
||||
</div>
|
||||
<NavLink to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name'>
|
||||
<DisplayName account={status.get('account')} />
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusContent
|
||||
status={status}
|
||||
expanded
|
||||
collapsable
|
||||
/>
|
||||
|
||||
<AttachmentThumbs
|
||||
compact
|
||||
media={status.get('media_attachments')}
|
||||
/>
|
||||
|
||||
{status.get('poll') && <PollPreview poll={status.get('poll')} />}
|
||||
|
||||
{showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
|
||||
<button className='status__content__read-more-button' onClick={this.handleClick}>
|
||||
<FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* TODO */}
|
||||
{/* <PlaceholderActionBar /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 (
|
||||
<li key={option}>
|
|
@ -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', '<br>'),
|
||||
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);
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -729,3 +729,7 @@ a.status-card.compact:hover {
|
|||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pending-status {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue