/g, '\n\n');
+ return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+}
+
export function normalizeAccount(account) {
account = { ...account };
@@ -70,7 +76,6 @@ export function normalizeStatus(status, normalOldStatus) {
export function normalizePoll(poll) {
const normalPoll = { ...poll };
-
const emojiMap = makeEmojiMap(normalPoll);
normalPoll.options = poll.options.map((option, index) => ({
@@ -81,3 +86,12 @@ export function normalizePoll(poll) {
return normalPoll;
}
+
+export function normalizeAnnouncement(announcement) {
+ const normalAnnouncement = { ...announcement };
+ const emojiMap = makeEmojiMap(normalAnnouncement);
+
+ normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
+
+ return normalAnnouncement;
+}
diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js
index 2dc4c574c..28c6b1a62 100644
--- a/app/javascript/mastodon/actions/interactions.js
+++ b/app/javascript/mastodon/actions/interactions.js
@@ -33,6 +33,14 @@ export const UNPIN_REQUEST = 'UNPIN_REQUEST';
export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
export const UNPIN_FAIL = 'UNPIN_FAIL';
+export const BOOKMARK_REQUEST = 'BOOKMARK_REQUEST';
+export const BOOKMARK_SUCCESS = 'BOOKMARKED_SUCCESS';
+export const BOOKMARK_FAIL = 'BOOKMARKED_FAIL';
+
+export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
+export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
+export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
+
export function reblog(status) {
return function (dispatch, getState) {
dispatch(reblogRequest(status));
@@ -187,6 +195,78 @@ export function unfavouriteFail(status, error) {
};
};
+export function bookmark(status) {
+ return function (dispatch, getState) {
+ dispatch(bookmarkRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) {
+ dispatch(importFetchedStatus(response.data));
+ dispatch(bookmarkSuccess(status, response.data));
+ }).catch(function (error) {
+ dispatch(bookmarkFail(status, error));
+ });
+ };
+};
+
+export function unbookmark(status) {
+ return (dispatch, getState) => {
+ dispatch(unbookmarkRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => {
+ dispatch(importFetchedStatus(response.data));
+ dispatch(unbookmarkSuccess(status, response.data));
+ }).catch(error => {
+ dispatch(unbookmarkFail(status, error));
+ });
+ };
+};
+
+export function bookmarkRequest(status) {
+ return {
+ type: BOOKMARK_REQUEST,
+ status: status,
+ };
+};
+
+export function bookmarkSuccess(status, response) {
+ return {
+ type: BOOKMARK_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function bookmarkFail(status, error) {
+ return {
+ type: BOOKMARK_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
+export function unbookmarkRequest(status) {
+ return {
+ type: UNBOOKMARK_REQUEST,
+ status: status,
+ };
+};
+
+export function unbookmarkSuccess(status, response) {
+ return {
+ type: UNBOOKMARK_SUCCESS,
+ status: status,
+ response: response,
+ };
+};
+
+export function unbookmarkFail(status, error) {
+ return {
+ type: UNBOOKMARK_FAIL,
+ status: status,
+ error: error,
+ };
+};
+
export function fetchReblogs(id) {
return (dispatch, getState) => {
dispatch(fetchReblogsRequest(id));
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 58803d1ae..8a066b896 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -14,6 +14,7 @@ import { unescapeHTML } from '../utils/html';
import { getFiltersRegex } from '../selectors';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import compareId from 'mastodon/compare_id';
+import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -60,7 +61,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
if (notification.type === 'mention') {
const dropRegex = filters[0];
const regex = filters[1];
- const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
+ const searchIndex = searchTextFromRawStatus(notification.status);
if (dropRegex && dropRegex.test(searchIndex)) {
return;
@@ -109,7 +110,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
const excludeTypesFromFilter = filter => {
- const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']);
+ const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']);
return allTypes.filterNot(item => item === filter).toJS();
};
@@ -156,9 +157,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
fetchRelatedRelationships(dispatch, response.data);
- done();
}).catch(error => {
dispatch(expandNotificationsFail(error, isLoadingMore));
+ }).finally(() => {
done();
});
};
@@ -187,6 +188,7 @@ export function expandNotificationsFail(error, isLoadingMore) {
type: NOTIFICATIONS_EXPAND_FAIL,
error,
skipLoading: !isLoadingMore,
+ skipAlert: !isLoadingMore,
};
};
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 06a19afc3..5640201c6 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -26,8 +26,9 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
-export const STATUS_REVEAL = 'STATUS_REVEAL';
-export const STATUS_HIDE = 'STATUS_HIDE';
+export const STATUS_REVEAL = 'STATUS_REVEAL';
+export const STATUS_HIDE = 'STATUS_HIDE';
+export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
export const REDRAFT = 'REDRAFT';
@@ -320,3 +321,11 @@ export function revealStatus(ids) {
ids,
};
};
+
+export function toggleStatusCollapse(id, isCollapsed) {
+ return {
+ type: STATUS_COLLAPSE,
+ id,
+ isCollapsed,
+ };
+}
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index c678e9393..080d665f4 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -8,6 +8,12 @@ import {
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
+import {
+ fetchAnnouncements,
+ updateAnnouncements,
+ updateReaction as updateAnnouncementsReaction,
+ deleteAnnouncement,
+} from './announcements';
import { fetchFilters } from './filters';
import { getLocale } from '../locales';
@@ -44,6 +50,15 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
case 'filters_changed':
dispatch(fetchFilters());
break;
+ case 'announcement':
+ dispatch(updateAnnouncements(JSON.parse(data.payload)));
+ break;
+ case 'announcement.reaction':
+ dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
+ break;
+ case 'announcement.delete':
+ dispatch(deleteAnnouncement(data.payload));
+ break;
}
},
};
@@ -51,12 +66,14 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
}
const refreshHomeTimelineAndNotification = (dispatch, done) => {
- dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
+ dispatch(expandHomeTimeline({}, () =>
+ dispatch(expandNotifications({}, () =>
+ dispatch(fetchAnnouncements(done))))));
};
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
-export const connectPublicStream = ({ onlyMedia } = {}) => connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`);
+export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index bc2ac5e82..01f0fb015 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -42,7 +42,7 @@ export function updateTimeline(timeline, status, accept) {
export function deleteFromTimelines(id) {
return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']);
- const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
+ const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id'));
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
dispatch({
@@ -98,27 +98,28 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
- done();
}).catch(error => {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
+ }).finally(() => {
done();
});
};
};
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
-export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
+export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
-export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
+export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId,
any: parseTags(tags, 'any'),
all: parseTags(tags, 'all'),
none: parseTags(tags, 'none'),
+ local: local,
}, done);
};
@@ -149,6 +150,7 @@ export function expandTimelineFail(timeline, error, isLoadingMore) {
timeline,
error,
skipLoading: !isLoadingMore,
+ skipNotFound: timeline.startsWith('account:'),
};
};
diff --git a/app/javascript/mastodon/base_polyfills.js b/app/javascript/mastodon/base_polyfills.js
index 997813a04..12096d902 100644
--- a/app/javascript/mastodon/base_polyfills.js
+++ b/app/javascript/mastodon/base_polyfills.js
@@ -6,6 +6,7 @@ import assign from 'object-assign';
import values from 'object.values';
import isNaN from 'is-nan';
import { decode as decodeBase64 } from './utils/base64';
+import promiseFinally from 'promise.prototype.finally';
if (!Array.prototype.includes) {
includes.shim();
@@ -23,6 +24,8 @@ if (!Number.isNaN) {
Number.isNaN = isNaN;
}
+promiseFinally.shim();
+
if (!HTMLCanvasElement.prototype.toBlob) {
const BASE64_MARKER = ';base64,';
diff --git a/app/javascript/mastodon/common.js b/app/javascript/mastodon/common.js
index fba21316a..6818aa5d5 100644
--- a/app/javascript/mastodon/common.js
+++ b/app/javascript/mastodon/common.js
@@ -1,4 +1,4 @@
-import Rails from 'rails-ujs';
+import Rails from '@rails/ujs';
export function start() {
require('font-awesome/css/font-awesome.css');
diff --git a/app/javascript/mastodon/components/animated_number.js b/app/javascript/mastodon/components/animated_number.js
new file mode 100644
index 000000000..f3127c88e
--- /dev/null
+++ b/app/javascript/mastodon/components/animated_number.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedNumber } from 'react-intl';
+import TransitionMotion from 'react-motion/lib/TransitionMotion';
+import spring from 'react-motion/lib/spring';
+import { reduceMotion } from 'mastodon/initial_state';
+
+export default class AnimatedNumber extends React.PureComponent {
+
+ static propTypes = {
+ value: PropTypes.number.isRequired,
+ };
+
+ state = {
+ direction: 1,
+ };
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.value > this.props.value) {
+ this.setState({ direction: 1 });
+ } else if (nextProps.value < this.props.value) {
+ this.setState({ direction: -1 });
+ }
+ }
+
+ willEnter = () => {
+ const { direction } = this.state;
+
+ return { y: -1 * direction };
+ }
+
+ willLeave = () => {
+ const { direction } = this.state;
+
+ return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
+ }
+
+ render () {
+ const { value } = this.props;
+ const { direction } = this.state;
+
+ if (reduceMotion) {
+ return