/g, '\n\n');
+ return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+}
+
export function normalizeAccount(account) {
account = { ...account };
@@ -22,7 +28,7 @@ export function normalizeAccount(account) {
if (account.fields) {
account.fields = account.fields.map(pair => ({
...pair,
- name_emojified: emojify(escapeTextContentForBrowser(pair.name)),
+ name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap),
value_emojified: emojify(pair.value, emojiMap),
value_plain: unescapeHTML(pair.value),
}));
@@ -56,7 +62,7 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.hidden = normalOldStatus.get('hidden');
} else {
const spoilerText = normalStatus.spoiler_text || '';
- const searchContent = [spoilerText, status.content].join('\n\n').replace(/
/g, '\n').replace(/<\/p>
/g, '\n\n');
+ const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/
/g, '\n').replace(/<\/p>
/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus);
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
@@ -70,13 +76,22 @@ export function normalizeStatus(status, normalOldStatus) {
export function normalizePoll(poll) {
const normalPoll = { ...poll };
-
const emojiMap = makeEmojiMap(normalPoll);
- normalPoll.options = poll.options.map(option => ({
+ normalPoll.options = poll.options.map((option, index) => ({
...option,
+ voted: poll.own_votes && poll.own_votes.includes(index),
title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}));
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 2dc4c57..28c6b1a 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/markers.js b/app/javascript/mastodon/actions/markers.js
new file mode 100644
index 0000000..c3a5fe8
--- /dev/null
+++ b/app/javascript/mastodon/actions/markers.js
@@ -0,0 +1,30 @@
+export const submitMarkers = () => (dispatch, getState) => {
+ const accessToken = getState().getIn(['meta', 'access_token'], '');
+ const params = {};
+
+ const lastHomeId = getState().getIn(['timelines', 'home', 'items', 0]);
+ const lastNotificationId = getState().getIn(['notifications', 'items', 0, 'id']);
+
+ if (lastHomeId) {
+ params.home = {
+ last_read_id: lastHomeId,
+ };
+ }
+
+ if (lastNotificationId) {
+ params.notifications = {
+ last_read_id: lastNotificationId,
+ };
+ }
+
+ if (Object.keys(params).length === 0) {
+ return;
+ }
+
+ const client = new XMLHttpRequest();
+
+ client.open('POST', '/api/v1/markers', false);
+ client.setRequestHeader('Content-Type', 'application/json');
+ client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
+ client.send(JSON.stringify(params));
+};
diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js
index 80e15c2..3d0299d 100644
--- a/app/javascript/mastodon/actions/modal.js
+++ b/app/javascript/mastodon/actions/modal.js
@@ -9,8 +9,9 @@ export function openModal(type, props) {
};
};
-export function closeModal() {
+export function closeModal(type) {
return {
type: MODAL_CLOSE,
+ modalType: type,
};
};
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index b0861fc..8a066b8 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -11,7 +11,10 @@ import { saveSettings } from './settings';
import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html';
-import { getFilters, regexFromFilters } from '../selectors';
+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';
@@ -22,8 +25,12 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
-export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
-export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
+export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
+export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
+
+export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
+export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
@@ -38,18 +45,27 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
}
};
+export const loadPending = () => ({
+ type: NOTIFICATIONS_LOAD_PENDING,
+});
+
export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => {
const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
- const filters = getFilters(getState(), { contextType: 'notifications' });
+ const filters = getFiltersRegex(getState(), { contextType: 'notifications' });
let filtered = false;
if (notification.type === 'mention') {
- const regex = regexFromFilters(filters);
- const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
+ const dropRegex = filters[0];
+ const regex = filters[1];
+ const searchIndex = searchTextFromRawStatus(notification.status);
+
+ if (dropRegex && dropRegex.test(searchIndex)) {
+ return;
+ }
filtered = regex && regex.test(searchIndex);
}
@@ -64,6 +80,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch({
type: NOTIFICATIONS_UPDATE,
notification,
+ usePendingItems: preferPendingItems,
meta: (playSound && !filtered) ? { sound: 'boop' } : undefined,
});
@@ -93,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();
};
@@ -117,10 +134,19 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
: excludeTypesFromFilter(activeFilter),
};
- if (!maxId && notifications.get('items').size > 0) {
- params.since_id = notifications.getIn(['items', 0, 'id']);
+ if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) {
+ const a = notifications.getIn(['pendingItems', 0, 'id']);
+ const b = notifications.getIn(['items', 0, 'id']);
+
+ if (a && b && compareId(a, b) > 0) {
+ params.since_id = a;
+ } else {
+ params.since_id = b || a;
+ }
}
+ const isLoadingRecent = !!params.since_id;
+
dispatch(expandNotificationsRequest(isLoadingMore));
api(getState).get('/api/v1/notifications', { params }).then(response => {
@@ -129,11 +155,11 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
- dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore));
+ 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();
});
};
@@ -146,11 +172,13 @@ export function expandNotificationsRequest(isLoadingMore) {
};
};
-export function expandNotificationsSuccess(notifications, next, isLoadingMore) {
+export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) {
return {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications,
next,
+ isLoadingRecent: isLoadingRecent,
+ usePendingItems,
skipLoading: !isLoadingMore,
};
};
@@ -160,6 +188,7 @@ export function expandNotificationsFail(error, isLoadingMore) {
type: NOTIFICATIONS_EXPAND_FAIL,
error,
skipLoading: !isLoadingMore,
+ skipAlert: !isLoadingMore,
};
};
@@ -191,3 +220,11 @@ export function setFilter (filterType) {
dispatch(saveSettings());
};
};
+
+export const mountNotifications = () => ({
+ type: NOTIFICATIONS_MOUNT,
+});
+
+export const unmountNotifications = () => ({
+ type: NOTIFICATIONS_UNMOUNT,
+});
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 7c06670..a178fae 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -10,6 +10,10 @@ export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
+export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
+export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
+export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
+
export function changeSearch(value) {
return {
type: SEARCH_CHANGE,
@@ -48,7 +52,7 @@ export function submitSearch() {
dispatch(importFetchedStatuses(response.data.statuses));
}
- dispatch(fetchSearchSuccess(response.data));
+ dispatch(fetchSearchSuccess(response.data, value));
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
}).catch(error => {
dispatch(fetchSearchFail(error));
@@ -62,10 +66,11 @@ export function fetchSearchRequest() {
};
};
-export function fetchSearchSuccess(results) {
+export function fetchSearchSuccess(results, searchTerm) {
return {
type: SEARCH_FETCH_SUCCESS,
results,
+ searchTerm,
};
};
@@ -76,8 +81,50 @@ export function fetchSearchFail(error) {
};
};
-export function showSearch() {
- return {
- type: SEARCH_SHOW,
- };
+export const expandSearch = type => (dispatch, getState) => {
+ const value = getState().getIn(['search', 'value']);
+ const offset = getState().getIn(['search', 'results', type]).size;
+
+ dispatch(expandSearchRequest());
+
+ api(getState).get('/api/v2/search', {
+ params: {
+ q: value,
+ type,
+ offset,
+ },
+ }).then(({ data }) => {
+ if (data.accounts) {
+ dispatch(importFetchedAccounts(data.accounts));
+ }
+
+ if (data.statuses) {
+ dispatch(importFetchedStatuses(data.statuses));
+ }
+
+ dispatch(expandSearchSuccess(data, value, type));
+ dispatch(fetchRelationships(data.accounts.map(item => item.id)));
+ }).catch(error => {
+ dispatch(expandSearchFail(error));
+ });
};
+
+export const expandSearchRequest = () => ({
+ type: SEARCH_EXPAND_REQUEST,
+});
+
+export const expandSearchSuccess = (results, searchTerm, searchType) => ({
+ type: SEARCH_EXPAND_SUCCESS,
+ results,
+ searchTerm,
+ searchType,
+});
+
+export const expandSearchFail = error => ({
+ type: SEARCH_EXPAND_FAIL,
+ error,
+});
+
+export const showSearch = () => ({
+ type: SEARCH_SHOW,
+});
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 1794538..5640201 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -4,6 +4,7 @@ import { evictStatus } from '../storage/modifier';
import { deleteFromTimelines } from './timelines';
import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer';
+import { ensureComposeIsVisible } from './compose';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@@ -25,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';
@@ -131,14 +133,15 @@ export function fetchStatusFail(id, error, skipLoading) {
};
};
-export function redraft(status) {
+export function redraft(status, raw_text) {
return {
type: REDRAFT,
status,
+ raw_text,
};
};
-export function deleteStatus(id, router, withRedraft = false) {
+export function deleteStatus(id, routerHistory, withRedraft = false) {
return (dispatch, getState) => {
let status = getState().getIn(['statuses', id]);
@@ -148,17 +151,14 @@ export function deleteStatus(id, router, withRedraft = false) {
dispatch(deleteStatusRequest(id));
- api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
+ api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
evictStatus(id);
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
if (withRedraft) {
- dispatch(redraft(status));
-
- if (!getState().getIn(['compose', 'mounted'])) {
- router.push('/statuses/new');
- }
+ dispatch(redraft(status, response.data.text));
+ ensureComposeIsVisible(getState, routerHistory);
}
}).catch(error => {
dispatch(deleteStatusFail(id, error));
@@ -321,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 c678e93..79b08bd 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,7 +66,9 @@ 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);
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index d92385e..0546686 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -1,6 +1,8 @@
import { importFetchedStatus, importFetchedStatuses } from './importer';
-import api, { getLinks } from '../api';
+import api, { getLinks } from 'mastodon/api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from 'mastodon/compare_id';
+import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
@@ -10,10 +12,15 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
-export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
+export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
+export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING';
+export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
-export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
-export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+export const loadPending = timeline => ({
+ type: TIMELINE_LOAD_PENDING,
+ timeline,
+});
export function updateTimeline(timeline, status, accept) {
return dispatch => {
@@ -27,6 +34,7 @@ export function updateTimeline(timeline, status, accept) {
type: TIMELINE_UPDATE,
timeline,
status,
+ usePendingItems: preferPendingItems,
});
};
};
@@ -71,8 +79,15 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
return;
}
- if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) {
- params.since_id = timeline.getIn(['items', 0]);
+ if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) {
+ const a = timeline.getIn(['pendingItems', 0]);
+ const b = timeline.getIn(['items', 0]);
+
+ if (a && b && compareId(a, b) > 0) {
+ params.since_id = a;
+ } else {
+ params.since_id = b || a;
+ }
}
const isLoadingRecent = !!params.since_id;
@@ -82,10 +97,10 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
api(getState).get(path, { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
- dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore));
- done();
+ dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
}).catch(error => {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
+ }).finally(() => {
done();
});
};
@@ -96,7 +111,7 @@ export const expandPublicTimeline = ({ maxId, 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 });
+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) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
@@ -115,7 +130,7 @@ export function expandTimelineRequest(timeline, isLoadingMore) {
};
};
-export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) {
+export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) {
return {
type: TIMELINE_EXPAND_SUCCESS,
timeline,
@@ -123,6 +138,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi
next,
partial,
isLoadingRecent,
+ usePendingItems,
skipLoading: !isLoadingMore,
};
};
@@ -151,9 +167,8 @@ export function connectTimeline(timeline) {
};
};
-export function disconnectTimeline(timeline) {
- return {
- type: TIMELINE_DISCONNECT,
- timeline,
- };
-};
+export const disconnectTimeline = timeline => ({
+ type: TIMELINE_DISCONNECT,
+ timeline,
+ usePendingItems: preferPendingItems,
+});
diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js
new file mode 100644
index 0000000..853e4f6
--- /dev/null
+++ b/app/javascript/mastodon/actions/trends.js
@@ -0,0 +1,32 @@
+import api from '../api';
+
+export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST';
+export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS';
+export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL';
+
+export const fetchTrends = () => (dispatch, getState) => {
+ dispatch(fetchTrendsRequest());
+
+ api(getState)
+ .get('/api/v1/trends')
+ .then(({ data }) => dispatch(fetchTrendsSuccess(data)))
+ .catch(err => dispatch(fetchTrendsFail(err)));
+};
+
+export const fetchTrendsRequest = () => ({
+ type: TRENDS_FETCH_REQUEST,
+ skipLoading: true,
+});
+
+export const fetchTrendsSuccess = trends => ({
+ type: TRENDS_FETCH_SUCCESS,
+ trends,
+ skipLoading: true,
+});
+
+export const fetchTrendsFail = error => ({
+ type: TRENDS_FETCH_FAIL,
+ error,
+ skipLoading: true,
+ skipAlert: true,
+});
diff --git a/app/javascript/mastodon/base_polyfills.js b/app/javascript/mastodon/base_polyfills.js
index 997813a..12096d9 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/compare_id.js b/app/javascript/mastodon/compare_id.js
index aaff664..66cf51c 100644
--- a/app/javascript/mastodon/compare_id.js
+++ b/app/javascript/mastodon/compare_id.js
@@ -1,10 +1,11 @@
-export default function compareId(id1, id2) {
+export default function compareId (id1, id2) {
if (id1 === id2) {
return 0;
}
+
if (id1.length === id2.length) {
return id1 > id2 ? 1 : -1;
} else {
return id1.length > id2.length ? 1 : -1;
}
-}
+};
diff --git a/app/javascript/mastodon/components/animated_number.js b/app/javascript/mastodon/components/animated_number.js
new file mode 100644
index 0000000..f3127c8
--- /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