commit b1b5dc62dbe8a5110d7471b5d4b773dd590f8bc8
Author: Alex Gleason /g, '\n\n');
+ const emojiMap = makeEmojiMap(normalStatus);
+
+ normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+ normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
+ normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
+ normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
+ }
+
+ return normalStatus;
+}
+
+export function normalizePoll(poll) {
+ const normalPoll = { ...poll };
+
+ const emojiMap = makeEmojiMap(normalPoll);
+
+ normalPoll.options = poll.options.map(option => ({
+ ...option,
+ title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
+ }));
+
+ return normalPoll;
+}
diff --git a/app/gabsocial/actions/interactions.js b/app/gabsocial/actions/interactions.js
new file mode 100644
index 000000000..542b96b57
--- /dev/null
+++ b/app/gabsocial/actions/interactions.js
@@ -0,0 +1,351 @@
+import api from '../api';
+import { importFetchedAccounts, importFetchedStatus } from './importer';
+import { me } from 'gabsocial/initial_state';
+
+export const REBLOG_REQUEST = 'REBLOG_REQUEST';
+export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
+export const REBLOG_FAIL = 'REBLOG_FAIL';
+
+export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
+export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
+export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
+
+export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
+export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
+export const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
+
+export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
+export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
+export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
+
+export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
+export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
+export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
+
+export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
+export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
+export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
+
+export const PIN_REQUEST = 'PIN_REQUEST';
+export const PIN_SUCCESS = 'PIN_SUCCESS';
+export const PIN_FAIL = 'PIN_FAIL';
+
+export const UNPIN_REQUEST = 'UNPIN_REQUEST';
+export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
+export const UNPIN_FAIL = 'UNPIN_FAIL';
+
+export function reblog(status) {
+ return function (dispatch, getState) {
+ if (!me) return;
+
+ dispatch(reblogRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) {
+ // The reblog API method returns a new status wrapped around the original. In this case we are only
+ // interested in how the original is modified, hence passing it skipping the wrapper
+ dispatch(importFetchedStatus(response.data.reblog));
+ dispatch(reblogSuccess(status));
+ }).catch(function (error) {
+ dispatch(reblogFail(status, error));
+ });
+ };
+};
+
+export function unreblog(status) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(unreblogRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
+ dispatch(importFetchedStatus(response.data));
+ dispatch(unreblogSuccess(status));
+ }).catch(error => {
+ dispatch(unreblogFail(status, error));
+ });
+ };
+};
+
+export function reblogRequest(status) {
+ return {
+ type: REBLOG_REQUEST,
+ status: status,
+ skipLoading: true,
+ };
+};
+
+export function reblogSuccess(status) {
+ return {
+ type: REBLOG_SUCCESS,
+ status: status,
+ skipLoading: true,
+ };
+};
+
+export function reblogFail(status, error) {
+ return {
+ type: REBLOG_FAIL,
+ status: status,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export function unreblogRequest(status) {
+ return {
+ type: UNREBLOG_REQUEST,
+ status: status,
+ skipLoading: true,
+ };
+};
+
+export function unreblogSuccess(status) {
+ return {
+ type: UNREBLOG_SUCCESS,
+ status: status,
+ skipLoading: true,
+ };
+};
+
+export function unreblogFail(status, error) {
+ return {
+ type: UNREBLOG_FAIL,
+ status: status,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export function favourite(status) {
+ return function (dispatch, getState) {
+ if (!me) return;
+
+ dispatch(favouriteRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) {
+ dispatch(importFetchedStatus(response.data));
+ dispatch(favouriteSuccess(status));
+ }).catch(function (error) {
+ dispatch(favouriteFail(status, error));
+ });
+ };
+};
+
+export function unfavourite(status) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(unfavouriteRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => {
+ dispatch(importFetchedStatus(response.data));
+ dispatch(unfavouriteSuccess(status));
+ }).catch(error => {
+ dispatch(unfavouriteFail(status, error));
+ });
+ };
+};
+
+export function favouriteRequest(status) {
+ return {
+ type: FAVOURITE_REQUEST,
+ status: status,
+ skipLoading: true,
+ };
+};
+
+export function favouriteSuccess(status) {
+ return {
+ type: FAVOURITE_SUCCESS,
+ status: status,
+ skipLoading: true,
+ };
+};
+
+export function favouriteFail(status, error) {
+ return {
+ type: FAVOURITE_FAIL,
+ status: status,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export function unfavouriteRequest(status) {
+ return {
+ type: UNFAVOURITE_REQUEST,
+ status: status,
+ skipLoading: true,
+ };
+};
+
+export function unfavouriteSuccess(status) {
+ return {
+ type: UNFAVOURITE_SUCCESS,
+ status: status,
+ skipLoading: true,
+ };
+};
+
+export function unfavouriteFail(status, error) {
+ return {
+ type: UNFAVOURITE_FAIL,
+ status: status,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export function fetchReblogs(id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(fetchReblogsRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
+ dispatch(importFetchedAccounts(response.data));
+ dispatch(fetchReblogsSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchReblogsFail(id, error));
+ });
+ };
+};
+
+export function fetchReblogsRequest(id) {
+ return {
+ type: REBLOGS_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchReblogsSuccess(id, accounts) {
+ return {
+ type: REBLOGS_FETCH_SUCCESS,
+ id,
+ accounts,
+ };
+};
+
+export function fetchReblogsFail(id, error) {
+ return {
+ type: REBLOGS_FETCH_FAIL,
+ error,
+ };
+};
+
+export function fetchFavourites(id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(fetchFavouritesRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
+ dispatch(importFetchedAccounts(response.data));
+ dispatch(fetchFavouritesSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchFavouritesFail(id, error));
+ });
+ };
+};
+
+export function fetchFavouritesRequest(id) {
+ return {
+ type: FAVOURITES_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchFavouritesSuccess(id, accounts) {
+ return {
+ type: FAVOURITES_FETCH_SUCCESS,
+ id,
+ accounts,
+ };
+};
+
+export function fetchFavouritesFail(id, error) {
+ return {
+ type: FAVOURITES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function pin(status) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(pinRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
+ dispatch(importFetchedStatus(response.data));
+ dispatch(pinSuccess(status));
+ }).catch(error => {
+ dispatch(pinFail(status, error));
+ });
+ };
+};
+
+export function pinRequest(status) {
+ return {
+ type: PIN_REQUEST,
+ status,
+ skipLoading: true,
+ };
+};
+
+export function pinSuccess(status) {
+ return {
+ type: PIN_SUCCESS,
+ status,
+ skipLoading: true,
+ };
+};
+
+export function pinFail(status, error) {
+ return {
+ type: PIN_FAIL,
+ status,
+ error,
+ skipLoading: true,
+ };
+};
+
+export function unpin (status) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(unpinRequest(status));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
+ dispatch(importFetchedStatus(response.data));
+ dispatch(unpinSuccess(status));
+ }).catch(error => {
+ dispatch(unpinFail(status, error));
+ });
+ };
+};
+
+export function unpinRequest(status) {
+ return {
+ type: UNPIN_REQUEST,
+ status,
+ skipLoading: true,
+ };
+};
+
+export function unpinSuccess(status) {
+ return {
+ type: UNPIN_SUCCESS,
+ status,
+ skipLoading: true,
+ };
+};
+
+export function unpinFail(status, error) {
+ return {
+ type: UNPIN_FAIL,
+ status,
+ error,
+ skipLoading: true,
+ };
+};
diff --git a/app/gabsocial/actions/lists.js b/app/gabsocial/actions/lists.js
new file mode 100644
index 000000000..3461c6b33
--- /dev/null
+++ b/app/gabsocial/actions/lists.js
@@ -0,0 +1,392 @@
+import api from '../api';
+import { importFetchedAccounts } from './importer';
+import { showAlertForError } from './alerts';
+import { me } from 'gabsocial/initial_state'
+
+export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
+export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
+export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL';
+
+export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
+export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
+export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL';
+
+export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE';
+export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET';
+export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP';
+
+export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST';
+export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS';
+export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL';
+
+export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST';
+export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS';
+export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL';
+
+export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
+export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
+export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
+
+export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
+export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
+export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL';
+
+export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE';
+export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY';
+export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR';
+
+export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST';
+export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS';
+export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL';
+
+export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST';
+export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS';
+export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL';
+
+export const LIST_ADDER_RESET = 'LIST_ADDER_RESET';
+export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP';
+
+export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST';
+export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS';
+export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL';
+
+export const fetchList = id => (dispatch, getState) => {
+ if (!me) return;
+
+ if (getState().getIn(['lists', id])) {
+ return;
+ }
+
+ dispatch(fetchListRequest(id));
+
+ api(getState).get(`/api/v1/lists/${id}`)
+ .then(({ data }) => dispatch(fetchListSuccess(data)))
+ .catch(err => dispatch(fetchListFail(id, err)));
+};
+
+export const fetchListRequest = id => ({
+ type: LIST_FETCH_REQUEST,
+ id,
+});
+
+export const fetchListSuccess = list => ({
+ type: LIST_FETCH_SUCCESS,
+ list,
+});
+
+export const fetchListFail = (id, error) => ({
+ type: LIST_FETCH_FAIL,
+ id,
+ error,
+});
+
+export const fetchLists = () => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(fetchListsRequest());
+
+ api(getState).get('/api/v1/lists')
+ .then(({ data }) => dispatch(fetchListsSuccess(data)))
+ .catch(err => dispatch(fetchListsFail(err)));
+};
+
+export const fetchListsRequest = () => ({
+ type: LISTS_FETCH_REQUEST,
+});
+
+export const fetchListsSuccess = lists => ({
+ type: LISTS_FETCH_SUCCESS,
+ lists,
+});
+
+export const fetchListsFail = error => ({
+ type: LISTS_FETCH_FAIL,
+ error,
+});
+
+export const submitListEditor = shouldReset => (dispatch, getState) => {
+ const listId = getState().getIn(['listEditor', 'listId']);
+ const title = getState().getIn(['listEditor', 'title']);
+
+ if (listId === null) {
+ dispatch(createList(title, shouldReset));
+ } else {
+ dispatch(updateList(listId, title, shouldReset));
+ }
+};
+
+export const setupListEditor = listId => (dispatch, getState) => {
+ dispatch({
+ type: LIST_EDITOR_SETUP,
+ list: getState().getIn(['lists', listId]),
+ });
+
+ dispatch(fetchListAccounts(listId));
+};
+
+export const changeListEditorTitle = value => ({
+ type: LIST_EDITOR_TITLE_CHANGE,
+ value,
+});
+
+export const createList = (title, shouldReset) => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(createListRequest());
+
+ api(getState).post('/api/v1/lists', { title }).then(({ data }) => {
+ dispatch(createListSuccess(data));
+
+ if (shouldReset) {
+ dispatch(resetListEditor());
+ }
+ }).catch(err => dispatch(createListFail(err)));
+};
+
+export const createListRequest = () => ({
+ type: LIST_CREATE_REQUEST,
+});
+
+export const createListSuccess = list => ({
+ type: LIST_CREATE_SUCCESS,
+ list,
+});
+
+export const createListFail = error => ({
+ type: LIST_CREATE_FAIL,
+ error,
+});
+
+export const updateList = (id, title, shouldReset) => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(updateListRequest(id));
+
+ api(getState).put(`/api/v1/lists/${id}`, { title }).then(({ data }) => {
+ dispatch(updateListSuccess(data));
+
+ if (shouldReset) {
+ dispatch(resetListEditor());
+ }
+ }).catch(err => dispatch(updateListFail(id, err)));
+};
+
+export const updateListRequest = id => ({
+ type: LIST_UPDATE_REQUEST,
+ id,
+});
+
+export const updateListSuccess = list => ({
+ type: LIST_UPDATE_SUCCESS,
+ list,
+});
+
+export const updateListFail = (id, error) => ({
+ type: LIST_UPDATE_FAIL,
+ id,
+ error,
+});
+
+export const resetListEditor = () => ({
+ type: LIST_EDITOR_RESET,
+});
+
+export const deleteList = id => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(deleteListRequest(id));
+
+ api(getState).delete(`/api/v1/lists/${id}`)
+ .then(() => dispatch(deleteListSuccess(id)))
+ .catch(err => dispatch(deleteListFail(id, err)));
+};
+
+export const deleteListRequest = id => ({
+ type: LIST_DELETE_REQUEST,
+ id,
+});
+
+export const deleteListSuccess = id => ({
+ type: LIST_DELETE_SUCCESS,
+ id,
+});
+
+export const deleteListFail = (id, error) => ({
+ type: LIST_DELETE_FAIL,
+ id,
+ error,
+});
+
+export const fetchListAccounts = listId => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(fetchListAccountsRequest(listId));
+
+ api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
+ dispatch(importFetchedAccounts(data));
+ dispatch(fetchListAccountsSuccess(listId, data));
+ }).catch(err => dispatch(fetchListAccountsFail(listId, err)));
+};
+
+export const fetchListAccountsRequest = id => ({
+ type: LIST_ACCOUNTS_FETCH_REQUEST,
+ id,
+});
+
+export const fetchListAccountsSuccess = (id, accounts, next) => ({
+ type: LIST_ACCOUNTS_FETCH_SUCCESS,
+ id,
+ accounts,
+ next,
+});
+
+export const fetchListAccountsFail = (id, error) => ({
+ type: LIST_ACCOUNTS_FETCH_FAIL,
+ id,
+ error,
+});
+
+export const fetchListSuggestions = q => (dispatch, getState) => {
+ if (!me) return;
+
+ const params = {
+ q,
+ resolve: false,
+ limit: 4,
+ following: true,
+ };
+
+ api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => {
+ dispatch(importFetchedAccounts(data));
+ dispatch(fetchListSuggestionsReady(q, data));
+ }).catch(error => dispatch(showAlertForError(error)));
+};
+
+export const fetchListSuggestionsReady = (query, accounts) => ({
+ type: LIST_EDITOR_SUGGESTIONS_READY,
+ query,
+ accounts,
+});
+
+export const clearListSuggestions = () => ({
+ type: LIST_EDITOR_SUGGESTIONS_CLEAR,
+});
+
+export const changeListSuggestions = value => ({
+ type: LIST_EDITOR_SUGGESTIONS_CHANGE,
+ value,
+});
+
+export const addToListEditor = accountId => (dispatch, getState) => {
+ dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId));
+};
+
+export const addToList = (listId, accountId) => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(addToListRequest(listId, accountId));
+
+ api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] })
+ .then(() => dispatch(addToListSuccess(listId, accountId)))
+ .catch(err => dispatch(addToListFail(listId, accountId, err)));
+};
+
+export const addToListRequest = (listId, accountId) => ({
+ type: LIST_EDITOR_ADD_REQUEST,
+ listId,
+ accountId,
+});
+
+export const addToListSuccess = (listId, accountId) => ({
+ type: LIST_EDITOR_ADD_SUCCESS,
+ listId,
+ accountId,
+});
+
+export const addToListFail = (listId, accountId, error) => ({
+ type: LIST_EDITOR_ADD_FAIL,
+ listId,
+ accountId,
+ error,
+});
+
+export const removeFromListEditor = accountId => (dispatch, getState) => {
+ dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId));
+};
+
+export const removeFromList = (listId, accountId) => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(removeFromListRequest(listId, accountId));
+
+ api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } })
+ .then(() => dispatch(removeFromListSuccess(listId, accountId)))
+ .catch(err => dispatch(removeFromListFail(listId, accountId, err)));
+};
+
+export const removeFromListRequest = (listId, accountId) => ({
+ type: LIST_EDITOR_REMOVE_REQUEST,
+ listId,
+ accountId,
+});
+
+export const removeFromListSuccess = (listId, accountId) => ({
+ type: LIST_EDITOR_REMOVE_SUCCESS,
+ listId,
+ accountId,
+});
+
+export const removeFromListFail = (listId, accountId, error) => ({
+ type: LIST_EDITOR_REMOVE_FAIL,
+ listId,
+ accountId,
+ error,
+});
+
+export const resetListAdder = () => ({
+ type: LIST_ADDER_RESET,
+});
+
+export const setupListAdder = accountId => (dispatch, getState) => {
+ dispatch({
+ type: LIST_ADDER_SETUP,
+ account: getState().getIn(['accounts', accountId]),
+ });
+ dispatch(fetchLists());
+ dispatch(fetchAccountLists(accountId));
+};
+
+export const fetchAccountLists = accountId => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(fetchAccountListsRequest(accountId));
+
+ api(getState).get(`/api/v1/accounts/${accountId}/lists`)
+ .then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data)))
+ .catch(err => dispatch(fetchAccountListsFail(accountId, err)));
+};
+
+export const fetchAccountListsRequest = id => ({
+ type:LIST_ADDER_LISTS_FETCH_REQUEST,
+ id,
+});
+
+export const fetchAccountListsSuccess = (id, lists) => ({
+ type: LIST_ADDER_LISTS_FETCH_SUCCESS,
+ id,
+ lists,
+});
+
+export const fetchAccountListsFail = (id, err) => ({
+ type: LIST_ADDER_LISTS_FETCH_FAIL,
+ id,
+ err,
+});
+
+export const addToListAdder = listId => (dispatch, getState) => {
+ dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId'])));
+};
+
+export const removeFromListAdder = listId => (dispatch, getState) => {
+ dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId'])));
+};
diff --git a/app/gabsocial/actions/modal.js b/app/gabsocial/actions/modal.js
new file mode 100644
index 000000000..80e15c28e
--- /dev/null
+++ b/app/gabsocial/actions/modal.js
@@ -0,0 +1,16 @@
+export const MODAL_OPEN = 'MODAL_OPEN';
+export const MODAL_CLOSE = 'MODAL_CLOSE';
+
+export function openModal(type, props) {
+ return {
+ type: MODAL_OPEN,
+ modalType: type,
+ modalProps: props,
+ };
+};
+
+export function closeModal() {
+ return {
+ type: MODAL_CLOSE,
+ };
+};
diff --git a/app/gabsocial/actions/mutes.js b/app/gabsocial/actions/mutes.js
new file mode 100644
index 000000000..4e2ae15d3
--- /dev/null
+++ b/app/gabsocial/actions/mutes.js
@@ -0,0 +1,111 @@
+import api, { getLinks } from '../api';
+import { fetchRelationships } from './accounts';
+import { importFetchedAccounts } from './importer';
+import { openModal } from './modal';
+import { me } from 'gabsocial/initial_state';
+
+export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
+export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
+export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL';
+
+export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
+export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
+export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
+
+export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
+export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+
+export function fetchMutes() {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(fetchMutesRequest());
+
+ api(getState).get('/api/v1/mutes').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(importFetchedAccounts(response.data));
+ dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(fetchMutesFail(error)));
+ };
+};
+
+export function fetchMutesRequest() {
+ return {
+ type: MUTES_FETCH_REQUEST,
+ };
+};
+
+export function fetchMutesSuccess(accounts, next) {
+ return {
+ type: MUTES_FETCH_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function fetchMutesFail(error) {
+ return {
+ type: MUTES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandMutes() {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ const url = getState().getIn(['user_lists', 'mutes', 'next']);
+
+ if (url === null) {
+ return;
+ }
+
+ dispatch(expandMutesRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(importFetchedAccounts(response.data));
+ dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
+ dispatch(fetchRelationships(response.data.map(item => item.id)));
+ }).catch(error => dispatch(expandMutesFail(error)));
+ };
+};
+
+export function expandMutesRequest() {
+ return {
+ type: MUTES_EXPAND_REQUEST,
+ };
+};
+
+export function expandMutesSuccess(accounts, next) {
+ return {
+ type: MUTES_EXPAND_SUCCESS,
+ accounts,
+ next,
+ };
+};
+
+export function expandMutesFail(error) {
+ return {
+ type: MUTES_EXPAND_FAIL,
+ error,
+ };
+};
+
+export function initMuteModal(account) {
+ return dispatch => {
+ dispatch({
+ type: MUTES_INIT_MODAL,
+ account,
+ });
+
+ dispatch(openModal('MUTE'));
+ };
+}
+
+export function toggleHideNotifications() {
+ return dispatch => {
+ dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
+ };
+}
diff --git a/app/gabsocial/actions/notifications.js b/app/gabsocial/actions/notifications.js
new file mode 100644
index 000000000..5d44ed224
--- /dev/null
+++ b/app/gabsocial/actions/notifications.js
@@ -0,0 +1,274 @@
+import api, { getLinks } from '../api';
+import IntlMessageFormat from 'intl-messageformat';
+import 'intl-pluralrules';
+import { fetchRelationships } from './accounts';
+import {
+ importFetchedAccount,
+ importFetchedAccounts,
+ importFetchedStatus,
+ importFetchedStatuses,
+} from './importer';
+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 { me } from 'gabsocial/initial_state';
+
+export const NOTIFICATIONS_INITIALIZE = 'NOTIFICATIONS_INITIALIZE';
+export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
+export const NOTIFICATIONS_UPDATE_QUEUE = 'NOTIFICATIONS_UPDATE_QUEUE';
+export const NOTIFICATIONS_DEQUEUE = 'NOTIFICATIONS_DEQUEUE';
+
+export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
+export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
+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_MARK_READ = 'NOTIFICATIONS_MARK_READ';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
+
+export const MAX_QUEUED_NOTIFICATIONS = 40;
+
+defineMessages({
+ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
+ group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
+});
+
+const fetchRelatedRelationships = (dispatch, notifications) => {
+ const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
+
+ if (accountIds.length > 0) {
+ dispatch(fetchRelationships(accountIds));
+ }
+};
+
+export function initializeNotifications() {
+ return {
+ type: NOTIFICATIONS_INITIALIZE
+ };
+}
+
+export function updateNotifications(notification, intlMessages, intlLocale) {
+ return (dispatch, getState) => {
+ const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
+
+ if (showInColumn) {
+ dispatch(importFetchedAccount(notification.account));
+
+ if (notification.status) {
+ dispatch(importFetchedStatus(notification.status));
+ }
+
+ dispatch({
+ type: NOTIFICATIONS_UPDATE,
+ notification,
+ });
+
+ fetchRelatedRelationships(dispatch, [notification]);
+ }
+ };
+};
+
+export function updateNotificationsQueue(notification, intlMessages, intlLocale, curPath) {
+ return (dispatch, getState) => {
+ const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+ const filters = getFilters(getState(), { contextType: 'notifications' });
+ const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
+ let filtered = false;
+
+ const isOnNotificationsPage = curPath === '/notifications';
+
+ if (notification.type === 'mention') {
+ const regex = regexFromFilters(filters);
+ const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
+ filtered = regex && regex.test(searchIndex);
+ }
+
+ // Desktop notifications
+ if (typeof window.Notification !== 'undefined' && showAlert && !filtered) {
+ const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
+ const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
+
+ const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
+
+ notify.addEventListener('click', () => {
+ window.focus();
+ notify.close();
+ });
+ }
+
+ if (playSound && !filtered) {
+ dispatch({
+ type: NOTIFICATIONS_UPDATE_NOOP,
+ meta: { sound: 'ribbit' },
+ });
+ }
+
+ if (isOnNotificationsPage) {
+ dispatch({
+ type: NOTIFICATIONS_UPDATE_QUEUE,
+ notification,
+ intlMessages,
+ intlLocale,
+ });
+ }
+ else {
+ dispatch(updateNotifications(notification, intlMessages, intlLocale));
+ }
+ }
+};
+
+export function dequeueNotifications() {
+ return (dispatch, getState) => {
+ const queuedNotifications = getState().getIn(['notifications', 'queuedNotifications'], ImmutableList());
+ const totalQueuedNotificationsCount = getState().getIn(['notifications', 'totalQueuedNotificationsCount'], 0);
+
+ if (totalQueuedNotificationsCount == 0) {
+ return;
+ }
+ else if (totalQueuedNotificationsCount > 0 && totalQueuedNotificationsCount <= MAX_QUEUED_NOTIFICATIONS) {
+ queuedNotifications.forEach(block => {
+ dispatch(updateNotifications(block.notification, block.intlMessages, block.intlLocale));
+ });
+ }
+ else {
+ dispatch(expandNotifications());
+ }
+
+ dispatch({
+ type: NOTIFICATIONS_DEQUEUE,
+ });
+ dispatch(markReadNotifications());
+ }
+};
+
+const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
+
+const excludeTypesFromFilter = filter => {
+ const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']);
+ return allTypes.filterNot(item => item === filter).toJS();
+};
+
+const noOp = () => {};
+
+export function expandNotifications({ maxId } = {}, done = noOp) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
+ const notifications = getState().get('notifications');
+ const isLoadingMore = !!maxId;
+
+ if (notifications.get('isLoading')) {
+ done();
+ return;
+ }
+
+ const params = {
+ max_id: maxId,
+ exclude_types: activeFilter === 'all'
+ ? excludeTypesFromSettings(getState())
+ : excludeTypesFromFilter(activeFilter),
+ };
+
+ if (!maxId && notifications.get('items').size > 0) {
+ params.since_id = notifications.getIn(['items', 0, 'id']);
+ }
+
+ dispatch(expandNotificationsRequest(isLoadingMore));
+
+ api(getState).get('/api/v1/notifications', { params }).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+ 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));
+ fetchRelatedRelationships(dispatch, response.data);
+ done();
+ }).catch(error => {
+ dispatch(expandNotificationsFail(error, isLoadingMore));
+ done();
+ });
+ };
+};
+
+export function expandNotificationsRequest(isLoadingMore) {
+ return {
+ type: NOTIFICATIONS_EXPAND_REQUEST,
+ skipLoading: !isLoadingMore,
+ };
+};
+
+export function expandNotificationsSuccess(notifications, next, isLoadingMore) {
+ return {
+ type: NOTIFICATIONS_EXPAND_SUCCESS,
+ notifications,
+ next,
+ skipLoading: !isLoadingMore,
+ };
+};
+
+export function expandNotificationsFail(error, isLoadingMore) {
+ return {
+ type: NOTIFICATIONS_EXPAND_FAIL,
+ error,
+ skipLoading: !isLoadingMore,
+ };
+};
+
+export function clearNotifications() {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch({
+ type: NOTIFICATIONS_CLEAR,
+ });
+
+ api(getState).post('/api/v1/notifications/clear');
+ };
+};
+
+export function scrollTopNotifications(top) {
+ return (dispatch, getState) => {
+ dispatch({
+ type: NOTIFICATIONS_SCROLL_TOP,
+ top,
+ });
+ dispatch(markReadNotifications());
+ }
+}
+
+export function setFilter (filterType) {
+ return dispatch => {
+ dispatch({
+ type: NOTIFICATIONS_FILTER_SET,
+ path: ['notifications', 'quickFilter', 'active'],
+ value: filterType,
+ });
+ dispatch(expandNotifications());
+ dispatch(saveSettings());
+ };
+}
+
+export function markReadNotifications() {
+ return (dispatch, getState) => {
+ if (!me) return;
+ const top_notification = parseInt(getState().getIn(['notifications', 'items', 0, 'id']));
+ const last_read = getState().getIn(['notifications', 'lastRead']);
+
+ if (top_notification && top_notification > last_read) {
+ api(getState).post('/api/v1/notifications/mark_read', {id: top_notification}).then(response => {
+ dispatch({
+ type: NOTIFICATIONS_MARK_READ,
+ notification: top_notification,
+ });
+ });
+ }
+ }
+}
diff --git a/app/gabsocial/actions/onboarding.js b/app/gabsocial/actions/onboarding.js
new file mode 100644
index 000000000..a1dd3a731
--- /dev/null
+++ b/app/gabsocial/actions/onboarding.js
@@ -0,0 +1,8 @@
+import { changeSetting, saveSettings } from './settings';
+
+export const INTRODUCTION_VERSION = 20181216044202;
+
+export const closeOnboarding = () => dispatch => {
+ dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
+ dispatch(saveSettings());
+};
diff --git a/app/gabsocial/actions/pin_statuses.js b/app/gabsocial/actions/pin_statuses.js
new file mode 100644
index 000000000..79a5d8140
--- /dev/null
+++ b/app/gabsocial/actions/pin_statuses.js
@@ -0,0 +1,43 @@
+import api from '../api';
+import { importFetchedStatuses } from './importer';
+import { me } from 'gabsocial/initial_state';
+
+export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
+export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
+export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
+
+export function fetchPinnedStatuses() {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(fetchPinnedStatusesRequest());
+
+ api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => {
+ dispatch(importFetchedStatuses(response.data));
+ dispatch(fetchPinnedStatusesSuccess(response.data, null));
+ }).catch(error => {
+ dispatch(fetchPinnedStatusesFail(error));
+ });
+ };
+};
+
+export function fetchPinnedStatusesRequest() {
+ return {
+ type: PINNED_STATUSES_FETCH_REQUEST,
+ };
+};
+
+export function fetchPinnedStatusesSuccess(statuses, next) {
+ return {
+ type: PINNED_STATUSES_FETCH_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function fetchPinnedStatusesFail(error) {
+ return {
+ type: PINNED_STATUSES_FETCH_FAIL,
+ error,
+ };
+};
diff --git a/app/gabsocial/actions/polls.js b/app/gabsocial/actions/polls.js
new file mode 100644
index 000000000..8e8b82df5
--- /dev/null
+++ b/app/gabsocial/actions/polls.js
@@ -0,0 +1,60 @@
+import api from '../api';
+import { importFetchedPoll } from './importer';
+
+export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
+export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS';
+export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL';
+
+export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST';
+export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS';
+export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL';
+
+export const vote = (pollId, choices) => (dispatch, getState) => {
+ dispatch(voteRequest());
+
+ api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices })
+ .then(({ data }) => {
+ dispatch(importFetchedPoll(data));
+ dispatch(voteSuccess(data));
+ })
+ .catch(err => dispatch(voteFail(err)));
+};
+
+export const fetchPoll = pollId => (dispatch, getState) => {
+ dispatch(fetchPollRequest());
+
+ api(getState).get(`/api/v1/polls/${pollId}`)
+ .then(({ data }) => {
+ dispatch(importFetchedPoll(data));
+ dispatch(fetchPollSuccess(data));
+ })
+ .catch(err => dispatch(fetchPollFail(err)));
+};
+
+export const voteRequest = () => ({
+ type: POLL_VOTE_REQUEST,
+});
+
+export const voteSuccess = poll => ({
+ type: POLL_VOTE_SUCCESS,
+ poll,
+});
+
+export const voteFail = error => ({
+ type: POLL_VOTE_FAIL,
+ error,
+});
+
+export const fetchPollRequest = () => ({
+ type: POLL_FETCH_REQUEST,
+});
+
+export const fetchPollSuccess = poll => ({
+ type: POLL_FETCH_SUCCESS,
+ poll,
+});
+
+export const fetchPollFail = error => ({
+ type: POLL_FETCH_FAIL,
+ error,
+});
diff --git a/app/gabsocial/actions/push_notifications/index.js b/app/gabsocial/actions/push_notifications/index.js
new file mode 100644
index 000000000..2ffec500a
--- /dev/null
+++ b/app/gabsocial/actions/push_notifications/index.js
@@ -0,0 +1,23 @@
+import {
+ SET_BROWSER_SUPPORT,
+ SET_SUBSCRIPTION,
+ CLEAR_SUBSCRIPTION,
+ SET_ALERTS,
+ setAlerts,
+} from './setter';
+import { register, saveSettings } from './registerer';
+
+export {
+ SET_BROWSER_SUPPORT,
+ SET_SUBSCRIPTION,
+ CLEAR_SUBSCRIPTION,
+ SET_ALERTS,
+ register,
+};
+
+export function changeAlerts(path, value) {
+ return dispatch => {
+ dispatch(setAlerts(path, value));
+ dispatch(saveSettings());
+ };
+}
diff --git a/app/gabsocial/actions/push_notifications/registerer.js b/app/gabsocial/actions/push_notifications/registerer.js
new file mode 100644
index 000000000..b0f42b6a2
--- /dev/null
+++ b/app/gabsocial/actions/push_notifications/registerer.js
@@ -0,0 +1,133 @@
+import api from '../../api';
+import { decode as decodeBase64 } from '../../utils/base64';
+import { pushNotificationsSetting } from '../../settings';
+import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
+import { me } from '../../initial_state';
+
+// Taken from https://www.npmjs.com/package/web-push
+const urlBase64ToUint8Array = (base64String) => {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
+ const base64 = (base64String + padding)
+ .replace(/\-/g, '+')
+ .replace(/_/g, '/');
+
+ return decodeBase64(base64);
+};
+
+const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
+
+const getRegistration = () => navigator.serviceWorker.ready;
+
+const getPushSubscription = (registration) =>
+ registration.pushManager.getSubscription()
+ .then(subscription => ({ registration, subscription }));
+
+const subscribe = (registration) =>
+ registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
+ });
+
+const unsubscribe = ({ registration, subscription }) =>
+ subscription ? subscription.unsubscribe().then(() => registration) : registration;
+
+const sendSubscriptionToBackend = (subscription) => {
+ const params = { subscription };
+
+ if (me) {
+ const data = pushNotificationsSetting.get(me);
+ if (data) {
+ params.data = data;
+ }
+ }
+
+ return api().post('/api/web/push_subscriptions', params).then(response => response.data);
+};
+
+// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
+const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
+
+export function register () {
+ return (dispatch, getState) => {
+ dispatch(setBrowserSupport(supportsPushNotifications));
+
+ if (supportsPushNotifications) {
+ if (!getApplicationServerKey()) {
+ console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
+ return;
+ }
+
+ getRegistration()
+ .then(getPushSubscription)
+ .then(({ registration, subscription }) => {
+ if (subscription !== null) {
+ // We have a subscription, check if it is still valid
+ const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
+ const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
+ const serverEndpoint = getState().getIn(['push_notifications', 'subscription', 'endpoint']);
+
+ // If the VAPID public key did not change and the endpoint corresponds
+ // to the endpoint saved in the backend, the subscription is valid
+ if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
+ return subscription;
+ } else {
+ // Something went wrong, try to subscribe again
+ return unsubscribe({ registration, subscription }).then(subscribe).then(
+ subscription => sendSubscriptionToBackend(subscription));
+ }
+ }
+
+ // No subscription, try to subscribe
+ return subscribe(registration).then(
+ subscription => sendSubscriptionToBackend(subscription));
+ })
+ .then(subscription => {
+ // If we got a PushSubscription (and not a subscription object from the backend)
+ // it means that the backend subscription is valid (and was set during hydration)
+ if (!(subscription instanceof PushSubscription)) {
+ dispatch(setSubscription(subscription));
+ if (me) {
+ pushNotificationsSetting.set(me, { alerts: subscription.alerts });
+ }
+ }
+ })
+ .catch(error => {
+ if (error.code === 20 && error.name === 'AbortError') {
+ console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
+ } else if (error.code === 5 && error.name === 'InvalidCharacterError') {
+ console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
+ }
+
+ // Clear alerts and hide UI settings
+ dispatch(clearSubscription());
+ if (me) {
+ pushNotificationsSetting.remove(me);
+ }
+
+ return getRegistration()
+ .then(getPushSubscription)
+ .then(unsubscribe);
+ })
+ .catch(console.warn);
+ } else {
+ console.warn('Your browser does not support Web Push Notifications.');
+ }
+ };
+}
+
+export function saveSettings() {
+ return (_, getState) => {
+ const state = getState().get('push_notifications');
+ const subscription = state.get('subscription');
+ const alerts = state.get('alerts');
+ const data = { alerts };
+
+ api().put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
+ data,
+ }).then(() => {
+ if (me) {
+ pushNotificationsSetting.set(me, data);
+ }
+ }).catch(console.warn);
+ };
+}
diff --git a/app/gabsocial/actions/push_notifications/setter.js b/app/gabsocial/actions/push_notifications/setter.js
new file mode 100644
index 000000000..5561766bf
--- /dev/null
+++ b/app/gabsocial/actions/push_notifications/setter.js
@@ -0,0 +1,34 @@
+export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
+export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
+export const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS';
+
+export function setBrowserSupport (value) {
+ return {
+ type: SET_BROWSER_SUPPORT,
+ value,
+ };
+}
+
+export function setSubscription (subscription) {
+ return {
+ type: SET_SUBSCRIPTION,
+ subscription,
+ };
+}
+
+export function clearSubscription () {
+ return {
+ type: CLEAR_SUBSCRIPTION,
+ };
+}
+
+export function setAlerts (path, value) {
+ return dispatch => {
+ dispatch({
+ type: SET_ALERTS,
+ path,
+ value,
+ });
+ };
+}
diff --git a/app/gabsocial/actions/reports.js b/app/gabsocial/actions/reports.js
new file mode 100644
index 000000000..afa0c3412
--- /dev/null
+++ b/app/gabsocial/actions/reports.js
@@ -0,0 +1,89 @@
+import api from '../api';
+import { openModal, closeModal } from './modal';
+
+export const REPORT_INIT = 'REPORT_INIT';
+export const REPORT_CANCEL = 'REPORT_CANCEL';
+
+export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
+export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
+export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL';
+
+export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
+export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
+export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE';
+
+export function initReport(account, status) {
+ return dispatch => {
+ dispatch({
+ type: REPORT_INIT,
+ account,
+ status,
+ });
+
+ dispatch(openModal('REPORT'));
+ };
+};
+
+export function cancelReport() {
+ return {
+ type: REPORT_CANCEL,
+ };
+};
+
+export function toggleStatusReport(statusId, checked) {
+ return {
+ type: REPORT_STATUS_TOGGLE,
+ statusId,
+ checked,
+ };
+};
+
+export function submitReport() {
+ return (dispatch, getState) => {
+ dispatch(submitReportRequest());
+
+ api(getState).post('/api/v1/reports', {
+ account_id: getState().getIn(['reports', 'new', 'account_id']),
+ status_ids: getState().getIn(['reports', 'new', 'status_ids']),
+ comment: getState().getIn(['reports', 'new', 'comment']),
+ forward: getState().getIn(['reports', 'new', 'forward']),
+ }).then(response => {
+ dispatch(closeModal());
+ dispatch(submitReportSuccess(response.data));
+ }).catch(error => dispatch(submitReportFail(error)));
+ };
+};
+
+export function submitReportRequest() {
+ return {
+ type: REPORT_SUBMIT_REQUEST,
+ };
+};
+
+export function submitReportSuccess(report) {
+ return {
+ type: REPORT_SUBMIT_SUCCESS,
+ report,
+ };
+};
+
+export function submitReportFail(error) {
+ return {
+ type: REPORT_SUBMIT_FAIL,
+ error,
+ };
+};
+
+export function changeReportComment(comment) {
+ return {
+ type: REPORT_COMMENT_CHANGE,
+ comment,
+ };
+};
+
+export function changeReportForward(forward) {
+ return {
+ type: REPORT_FORWARD_CHANGE,
+ forward,
+ };
+};
diff --git a/app/gabsocial/actions/search.js b/app/gabsocial/actions/search.js
new file mode 100644
index 000000000..ee7bc1657
--- /dev/null
+++ b/app/gabsocial/actions/search.js
@@ -0,0 +1,83 @@
+import api from '../api';
+import { fetchRelationships } from './accounts';
+import { importFetchedAccounts, importFetchedStatuses } from './importer';
+
+export const SEARCH_CHANGE = 'SEARCH_CHANGE';
+export const SEARCH_CLEAR = 'SEARCH_CLEAR';
+export const SEARCH_SHOW = 'SEARCH_SHOW';
+
+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 function changeSearch(value) {
+ return {
+ type: SEARCH_CHANGE,
+ value,
+ };
+};
+
+export function clearSearch() {
+ return {
+ type: SEARCH_CLEAR,
+ };
+};
+
+export function submitSearch() {
+ return (dispatch, getState) => {
+ const value = getState().getIn(['search', 'value']);
+
+ if (value.length === 0) {
+ return;
+ }
+
+ dispatch(fetchSearchRequest());
+
+ api(getState).get('/api/v2/search', {
+ params: {
+ q: value,
+ resolve: true,
+ limit: 20,
+ },
+ }).then(response => {
+ if (response.data.accounts) {
+ dispatch(importFetchedAccounts(response.data.accounts));
+ }
+
+ if (response.data.statuses) {
+ dispatch(importFetchedStatuses(response.data.statuses));
+ }
+
+ dispatch(fetchSearchSuccess(response.data));
+ dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
+ }).catch(error => {
+ dispatch(fetchSearchFail(error));
+ });
+ };
+};
+
+export function fetchSearchRequest() {
+ return {
+ type: SEARCH_FETCH_REQUEST,
+ };
+};
+
+export function fetchSearchSuccess(results) {
+ return {
+ type: SEARCH_FETCH_SUCCESS,
+ results,
+ };
+};
+
+export function fetchSearchFail(error) {
+ return {
+ type: SEARCH_FETCH_FAIL,
+ error,
+ };
+};
+
+export function showSearch() {
+ return {
+ type: SEARCH_SHOW,
+ };
+};
diff --git a/app/gabsocial/actions/settings.js b/app/gabsocial/actions/settings.js
new file mode 100644
index 000000000..0ca8bc126
--- /dev/null
+++ b/app/gabsocial/actions/settings.js
@@ -0,0 +1,37 @@
+import api from '../api';
+import { debounce } from 'lodash';
+import { showAlertForError } from './alerts';
+import { me } from 'gabsocial/initial_state';
+
+export const SETTING_CHANGE = 'SETTING_CHANGE';
+export const SETTING_SAVE = 'SETTING_SAVE';
+
+export function changeSetting(path, value) {
+ return dispatch => {
+ dispatch({
+ type: SETTING_CHANGE,
+ path,
+ value,
+ });
+
+ dispatch(saveSettings());
+ };
+};
+
+const debouncedSave = debounce((dispatch, getState) => {
+ if (!me) return;
+
+ if (getState().getIn(['settings', 'saved'])) {
+ return;
+ }
+
+ const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS();
+
+ api().put('/api/web/settings', { data })
+ .then(() => dispatch({ type: SETTING_SAVE }))
+ .catch(error => dispatch(showAlertForError(error)));
+}, 5000, { trailing: true });
+
+export function saveSettings() {
+ return (dispatch, getState) => debouncedSave(dispatch, getState);
+};
diff --git a/app/gabsocial/actions/sidebar.js b/app/gabsocial/actions/sidebar.js
new file mode 100644
index 000000000..bd6467963
--- /dev/null
+++ b/app/gabsocial/actions/sidebar.js
@@ -0,0 +1,14 @@
+export const SIDEBAR_OPEN = 'SIDEBAR_OPEN';
+export const SIDEBAR_CLOSE = 'SIDEBAR_CLOSE';
+
+export function openSidebar() {
+ return {
+ type: SIDEBAR_OPEN,
+ };
+};
+
+export function closeSidebar() {
+ return {
+ type: SIDEBAR_CLOSE,
+ };
+};
diff --git a/app/gabsocial/actions/statuses.js b/app/gabsocial/actions/statuses.js
new file mode 100644
index 000000000..69261f41b
--- /dev/null
+++ b/app/gabsocial/actions/statuses.js
@@ -0,0 +1,329 @@
+import api from '../api';
+import openDB from '../storage/db';
+import { evictStatus } from '../storage/modifier';
+import { deleteFromTimelines } from './timelines';
+import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer';
+import { ensureComposeIsVisible } from './compose';
+import { openModal, closeModal } from './modal';
+import { me } from 'gabsocial/initial_state';
+
+export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
+export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
+export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
+
+export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
+export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
+export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
+
+export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
+export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
+export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
+
+export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
+export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
+export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
+
+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 REDRAFT = 'REDRAFT';
+
+export function fetchStatusRequest(id, skipLoading) {
+ return {
+ type: STATUS_FETCH_REQUEST,
+ id,
+ skipLoading,
+ };
+};
+
+function getFromDB(dispatch, getState, accountIndex, index, id) {
+ return new Promise((resolve, reject) => {
+ const request = index.get(id);
+
+ request.onerror = reject;
+
+ request.onsuccess = () => {
+ const promises = [];
+
+ if (!request.result) {
+ reject();
+ return;
+ }
+
+ dispatch(importStatus(request.result));
+
+ if (getState().getIn(['accounts', request.result.account], null) === null) {
+ promises.push(new Promise((accountResolve, accountReject) => {
+ const accountRequest = accountIndex.get(request.result.account);
+
+ accountRequest.onerror = accountReject;
+ accountRequest.onsuccess = () => {
+ if (!request.result) {
+ accountReject();
+ return;
+ }
+
+ dispatch(importAccount(accountRequest.result));
+ accountResolve();
+ };
+ }));
+ }
+
+ if (request.result.reblog && getState().getIn(['statuses', request.result.reblog], null) === null) {
+ promises.push(getFromDB(dispatch, getState, accountIndex, index, request.result.reblog));
+ }
+
+ resolve(Promise.all(promises));
+ };
+ });
+}
+
+export function fetchStatus(id) {
+ return (dispatch, getState) => {
+ const skipLoading = getState().getIn(['statuses', id], null) !== null;
+
+ dispatch(fetchContext(id));
+
+ if (skipLoading) {
+ return;
+ }
+
+ dispatch(fetchStatusRequest(id, skipLoading));
+
+ openDB().then(db => {
+ const transaction = db.transaction(['accounts', 'statuses'], 'read');
+ const accountIndex = transaction.objectStore('accounts').index('id');
+ const index = transaction.objectStore('statuses').index('id');
+
+ return getFromDB(dispatch, getState, accountIndex, index, id).then(() => {
+ db.close();
+ }, error => {
+ db.close();
+ throw error;
+ });
+ }).then(() => {
+ dispatch(fetchStatusSuccess(skipLoading));
+ }, () => api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+ dispatch(importFetchedStatus(response.data));
+ dispatch(fetchStatusSuccess(skipLoading));
+ })).catch(error => {
+ dispatch(fetchStatusFail(id, error, skipLoading));
+ });
+ };
+};
+
+export function fetchStatusSuccess(skipLoading) {
+ return {
+ type: STATUS_FETCH_SUCCESS,
+ skipLoading,
+ };
+};
+
+export function fetchStatusFail(id, error, skipLoading) {
+ return {
+ type: STATUS_FETCH_FAIL,
+ id,
+ error,
+ skipLoading,
+ skipAlert: true,
+ };
+};
+
+export function redraft(status, raw_text) {
+ return {
+ type: REDRAFT,
+ status,
+ raw_text,
+ };
+};
+
+export function deleteStatus(id, routerHistory, withRedraft = false) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ let status = getState().getIn(['statuses', id]);
+
+ if (status.get('poll')) {
+ status = status.set('poll', getState().getIn(['polls', status.get('poll')]));
+ }
+
+ dispatch(deleteStatusRequest(id));
+
+ api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
+ evictStatus(id);
+ dispatch(deleteStatusSuccess(id));
+ dispatch(deleteFromTimelines(id));
+
+ if (withRedraft) {
+ dispatch(redraft(status, response.data.text));
+ dispatch(openModal('COMPOSE'));
+ }
+ }).catch(error => {
+ dispatch(deleteStatusFail(id, error));
+ });
+ };
+};
+
+export function deleteStatusRequest(id) {
+ return {
+ type: STATUS_DELETE_REQUEST,
+ id: id,
+ };
+};
+
+export function deleteStatusSuccess(id) {
+ return {
+ type: STATUS_DELETE_SUCCESS,
+ id: id,
+ };
+};
+
+export function deleteStatusFail(id, error) {
+ return {
+ type: STATUS_DELETE_FAIL,
+ id: id,
+ error: error,
+ };
+};
+
+export function fetchContext(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchContextRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
+ dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants)));
+ dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
+
+ }).catch(error => {
+ if (error.response && error.response.status === 404) {
+ dispatch(deleteFromTimelines(id));
+ }
+
+ dispatch(fetchContextFail(id, error));
+ });
+ };
+};
+
+export function fetchContextRequest(id) {
+ return {
+ type: CONTEXT_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchContextSuccess(id, ancestors, descendants) {
+ return {
+ type: CONTEXT_FETCH_SUCCESS,
+ id,
+ ancestors,
+ descendants,
+ statuses: ancestors.concat(descendants),
+ };
+};
+
+export function fetchContextFail(id, error) {
+ return {
+ type: CONTEXT_FETCH_FAIL,
+ id,
+ error,
+ skipAlert: true,
+ };
+};
+
+export function muteStatus(id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(muteStatusRequest(id));
+
+ api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => {
+ dispatch(muteStatusSuccess(id));
+ }).catch(error => {
+ dispatch(muteStatusFail(id, error));
+ });
+ };
+};
+
+export function muteStatusRequest(id) {
+ return {
+ type: STATUS_MUTE_REQUEST,
+ id,
+ };
+};
+
+export function muteStatusSuccess(id) {
+ return {
+ type: STATUS_MUTE_SUCCESS,
+ id,
+ };
+};
+
+export function muteStatusFail(id, error) {
+ return {
+ type: STATUS_MUTE_FAIL,
+ id,
+ error,
+ };
+};
+
+export function unmuteStatus(id) {
+ return (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch(unmuteStatusRequest(id));
+
+ api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => {
+ dispatch(unmuteStatusSuccess(id));
+ }).catch(error => {
+ dispatch(unmuteStatusFail(id, error));
+ });
+ };
+};
+
+export function unmuteStatusRequest(id) {
+ return {
+ type: STATUS_UNMUTE_REQUEST,
+ id,
+ };
+};
+
+export function unmuteStatusSuccess(id) {
+ return {
+ type: STATUS_UNMUTE_SUCCESS,
+ id,
+ };
+};
+
+export function unmuteStatusFail(id, error) {
+ return {
+ type: STATUS_UNMUTE_FAIL,
+ id,
+ error,
+ };
+};
+
+export function hideStatus(ids) {
+ if (!Array.isArray(ids)) {
+ ids = [ids];
+ }
+
+ return {
+ type: STATUS_HIDE,
+ ids,
+ };
+};
+
+export function revealStatus(ids) {
+ if (!Array.isArray(ids)) {
+ ids = [ids];
+ }
+
+ return {
+ type: STATUS_REVEAL,
+ ids,
+ };
+};
diff --git a/app/gabsocial/actions/store.js b/app/gabsocial/actions/store.js
new file mode 100644
index 000000000..34dcafc51
--- /dev/null
+++ b/app/gabsocial/actions/store.js
@@ -0,0 +1,24 @@
+import { Iterable, fromJS } from 'immutable';
+import { hydrateCompose } from './compose';
+import { importFetchedAccounts } from './importer';
+
+export const STORE_HYDRATE = 'STORE_HYDRATE';
+export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
+
+const convertState = rawState =>
+ fromJS(rawState, (k, v) =>
+ Iterable.isIndexed(v) ? v.toList() : v.toMap());
+
+export function hydrateStore(rawState) {
+ return dispatch => {
+ const state = convertState(rawState);
+
+ dispatch({
+ type: STORE_HYDRATE,
+ state,
+ });
+
+ dispatch(hydrateCompose());
+ dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
+ };
+};
diff --git a/app/gabsocial/actions/streaming.js b/app/gabsocial/actions/streaming.js
new file mode 100644
index 000000000..e599805e7
--- /dev/null
+++ b/app/gabsocial/actions/streaming.js
@@ -0,0 +1,63 @@
+import { connectStream } from '../stream';
+import {
+ deleteFromTimelines,
+ expandHomeTimeline,
+ connectTimeline,
+ disconnectTimeline,
+ updateTimelineQueue,
+} from './timelines';
+import { updateNotificationsQueue, expandNotifications } from './notifications';
+import { updateConversations } from './conversations';
+import { fetchFilters } from './filters';
+import { getLocale } from '../locales';
+
+const { messages } = getLocale();
+
+export function connectTimelineStream (timelineId, path, pollingRefresh = null, accept = null) {
+
+ return connectStream (path, pollingRefresh, (dispatch, getState) => {
+ const locale = getState().getIn(['meta', 'locale']);
+
+ return {
+ onConnect() {
+ dispatch(connectTimeline(timelineId));
+ },
+
+ onDisconnect() {
+ dispatch(disconnectTimeline(timelineId));
+ },
+
+ onReceive (data) {
+ switch(data.event) {
+ case 'update':
+ dispatch(updateTimelineQueue(timelineId, JSON.parse(data.payload), accept));
+ break;
+ case 'delete':
+ dispatch(deleteFromTimelines(data.payload));
+ break;
+ case 'notification':
+ dispatch(updateNotificationsQueue(JSON.parse(data.payload), messages, locale, window.location.pathname));
+ break;
+ case 'conversation':
+ dispatch(updateConversations(JSON.parse(data.payload)));
+ break;
+ case 'filters_changed':
+ dispatch(fetchFilters());
+ break;
+ }
+ },
+ };
+ });
+}
+
+const refreshHomeTimelineAndNotification = (dispatch, done) => {
+ dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, 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 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}`);
+export const connectGroupStream = id => connectTimelineStream(`group:${id}`, `group&group=${id}`);
diff --git a/app/gabsocial/actions/suggestions.js b/app/gabsocial/actions/suggestions.js
new file mode 100644
index 000000000..5394fd81c
--- /dev/null
+++ b/app/gabsocial/actions/suggestions.js
@@ -0,0 +1,55 @@
+import api from '../api';
+import { importFetchedAccounts } from './importer';
+import { me } from 'gabsocial/initial_state';
+
+export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
+export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
+export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
+
+export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
+
+export function fetchSuggestions() {
+ return (dispatch, getState) => {
+ dispatch(fetchSuggestionsRequest());
+
+ api(getState).get('/api/v1/suggestions').then(response => {
+ dispatch(importFetchedAccounts(response.data));
+ dispatch(fetchSuggestionsSuccess(response.data));
+ }).catch(error => dispatch(fetchSuggestionsFail(error)));
+ };
+};
+
+export function fetchSuggestionsRequest() {
+ return {
+ type: SUGGESTIONS_FETCH_REQUEST,
+ skipLoading: true,
+ };
+};
+
+export function fetchSuggestionsSuccess(accounts) {
+ return {
+ type: SUGGESTIONS_FETCH_SUCCESS,
+ accounts,
+ skipLoading: true,
+ };
+};
+
+export function fetchSuggestionsFail(error) {
+ return {
+ type: SUGGESTIONS_FETCH_FAIL,
+ error,
+ skipLoading: true,
+ skipAlert: true,
+ };
+};
+
+export const dismissSuggestion = accountId => (dispatch, getState) => {
+ if (!me) return;
+
+ dispatch({
+ type: SUGGESTIONS_DISMISS,
+ id: accountId,
+ });
+
+ api(getState).delete(`/api/v1/suggestions/${accountId}`);
+};
diff --git a/app/gabsocial/actions/timelines.js b/app/gabsocial/actions/timelines.js
new file mode 100644
index 000000000..78f372b23
--- /dev/null
+++ b/app/gabsocial/actions/timelines.js
@@ -0,0 +1,221 @@
+import { importFetchedStatus, importFetchedStatuses } from './importer';
+import api, { getLinks } from '../api';
+import { Map as ImmutableMap, List as ImmutableList, toJS } from 'immutable';
+
+export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
+export const TIMELINE_DELETE = 'TIMELINE_DELETE';
+export const TIMELINE_CLEAR = 'TIMELINE_CLEAR';
+export const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE';
+export const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE';
+export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
+
+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_CONNECT = 'TIMELINE_CONNECT';
+export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+
+export const MAX_QUEUED_ITEMS = 40;
+
+export function updateTimeline(timeline, status, accept) {
+ return dispatch => {
+ if (typeof accept === 'function' && !accept(status)) {
+ return;
+ }
+
+ dispatch(importFetchedStatus(status));
+
+ dispatch({
+ type: TIMELINE_UPDATE,
+ timeline,
+ status,
+ });
+ };
+};
+
+export function updateTimelineQueue(timeline, status, accept) {
+ return dispatch => {
+ if (typeof accept === 'function' && !accept(status)) {
+ return;
+ }
+
+ dispatch({
+ type: TIMELINE_UPDATE_QUEUE,
+ timeline,
+ status,
+ });
+ }
+};
+
+export function dequeueTimeline(timeline, expandFunc, optionalExpandArgs) {
+ return (dispatch, getState) => {
+ const queuedItems = getState().getIn(['timelines', timeline, 'queuedItems'], ImmutableList());
+ const totalQueuedItemsCount = getState().getIn(['timelines', timeline, 'totalQueuedItemsCount'], 0);
+
+ let shouldDispatchDequeue = true;
+
+ if (totalQueuedItemsCount == 0) {
+ return;
+ }
+ else if (totalQueuedItemsCount > 0 && totalQueuedItemsCount <= MAX_QUEUED_ITEMS) {
+ queuedItems.forEach(status => {
+ dispatch(updateTimeline(timeline, status.toJS(), null));
+ });
+ }
+ else {
+ if (typeof expandFunc === 'function') {
+ dispatch(clearTimeline(timeline));
+ expandFunc();
+ }
+ else {
+ if (timeline === 'home') {
+ dispatch(clearTimeline(timeline));
+ dispatch(expandHomeTimeline(optionalExpandArgs));
+ }
+ else if (timeline === 'community') {
+ dispatch(clearTimeline(timeline));
+ dispatch(expandCommunityTimeline(optionalExpandArgs));
+ }
+ else {
+ shouldDispatchDequeue = false;
+ }
+ }
+ }
+
+ if (!shouldDispatchDequeue) return;
+
+ dispatch({
+ type: TIMELINE_DEQUEUE,
+ timeline,
+ });
+ }
+};
+
+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 reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
+
+ dispatch({
+ type: TIMELINE_DELETE,
+ id,
+ accountId,
+ references,
+ reblogOf,
+ });
+ };
+};
+
+export function clearTimeline(timeline) {
+ return (dispatch) => {
+ dispatch({ type: TIMELINE_CLEAR, timeline });
+ };
+};
+
+const noOp = () => {};
+
+const parseTags = (tags = {}, mode) => {
+ return (tags[mode] || []).map((tag) => {
+ return tag.value;
+ });
+};
+
+export function expandTimeline(timelineId, path, params = {}, done = noOp) {
+ return (dispatch, getState) => {
+ const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+ const isLoadingMore = !!params.max_id;
+
+ if (timeline.get('isLoading')) {
+ done();
+ return;
+ }
+
+ if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) {
+ params.since_id = timeline.getIn(['items', 0]);
+ }
+
+ const isLoadingRecent = !!params.since_id;
+
+ dispatch(expandTimelineRequest(timelineId, isLoadingMore));
+
+ 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();
+ }).catch(error => {
+ dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
+ 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 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 expandGroupTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
+export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, 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'),
+ }, done);
+};
+
+export function expandTimelineRequest(timeline, isLoadingMore) {
+ return {
+ type: TIMELINE_EXPAND_REQUEST,
+ timeline,
+ skipLoading: !isLoadingMore,
+ };
+};
+
+export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) {
+ return {
+ type: TIMELINE_EXPAND_SUCCESS,
+ timeline,
+ statuses,
+ next,
+ partial,
+ isLoadingRecent,
+ skipLoading: !isLoadingMore,
+ };
+};
+
+export function expandTimelineFail(timeline, error, isLoadingMore) {
+ return {
+ type: TIMELINE_EXPAND_FAIL,
+ timeline,
+ error,
+ skipLoading: !isLoadingMore,
+ };
+};
+
+export function connectTimeline(timeline) {
+ return {
+ type: TIMELINE_CONNECT,
+ timeline,
+ };
+};
+
+export function disconnectTimeline(timeline) {
+ return {
+ type: TIMELINE_DISCONNECT,
+ timeline,
+ };
+};
+
+export function scrollTopTimeline(timeline, top) {
+ return {
+ type: TIMELINE_SCROLL_TOP,
+ timeline,
+ top,
+ };
+};
diff --git a/app/gabsocial/actions/trends.js b/app/gabsocial/actions/trends.js
new file mode 100644
index 000000000..b23c1c60e
--- /dev/null
+++ b/app/gabsocial/actions/trends.js
@@ -0,0 +1,39 @@
+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 function fetchTrends() {
+ return (dispatch, getState) => {
+ dispatch(fetchTrendsRequest());
+
+ api(getState).get('/api/v1/trends').then(response => {
+ dispatch(fetchTrendsSuccess(response.data));
+ }).catch(error => dispatch(fetchTrendsFail(error)));
+ };
+};
+
+export function fetchTrendsRequest() {
+ return {
+ type: TRENDS_FETCH_REQUEST,
+ skipLoading: true,
+ };
+};
+
+export function fetchTrendsSuccess(tags) {
+ return {
+ type: TRENDS_FETCH_SUCCESS,
+ tags,
+ skipLoading: true,
+ };
+};
+
+export function fetchTrendsFail(error) {
+ return {
+ type: TRENDS_FETCH_FAIL,
+ error,
+ skipLoading: true,
+ skipAlert: true,
+ };
+};
diff --git a/app/gabsocial/api.js b/app/gabsocial/api.js
new file mode 100644
index 000000000..8898bdeba
--- /dev/null
+++ b/app/gabsocial/api.js
@@ -0,0 +1,40 @@
+'use strict';
+
+import axios from 'axios';
+import LinkHeader from 'http-link-header';
+import ready from './ready';
+
+export const getLinks = response => {
+ const value = response.headers.link;
+
+ if (!value) {
+ return { refs: [] };
+ }
+
+ return LinkHeader.parse(value);
+};
+
+let csrfHeader = {};
+
+function setCSRFHeader() {
+ const csrfToken = document.querySelector('meta[name=csrf-token]');
+ if (csrfToken) {
+ csrfHeader['X-CSRF-Token'] = csrfToken.content;
+ }
+}
+
+ready(setCSRFHeader);
+
+export default getState => axios.create({
+ headers: Object.assign(csrfHeader, getState ? {
+ 'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`,
+ } : {}),
+
+ transformResponse: [function (data) {
+ try {
+ return JSON.parse(data);
+ } catch(Exception) {
+ return data;
+ }
+ }],
+});
diff --git a/app/gabsocial/base_polyfills.js b/app/gabsocial/base_polyfills.js
new file mode 100644
index 000000000..d54ed977c
--- /dev/null
+++ b/app/gabsocial/base_polyfills.js
@@ -0,0 +1,46 @@
+'use strict';
+
+import 'intl';
+import 'intl/locale-data/jsonp/en';
+import 'es6-symbol/implement';
+import includes from 'array-includes';
+import assign from 'object-assign';
+import values from 'object.values';
+import isNaN from 'is-nan';
+import { decode as decodeBase64 } from './utils/base64';
+
+if (!Array.prototype.includes) {
+ includes.shim();
+}
+
+if (!Object.assign) {
+ Object.assign = assign;
+}
+
+if (!Object.values) {
+ values.shim();
+}
+
+if (!Number.isNaN) {
+ Number.isNaN = isNaN;
+}
+
+if (!HTMLCanvasElement.prototype.toBlob) {
+ const BASE64_MARKER = ';base64,';
+
+ Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
+ value(callback, type = 'image/png', quality) {
+ const dataURL = this.toDataURL(type, quality);
+ let data;
+
+ if (dataURL.indexOf(BASE64_MARKER) >= 0) {
+ const [, base64] = dataURL.split(BASE64_MARKER);
+ data = decodeBase64(base64);
+ } else {
+ [, data] = dataURL.split(',');
+ }
+
+ callback(new Blob([data], { type }));
+ },
+ });
+}
diff --git a/app/gabsocial/common.js b/app/gabsocial/common.js
new file mode 100644
index 000000000..22146c0e0
--- /dev/null
+++ b/app/gabsocial/common.js
@@ -0,0 +1,14 @@
+'use strict';
+
+import Rails from 'rails-ujs';
+
+export function start() {
+ require('font-awesome/css/font-awesome.css');
+ require.context('../images/', true);
+
+ try {
+ Rails.start();
+ } catch (e) {
+ // If called twice
+ }
+};
diff --git a/app/gabsocial/compare_id.js b/app/gabsocial/compare_id.js
new file mode 100644
index 000000000..f8c15e327
--- /dev/null
+++ b/app/gabsocial/compare_id.js
@@ -0,0 +1,12 @@
+'use strict';
+
+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/gabsocial/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap b/app/gabsocial/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap
new file mode 100644
index 000000000..1c3727848
--- /dev/null
+++ b/app/gabsocial/components/__tests__/__snapshots__/autosuggest_emoji-test.js.snap
@@ -0,0 +1,27 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`
/g, '\n').replace(/<\/p>
children
; + const component = renderer.create(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders the props.text instead of children', () => { + const text = 'foo'; + const children =children
; + const component = renderer.create(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('renders class="button--block" if props.block given', () => { + const component = renderer.create(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); + + it('adds class "button-secondary" if props.secondary given', () => { + const component = renderer.create(); + const tree = component.toJSON(); + + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/app/gabsocial/components/__tests__/display_name-test.js b/app/gabsocial/components/__tests__/display_name-test.js new file mode 100644 index 000000000..0d040c4cd --- /dev/null +++ b/app/gabsocial/components/__tests__/display_name-test.js @@ -0,0 +1,18 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { fromJS } from 'immutable'; +import DisplayName from '../display_name'; + +describe('Foo
', + }); + const component = renderer.create(+ + {' '} + +
+ + {mentionsPlaceholder} + + +{trim(card.get('description') || '', maxDescription)}
} + {provider} +
+
+
r | +|
m | +|
p | +|
f | +|
b | +|
enter, o | +|
x | ++ |
h | +|
up, k | +
down, j | +|
1 - 9 | +|
n | +|
alt + n | +|
backspace | +|
s | +|
esc | +|
g + h | +|
g + n | +|
g + d | +
g + s | +|
g + f | +|
g + p | +|
g + u | +|
g + b | +|
g + m | +|
g + r | +|
? | +
+
+
/g, '\n\n').replace(/<[^>]*>/g, '')); + +const handlePush = (event) => { + const { access_token, notification_id, preferred_locale, title, body, icon } = event.data.json(); + + // Placeholder until more information can be loaded + event.waitUntil( + fetchFromApi(`/api/v1/notifications/${notification_id}`, 'get', access_token).then(notification => { + const options = {}; + + options.title = formatMessage(`notification.${notification.type}`, preferred_locale, { name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); + options.body = notification.status && htmlToPlainText(notification.status.content); + options.icon = notification.account.avatar_static; + options.timestamp = notification.created_at && new Date(notification.created_at); + options.tag = notification.id; + options.badge = '/badge.png'; + options.image = notification.status && notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url || undefined; + options.data = { access_token, preferred_locale, id: notification.status ? notification.status.id : notification.account.id, url: notification.status ? `/${notification.account.username}/posts/${notification.status.id}` : `/${notification.account.username}` }; + + if (notification.status && notification.status.spoiler_text || notification.status.sensitive) { + options.data.hiddenBody = htmlToPlainText(notification.status.content); + options.data.hiddenImage = notification.status.media_attachments.length > 0 && notification.status.media_attachments[0].preview_url; + + if (notification.status.spoiler_text) { + options.body = notification.status.spoiler_text; + } + + options.image = undefined; + options.actions = [actionExpand(preferred_locale)]; + } else if (notification.type === 'mention') { + options.actions = [actionReblog(preferred_locale), actionFavourite(preferred_locale)]; + } + + return notify(options); + }).catch(() => { + return notify({ + title, + body, + icon, + tag: notification_id, + timestamp: new Date(), + badge: '/badge.png', + data: { access_token, preferred_locale, url: '/notifications' }, + }); + }) + ); +}; + +const actionExpand = preferred_locale => ({ + action: 'expand', + icon: '/web-push-icon_expand.png', + title: formatMessage('status.show_more', preferred_locale), +}); + +const actionReblog = preferred_locale => ({ + action: 'reblog', + icon: '/web-push-icon_reblog.png', + title: formatMessage('status.reblog', preferred_locale), +}); + +const actionFavourite = preferred_locale => ({ + action: 'favourite', + icon: '/web-push-icon_favourite.png', + title: formatMessage('status.favourite', preferred_locale), +}); + +const findBestClient = clients => { + const focusedClient = clients.find(client => client.focused); + const visibleClient = clients.find(client => client.visibilityState === 'visible'); + + return focusedClient || visibleClient || clients[0]; +}; + +const expandNotification = notification => { + const newNotification = cloneNotification(notification); + + newNotification.body = newNotification.data.hiddenBody; + newNotification.image = newNotification.data.hiddenImage; + newNotification.actions = [actionReblog(notification.data.preferred_locale), actionFavourite(notification.data.preferred_locale)]; + + return self.registration.showNotification(newNotification.title, newNotification); +}; + +const removeActionFromNotification = (notification, action) => { + const newNotification = cloneNotification(notification); + + newNotification.actions = newNotification.actions.filter(item => item.action !== action); + + return self.registration.showNotification(newNotification.title, newNotification); +}; + +const openUrl = url => + self.clients.matchAll({ type: 'window' }).then(clientList => { + if (clientList.length !== 0) { + // : TODO : + const webClients = clientList.filter(client => /\//.test(client.url)); + + if (webClients.length !== 0) { + const client = findBestClient(webClients); + const { pathname } = new URL(url, self.location); + + // : TODO : + if (pathname.startsWith('/')) { + return client.focus().then(client => client.postMessage({ + type: 'navigate', + path: pathname.slice('/'.length - 1), + })); + } + } else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate + const client = findBestClient(clientList); + + return client.navigate(url).then(client => client.focus()); + } + } + + return self.clients.openWindow(url); + }); + +const handleNotificationClick = (event) => { + const reactToNotificationClick = new Promise((resolve, reject) => { + if (event.action) { + if (event.action === 'expand') { + resolve(expandNotification(event.notification)); + } else if (event.action === 'reblog') { + const { data } = event.notification; + resolve(fetchFromApi(`/api/v1/statuses/${data.id}/reblog`, 'post', data.access_token).then(() => removeActionFromNotification(event.notification, 'reblog'))); + } else if (event.action === 'favourite') { + const { data } = event.notification; + resolve(fetchFromApi(`/api/v1/statuses/${data.id}/favourite`, 'post', data.access_token).then(() => removeActionFromNotification(event.notification, 'favourite'))); + } else { + reject(`Unknown action: ${event.action}`); + } + } else { + event.notification.close(); + resolve(openUrl(event.notification.data.url)); + } + }); + + event.waitUntil(reactToNotificationClick); +}; + +self.addEventListener('push', handlePush); +self.addEventListener('notificationclick', handleNotificationClick); diff --git a/app/gabsocial/settings.js b/app/gabsocial/settings.js new file mode 100644 index 000000000..27b84a26f --- /dev/null +++ b/app/gabsocial/settings.js @@ -0,0 +1,49 @@ +'use strict'; + +export default class Settings { + + constructor(keyBase = null) { + this.keyBase = keyBase; + } + + generateKey(id) { + return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id; + } + + set(id, data) { + const key = this.generateKey(id); + try { + const encodedData = JSON.stringify(data); + localStorage.setItem(key, encodedData); + return data; + } catch (e) { + return null; + } + } + + get(id) { + const key = this.generateKey(id); + try { + const rawData = localStorage.getItem(key); + return JSON.parse(rawData); + } catch (e) { + return null; + } + } + + remove(id) { + const data = this.get(id); + if (data) { + const key = this.generateKey(id); + try { + localStorage.removeItem(key); + } catch (e) { + } + } + return data; + } + +} + +export const pushNotificationsSetting = new Settings('gabsocial_push_notification_data'); +export const tagHistory = new Settings('gabsocial_tag_history'); diff --git a/app/gabsocial/storage/db.js b/app/gabsocial/storage/db.js new file mode 100644 index 000000000..95bee5a91 --- /dev/null +++ b/app/gabsocial/storage/db.js @@ -0,0 +1,27 @@ +export default () => new Promise((resolve, reject) => { + // ServiceWorker is required to synchronize the login state. + // Microsoft Edge 17 does not support getAll according to: + // Catalog of standard and vendor APIs across browsers - Microsoft Edge Development + // https://developer.microsoft.com/en-us/microsoft-edge/platform/catalog/?q=specName%3Aindexeddb + if (!('caches' in self && 'getAll' in IDBObjectStore.prototype)) { + reject(); + return; + } + + const request = indexedDB.open('gabsocial'); + + request.onerror = reject; + request.onsuccess = ({ target }) => resolve(target.result); + + request.onupgradeneeded = ({ target }) => { + const accounts = target.result.createObjectStore('accounts', { autoIncrement: true }); + const statuses = target.result.createObjectStore('statuses', { autoIncrement: true }); + + accounts.createIndex('id', 'id', { unique: true }); + accounts.createIndex('moved', 'moved'); + + statuses.createIndex('id', 'id', { unique: true }); + statuses.createIndex('account', 'account'); + statuses.createIndex('reblog', 'reblog'); + }; +}); diff --git a/app/gabsocial/storage/modifier.js b/app/gabsocial/storage/modifier.js new file mode 100644 index 000000000..574802ea8 --- /dev/null +++ b/app/gabsocial/storage/modifier.js @@ -0,0 +1,211 @@ +import openDB from './db'; + +const accountAssetKeys = ['avatar', 'avatar_static', 'header', 'header_static']; +const storageMargin = 8388608; +const storeLimit = 1024; + +// navigator.storage is not present on: +// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.100 Safari/537.36 Edge/16.16299 +// estimate method is not present on Chrome 57.0.2987.98 on Linux. +export const storageFreeable = 'storage' in navigator && 'estimate' in navigator.storage; + +function openCache() { + // ServiceWorker and Cache API is not available on iOS 11 + // https://webkit.org/status/#specification-service-workers + return self.caches ? caches.open('gabsocial-system') : Promise.reject(); +} + +function printErrorIfAvailable(error) { + if (error) { + console.warn(error); + } +} + +function put(name, objects, onupdate, oncreate) { + return openDB().then(db => (new Promise((resolve, reject) => { + const putTransaction = db.transaction(name, 'readwrite'); + const putStore = putTransaction.objectStore(name); + const putIndex = putStore.index('id'); + + objects.forEach(object => { + putIndex.getKey(object.id).onsuccess = retrieval => { + function addObject() { + putStore.add(object); + } + + function deleteObject() { + putStore.delete(retrieval.target.result).onsuccess = addObject; + } + + if (retrieval.target.result) { + if (onupdate) { + onupdate(object, retrieval.target.result, putStore, deleteObject); + } else { + deleteObject(); + } + } else { + if (oncreate) { + oncreate(object, addObject); + } else { + addObject(); + } + } + }; + }); + + putTransaction.oncomplete = () => { + const readTransaction = db.transaction(name, 'readonly'); + const readStore = readTransaction.objectStore(name); + const count = readStore.count(); + + count.onsuccess = () => { + const excess = count.result - storeLimit; + + if (excess > 0) { + const retrieval = readStore.getAll(null, excess); + + retrieval.onsuccess = () => resolve(retrieval.result); + retrieval.onerror = reject; + } else { + resolve([]); + } + }; + + count.onerror = reject; + }; + + putTransaction.onerror = reject; + })).then(resolved => { + db.close(); + return resolved; + }, error => { + db.close(); + throw error; + })); +} + +function evictAccountsByRecords(records) { + return openDB().then(db => { + const transaction = db.transaction(['accounts', 'statuses'], 'readwrite'); + const accounts = transaction.objectStore('accounts'); + const accountsIdIndex = accounts.index('id'); + const accountsMovedIndex = accounts.index('moved'); + const statuses = transaction.objectStore('statuses'); + const statusesIndex = statuses.index('account'); + + function evict(toEvict) { + toEvict.forEach(record => { + openCache() + .then(cache => accountAssetKeys.forEach(key => cache.delete(records[key]))) + .catch(printErrorIfAvailable); + + accountsMovedIndex.getAll(record.id).onsuccess = ({ target }) => evict(target.result); + + statusesIndex.getAll(record.id).onsuccess = + ({ target }) => evictStatusesByRecords(target.result); + + accountsIdIndex.getKey(record.id).onsuccess = + ({ target }) => target.result && accounts.delete(target.result); + }); + } + + evict(records); + + db.close(); + }).catch(printErrorIfAvailable); +} + +export function evictStatus(id) { + evictStatuses([id]); +} + +export function evictStatuses(ids) { + return openDB().then(db => { + const transaction = db.transaction('statuses', 'readwrite'); + const store = transaction.objectStore('statuses'); + const idIndex = store.index('id'); + const reblogIndex = store.index('reblog'); + + ids.forEach(id => { + reblogIndex.getAllKeys(id).onsuccess = + ({ target }) => target.result.forEach(reblogKey => store.delete(reblogKey)); + + idIndex.getKey(id).onsuccess = + ({ target }) => target.result && store.delete(target.result); + }); + + db.close(); + }).catch(printErrorIfAvailable); +} + +function evictStatusesByRecords(records) { + return evictStatuses(records.map(({ id }) => id)); +} + +export function putAccounts(records, avatarStatic) { + const avatarKey = avatarStatic ? 'avatar_static' : 'avatar'; + const newURLs = []; + + put('accounts', records, (newRecord, oldKey, store, oncomplete) => { + store.get(oldKey).onsuccess = ({ target }) => { + accountAssetKeys.forEach(key => { + const newURL = newRecord[key]; + const oldURL = target.result[key]; + + if (newURL !== oldURL) { + openCache() + .then(cache => cache.delete(oldURL)) + .catch(printErrorIfAvailable); + } + }); + + const newURL = newRecord[avatarKey]; + const oldURL = target.result[avatarKey]; + + if (newURL !== oldURL) { + newURLs.push(newURL); + } + + oncomplete(); + }; + }, (newRecord, oncomplete) => { + newURLs.push(newRecord[avatarKey]); + oncomplete(); + }).then(records => Promise.all([ + evictAccountsByRecords(records), + openCache().then(cache => cache.addAll(newURLs)), + ])).then(freeStorage, error => { + freeStorage(); + throw error; + }).catch(printErrorIfAvailable); +} + +export function putStatuses(records) { + put('statuses', records) + .then(evictStatusesByRecords) + .catch(printErrorIfAvailable); +} + +export function freeStorage() { + return storageFreeable && navigator.storage.estimate().then(({ quota, usage }) => { + if (usage + storageMargin < quota) { + return null; + } + + return openDB().then(db => new Promise((resolve, reject) => { + const retrieval = db.transaction('accounts', 'readonly').objectStore('accounts').getAll(null, 1); + + retrieval.onsuccess = () => { + if (retrieval.result.length > 0) { + resolve(evictAccountsByRecords(retrieval.result).then(freeStorage)); + } else { + resolve(caches.delete('gabsocial-system')); + } + }; + + retrieval.onerror = reject; + + db.close(); + })); + }); +} diff --git a/app/gabsocial/store/configureStore.js b/app/gabsocial/store/configureStore.js new file mode 100644 index 000000000..7e7472841 --- /dev/null +++ b/app/gabsocial/store/configureStore.js @@ -0,0 +1,15 @@ +import { createStore, applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import appReducer from '../reducers'; +import loadingBarMiddleware from '../middleware/loading_bar'; +import errorsMiddleware from '../middleware/errors'; +import soundsMiddleware from '../middleware/sounds'; + +export default function configureStore() { + return createStore(appReducer, compose(applyMiddleware( + thunk, + loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), + errorsMiddleware(), + soundsMiddleware() + ), window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f)); +}; diff --git a/app/gabsocial/stream.js b/app/gabsocial/stream.js new file mode 100644 index 000000000..99427f71c --- /dev/null +++ b/app/gabsocial/stream.js @@ -0,0 +1,84 @@ +'use strict'; + +import WebSocketClient from 'websocket.js'; + +const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); + +export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { + return (dispatch, getState) => { + const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = getState().getIn(['meta', 'access_token']); + const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); + + let polling = null; + + const setupPolling = () => { + pollingRefresh(dispatch, () => { + polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000)); + }); + }; + + const clearPolling = () => { + if (polling) { + clearTimeout(polling); + polling = null; + } + }; + + const subscription = getStream(streamingAPIBaseURL, accessToken, path, { + connected () { + if (pollingRefresh) { + clearPolling(); + } + + onConnect(); + }, + + disconnected () { + if (pollingRefresh) { + polling = setTimeout(() => setupPolling(), randomIntUpTo(40000)); + } + + onDisconnect(); + }, + + received (data) { + onReceive(data); + }, + + reconnected () { + if (pollingRefresh) { + clearPolling(); + pollingRefresh(dispatch); + } + + onConnect(); + }, + + }); + + const disconnect = () => { + if (subscription) { + subscription.close(); + } + + clearPolling(); + }; + + return disconnect; + }; +} + + +export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) { + const params = [ `stream=${stream}` ]; + + const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken); + + ws.onopen = connected; + ws.onmessage = e => received(JSON.parse(e.data)); + ws.onclose = disconnected; + ws.onreconnect = reconnected; + + return ws; +}; diff --git a/app/gabsocial/test_setup.js b/app/gabsocial/test_setup.js new file mode 100644 index 000000000..655ce97e9 --- /dev/null +++ b/app/gabsocial/test_setup.js @@ -0,0 +1,7 @@ +'use strict'; + +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +const adapter = new Adapter(); +configure({ adapter }); diff --git a/app/gabsocial/utils/__tests__/base64-test.js b/app/gabsocial/utils/__tests__/base64-test.js new file mode 100644 index 000000000..1b3260faa --- /dev/null +++ b/app/gabsocial/utils/__tests__/base64-test.js @@ -0,0 +1,10 @@ +import * as base64 from '../base64'; + +describe('base64', () => { + describe('decode', () => { + it('returns a uint8 array', () => { + const arr = base64.decode('dGVzdA=='); + expect(arr).toEqual(new Uint8Array([116, 101, 115, 116])); + }); + }); +}); diff --git a/app/gabsocial/utils/__tests__/html-test.js b/app/gabsocial/utils/__tests__/html-test.js new file mode 100644 index 000000000..ef9296e6c --- /dev/null +++ b/app/gabsocial/utils/__tests__/html-test.js @@ -0,0 +1,10 @@ +import * as html from '../html'; + +describe('html', () => { + describe('unsecapeHTML', () => { + it('returns unescaped HTML', () => { + const output = html.unescapeHTML('
lorem
ipsum
/g, '\n\n').replace(/<[^>]*>/g, '');
+ return wrapper.textContent;
+};
diff --git a/app/gabsocial/utils/media_aspect_ratio.js b/app/gabsocial/utils/media_aspect_ratio.js
new file mode 100644
index 000000000..0a7d740a7
--- /dev/null
+++ b/app/gabsocial/utils/media_aspect_ratio.js
@@ -0,0 +1,17 @@
+export const minimumAspectRatio = .8;
+export const maximumAspectRatio = 2.8;
+
+export const isPanoramic = ar => {
+ if (isNaN(ar)) return false;
+ return ar >= maximumAspectRatio;
+}
+
+export const isPortrait = ar => {
+ if (isNaN(ar)) return false;
+ return ar <= minimumAspectRatio;
+}
+
+export const isNonConformingRatio = ar => {
+ if (isNaN(ar)) return false;
+ return !isPanoramic(ar) && !isPortrait(ar);
+}
diff --git a/app/gabsocial/utils/numbers.js b/app/gabsocial/utils/numbers.js
new file mode 100644
index 000000000..619b06f40
--- /dev/null
+++ b/app/gabsocial/utils/numbers.js
@@ -0,0 +1,10 @@
+import React, { Fragment } from 'react';
+import { FormattedNumber } from 'react-intl';
+
+export const shortNumberFormat = number => {
+ if (number < 1000) {
+ return