Optimistic, nonblocking statuses

profile-avatar-switcher
Alex Gleason 2021-10-09 16:56:03 -05:00
rodzic 8c758d7577
commit 50feacbd6b
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
13 zmienionych plików z 352 dodań i 62 usunięć

Wyświetl plik

@ -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 => {

Wyświetl plik

@ -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 => {

Wyświetl plik

@ -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));
}
};
}

Wyświetl plik

@ -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>,
];
}

Wyświetl plik

@ -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) => {

Wyświetl plik

@ -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>
);
}
}

Wyświetl plik

@ -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}>

Wyświetl plik

@ -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);
};

Wyświetl plik

@ -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,
});

Wyświetl plik

@ -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;
}
}

Wyświetl plik

@ -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:

Wyświetl plik

@ -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:

Wyświetl plik

@ -729,3 +729,7 @@ a.status-card.compact:hover {
bottom: 0;
}
}
.pending-status {
opacity: 0.5;
}