You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

653 lines
18 KiB

7 years ago
7 years ago
  1. import api from '../api';
  2. import { CancelToken, isCancel } from 'axios';
  3. import { throttle } from 'lodash';
  4. import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
  5. import { tagHistory } from '../settings';
  6. import { useEmoji } from './emojis';
  7. import resizeImage from '../utils/resize_image';
  8. import { importFetchedAccounts } from './importer';
  9. import { updateTimeline } from './timelines';
  10. import { showAlertForError } from './alerts';
  11. import { showAlert } from './alerts';
  12. import { defineMessages } from 'react-intl';
  13. let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
  14. export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
  15. export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
  16. export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
  17. export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
  18. export const COMPOSE_REPLY = 'COMPOSE_REPLY';
  19. export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
  20. export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
  21. export const COMPOSE_MENTION = 'COMPOSE_MENTION';
  22. export const COMPOSE_RESET = 'COMPOSE_RESET';
  23. export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
  24. export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
  25. export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
  26. export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
  27. export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
  28. export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST';
  29. export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS';
  30. export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL';
  31. export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
  32. export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
  33. export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
  34. export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
  35. export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
  36. export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
  37. export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
  38. export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
  39. export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
  40. export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
  41. export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
  42. export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
  43. export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
  44. export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
  45. export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
  46. export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
  47. export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
  48. export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
  49. export const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD';
  50. export const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE';
  51. export const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD';
  52. export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE';
  53. export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE';
  54. export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
  55. const messages = defineMessages({
  56. uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
  57. uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
  58. });
  59. const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
  60. export const ensureComposeIsVisible = (getState, routerHistory) => {
  61. if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
  62. routerHistory.push('/statuses/new');
  63. }
  64. };
  65. export function changeCompose(text) {
  66. return {
  67. type: COMPOSE_CHANGE,
  68. text: text,
  69. };
  70. };
  71. export function replyCompose(status, routerHistory) {
  72. return (dispatch, getState) => {
  73. dispatch({
  74. type: COMPOSE_REPLY,
  75. status: status,
  76. });
  77. ensureComposeIsVisible(getState, routerHistory);
  78. };
  79. };
  80. export function cancelReplyCompose() {
  81. return {
  82. type: COMPOSE_REPLY_CANCEL,
  83. };
  84. };
  85. export function resetCompose() {
  86. return {
  87. type: COMPOSE_RESET,
  88. };
  89. };
  90. export function mentionCompose(account, routerHistory) {
  91. return (dispatch, getState) => {
  92. dispatch({
  93. type: COMPOSE_MENTION,
  94. account: account,
  95. });
  96. ensureComposeIsVisible(getState, routerHistory);
  97. };
  98. };
  99. export function directCompose(account, routerHistory) {
  100. return (dispatch, getState) => {
  101. dispatch({
  102. type: COMPOSE_DIRECT,
  103. account: account,
  104. });
  105. ensureComposeIsVisible(getState, routerHistory);
  106. };
  107. };
  108. export function submitCompose(routerHistory) {
  109. return function (dispatch, getState) {
  110. const status = getState().getIn(['compose', 'text'], '');
  111. const media = getState().getIn(['compose', 'media_attachments']);
  112. if ((!status || !status.length) && media.size === 0) {
  113. return;
  114. }
  115. dispatch(submitComposeRequest());
  116. api(getState).post('/api/v1/statuses', {
  117. status,
  118. in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
  119. media_ids: media.map(item => item.get('id')),
  120. sensitive: getState().getIn(['compose', 'sensitive']),
  121. spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
  122. visibility: getState().getIn(['compose', 'privacy']),
  123. poll: getState().getIn(['compose', 'poll'], null),
  124. }, {
  125. headers: {
  126. 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
  127. },
  128. }).then(function (response) {
  129. if (response.data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) {
  130. routerHistory.push('/timelines/direct');
  131. } else if (routerHistory && routerHistory.location.pathname === '/statuses/new' && window.history.state) {
  132. routerHistory.goBack();
  133. }
  134. dispatch(insertIntoTagHistory(response.data.tags, status));
  135. dispatch(submitComposeSuccess({ ...response.data }));
  136. // To make the app more responsive, immediately push the status
  137. // into the columns
  138. const insertIfOnline = timelineId => {
  139. const timeline = getState().getIn(['timelines', timelineId]);
  140. if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) {
  141. dispatch(updateTimeline(timelineId, { ...response.data }));
  142. }
  143. };
  144. if (response.data.visibility !== 'direct') {
  145. insertIfOnline('home');
  146. }
  147. if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
  148. insertIfOnline('community');
  149. insertIfOnline('public');
  150. insertIfOnline(`account:${response.data.account.id}`);
  151. }
  152. }).catch(function (error) {
  153. dispatch(submitComposeFail(error));
  154. });
  155. };
  156. };
  157. export function submitComposeRequest() {
  158. return {
  159. type: COMPOSE_SUBMIT_REQUEST,
  160. };
  161. };
  162. export function submitComposeSuccess(status) {
  163. return {
  164. type: COMPOSE_SUBMIT_SUCCESS,
  165. status: status,
  166. };
  167. };
  168. export function submitComposeFail(error) {
  169. return {
  170. type: COMPOSE_SUBMIT_FAIL,
  171. error: error,
  172. };
  173. };
  174. export function uploadCompose(files) {
  175. return function (dispatch, getState) {
  176. const uploadLimit = 4;
  177. const media = getState().getIn(['compose', 'media_attachments']);
  178. const pending = getState().getIn(['compose', 'pending_media_attachments']);
  179. const progress = new Array(files.length).fill(0);
  180. let total = Array.from(files).reduce((a, v) => a + v.size, 0);
  181. if (files.length + media.size + pending > uploadLimit) {
  182. dispatch(showAlert(undefined, messages.uploadErrorLimit));
  183. return;
  184. }
  185. if (getState().getIn(['compose', 'poll'])) {
  186. dispatch(showAlert(undefined, messages.uploadErrorPoll));
  187. return;
  188. }
  189. dispatch(uploadComposeRequest());
  190. for (const [i, f] of Array.from(files).entries()) {
  191. if (media.size + i > 3) break;
  192. resizeImage(f).then(file => {
  193. const data = new FormData();
  194. data.append('file', file);
  195. // Account for disparity in size of original image and resized data
  196. total += file.size - f.size;
  197. return api(getState).post('/api/v2/media', data, {
  198. onUploadProgress: function({ loaded }){
  199. progress[i] = loaded;
  200. dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
  201. },
  202. }).then(({ status, data }) => {
  203. // If server-side processing of the media attachment has not completed yet,
  204. // poll the server until it is, before showing the media attachment as uploaded
  205. if (status === 200) {
  206. dispatch(uploadComposeSuccess(data, f));
  207. } else if (status === 202) {
  208. const poll = () => {
  209. api(getState).get(`/api/v1/media/${data.id}`).then(response => {
  210. if (response.status === 200) {
  211. dispatch(uploadComposeSuccess(response.data, f));
  212. } else if (response.status === 206) {
  213. setTimeout(() => poll(), 1000);
  214. }
  215. }).catch(error => dispatch(uploadComposeFail(error)));
  216. };
  217. poll();
  218. }
  219. });
  220. }).catch(error => dispatch(uploadComposeFail(error)));
  221. };
  222. };
  223. };
  224. export const uploadThumbnail = (id, file) => (dispatch, getState) => {
  225. dispatch(uploadThumbnailRequest());
  226. const total = file.size;
  227. const data = new FormData();
  228. data.append('thumbnail', file);
  229. api(getState).put(`/api/v1/media/${id}`, data, {
  230. onUploadProgress: ({ loaded }) => {
  231. dispatch(uploadThumbnailProgress(loaded, total));
  232. },
  233. }).then(({ data }) => {
  234. dispatch(uploadThumbnailSuccess(data));
  235. }).catch(error => {
  236. dispatch(uploadThumbnailFail(id, error));
  237. });
  238. };
  239. export const uploadThumbnailRequest = () => ({
  240. type: THUMBNAIL_UPLOAD_REQUEST,
  241. skipLoading: true,
  242. });
  243. export const uploadThumbnailProgress = (loaded, total) => ({
  244. type: THUMBNAIL_UPLOAD_PROGRESS,
  245. loaded,
  246. total,
  247. skipLoading: true,
  248. });
  249. export const uploadThumbnailSuccess = media => ({
  250. type: THUMBNAIL_UPLOAD_SUCCESS,
  251. media,
  252. skipLoading: true,
  253. });
  254. export const uploadThumbnailFail = error => ({
  255. type: THUMBNAIL_UPLOAD_FAIL,
  256. error,
  257. skipLoading: true,
  258. });
  259. export function changeUploadCompose(id, params) {
  260. return (dispatch, getState) => {
  261. dispatch(changeUploadComposeRequest());
  262. api(getState).put(`/api/v1/media/${id}`, params).then(response => {
  263. dispatch(changeUploadComposeSuccess(response.data));
  264. }).catch(error => {
  265. dispatch(changeUploadComposeFail(id, error));
  266. });
  267. };
  268. };
  269. export function changeUploadComposeRequest() {
  270. return {
  271. type: COMPOSE_UPLOAD_CHANGE_REQUEST,
  272. skipLoading: true,
  273. };
  274. };
  275. export function changeUploadComposeSuccess(media) {
  276. return {
  277. type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
  278. media: media,
  279. skipLoading: true,
  280. };
  281. };
  282. export function changeUploadComposeFail(error) {
  283. return {
  284. type: COMPOSE_UPLOAD_CHANGE_FAIL,
  285. error: error,
  286. skipLoading: true,
  287. };
  288. };
  289. export function uploadComposeRequest() {
  290. return {
  291. type: COMPOSE_UPLOAD_REQUEST,
  292. skipLoading: true,
  293. };
  294. };
  295. export function uploadComposeProgress(loaded, total) {
  296. return {
  297. type: COMPOSE_UPLOAD_PROGRESS,
  298. loaded: loaded,
  299. total: total,
  300. };
  301. };
  302. export function uploadComposeSuccess(media, file) {
  303. return {
  304. type: COMPOSE_UPLOAD_SUCCESS,
  305. media: media,
  306. file: file,
  307. skipLoading: true,
  308. };
  309. };
  310. export function uploadComposeFail(error) {
  311. return {
  312. type: COMPOSE_UPLOAD_FAIL,
  313. error: error,
  314. skipLoading: true,
  315. };
  316. };
  317. export function undoUploadCompose(media_id) {
  318. return {
  319. type: COMPOSE_UPLOAD_UNDO,
  320. media_id: media_id,
  321. };
  322. };
  323. export function clearComposeSuggestions() {
  324. if (cancelFetchComposeSuggestionsAccounts) {
  325. cancelFetchComposeSuggestionsAccounts();
  326. }
  327. return {
  328. type: COMPOSE_SUGGESTIONS_CLEAR,
  329. };
  330. };
  331. const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
  332. if (cancelFetchComposeSuggestionsAccounts) {
  333. cancelFetchComposeSuggestionsAccounts();
  334. }
  335. api(getState).get('/api/v1/accounts/search', {
  336. cancelToken: new CancelToken(cancel => {
  337. cancelFetchComposeSuggestionsAccounts = cancel;
  338. }),
  339. params: {
  340. q: token.slice(1),
  341. resolve: false,
  342. limit: 4,
  343. },
  344. }).then(response => {
  345. dispatch(importFetchedAccounts(response.data));
  346. dispatch(readyComposeSuggestionsAccounts(token, response.data));
  347. }).catch(error => {
  348. if (!isCancel(error)) {
  349. dispatch(showAlertForError(error));
  350. }
  351. });
  352. }, 200, { leading: true, trailing: true });
  353. const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
  354. const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
  355. dispatch(readyComposeSuggestionsEmojis(token, results));
  356. };
  357. const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
  358. if (cancelFetchComposeSuggestionsTags) {
  359. cancelFetchComposeSuggestionsTags();
  360. }
  361. dispatch(updateSuggestionTags(token));
  362. api(getState).get('/api/v2/search', {
  363. cancelToken: new CancelToken(cancel => {
  364. cancelFetchComposeSuggestionsTags = cancel;
  365. }),
  366. params: {
  367. type: 'hashtags',
  368. q: token.slice(1),
  369. resolve: false,
  370. limit: 4,
  371. exclude_unreviewed: true,
  372. },
  373. }).then(({ data }) => {
  374. dispatch(readyComposeSuggestionsTags(token, data.hashtags));
  375. }).catch(error => {
  376. if (!isCancel(error)) {
  377. dispatch(showAlertForError(error));
  378. }
  379. });
  380. }, 200, { leading: true, trailing: true });
  381. export function fetchComposeSuggestions(token) {
  382. return (dispatch, getState) => {
  383. switch (token[0]) {
  384. case ':':
  385. fetchComposeSuggestionsEmojis(dispatch, getState, token);
  386. break;
  387. case '#':
  388. fetchComposeSuggestionsTags(dispatch, getState, token);
  389. break;
  390. default:
  391. fetchComposeSuggestionsAccounts(dispatch, getState, token);
  392. break;
  393. }
  394. };
  395. };
  396. export function readyComposeSuggestionsEmojis(token, emojis) {
  397. return {
  398. type: COMPOSE_SUGGESTIONS_READY,
  399. token,
  400. emojis,
  401. };
  402. };
  403. export function readyComposeSuggestionsAccounts(token, accounts) {
  404. return {
  405. type: COMPOSE_SUGGESTIONS_READY,
  406. token,
  407. accounts,
  408. };
  409. };
  410. export const readyComposeSuggestionsTags = (token, tags) => ({
  411. type: COMPOSE_SUGGESTIONS_READY,
  412. token,
  413. tags,
  414. });
  415. export function selectComposeSuggestion(position, token, suggestion, path) {
  416. return (dispatch, getState) => {
  417. let completion, startPosition;
  418. if (suggestion.type === 'emoji') {
  419. completion = suggestion.native || suggestion.colons;
  420. startPosition = position - 1;
  421. dispatch(useEmoji(suggestion));
  422. } else if (suggestion.type === 'hashtag') {
  423. completion = `#${suggestion.name}`;
  424. startPosition = position - 1;
  425. } else if (suggestion.type === 'account') {
  426. completion = getState().getIn(['accounts', suggestion.id, 'acct']);
  427. startPosition = position;
  428. }
  429. dispatch({
  430. type: COMPOSE_SUGGESTION_SELECT,
  431. position: startPosition,
  432. token,
  433. completion,
  434. path,
  435. });
  436. };
  437. };
  438. export function updateSuggestionTags(token) {
  439. return {
  440. type: COMPOSE_SUGGESTION_TAGS_UPDATE,
  441. token,
  442. };
  443. }
  444. export function updateTagHistory(tags) {
  445. return {
  446. type: COMPOSE_TAG_HISTORY_UPDATE,
  447. tags,
  448. };
  449. }
  450. export function hydrateCompose() {
  451. return (dispatch, getState) => {
  452. const me = getState().getIn(['meta', 'me']);
  453. const history = tagHistory.get(me);
  454. if (history !== null) {
  455. dispatch(updateTagHistory(history));
  456. }
  457. };
  458. }
  459. function insertIntoTagHistory(recognizedTags, text) {
  460. return (dispatch, getState) => {
  461. const state = getState();
  462. const oldHistory = state.getIn(['compose', 'tagHistory']);
  463. const me = state.getIn(['meta', 'me']);
  464. const names = recognizedTags.map(tag => text.match(new RegExp(`#${tag.name}`, 'i'))[0].slice(1));
  465. const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1);
  466. names.push(...intersectedOldHistory.toJS());
  467. const newHistory = names.slice(0, 1000);
  468. tagHistory.set(me, newHistory);
  469. dispatch(updateTagHistory(newHistory));
  470. };
  471. }
  472. export function mountCompose() {
  473. return {
  474. type: COMPOSE_MOUNT,
  475. };
  476. };
  477. export function unmountCompose() {
  478. return {
  479. type: COMPOSE_UNMOUNT,
  480. };
  481. };
  482. export function changeComposeSensitivity() {
  483. return {
  484. type: COMPOSE_SENSITIVITY_CHANGE,
  485. };
  486. };
  487. export function changeComposeSpoilerness() {
  488. return {
  489. type: COMPOSE_SPOILERNESS_CHANGE,
  490. };
  491. };
  492. export function changeComposeSpoilerText(text) {
  493. return {
  494. type: COMPOSE_SPOILER_TEXT_CHANGE,
  495. text,
  496. };
  497. };
  498. export function changeComposeVisibility(value) {
  499. return {
  500. type: COMPOSE_VISIBILITY_CHANGE,
  501. value,
  502. };
  503. };
  504. export function insertEmojiCompose(position, emoji, needsSpace) {
  505. return {
  506. type: COMPOSE_EMOJI_INSERT,
  507. position,
  508. emoji,
  509. needsSpace,
  510. };
  511. };
  512. export function changeComposing(value) {
  513. return {
  514. type: COMPOSE_COMPOSING_CHANGE,
  515. value,
  516. };
  517. };
  518. export function addPoll() {
  519. return {
  520. type: COMPOSE_POLL_ADD,
  521. };
  522. };
  523. export function removePoll() {
  524. return {
  525. type: COMPOSE_POLL_REMOVE,
  526. };
  527. };
  528. export function addPollOption(title) {
  529. return {
  530. type: COMPOSE_POLL_OPTION_ADD,
  531. title,
  532. };
  533. };
  534. export function changePollOption(index, title) {
  535. return {
  536. type: COMPOSE_POLL_OPTION_CHANGE,
  537. index,
  538. title,
  539. };
  540. };
  541. export function removePollOption(index) {
  542. return {
  543. type: COMPOSE_POLL_OPTION_REMOVE,
  544. index,
  545. };
  546. };
  547. export function changePollSettings(expiresIn, isMultiple) {
  548. return {
  549. type: COMPOSE_POLL_SETTINGS_CHANGE,
  550. expiresIn,
  551. isMultiple,
  552. };
  553. };