compose.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810
  1. import { defineMessages } from 'react-intl';
  2. import axios from 'axios';
  3. import { throttle } from 'lodash';
  4. import api from 'mastodon/api';
  5. import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light';
  6. import { tagHistory } from 'mastodon/settings';
  7. import { showAlert, showAlertForError } from './alerts';
  8. import { useEmoji } from './emojis';
  9. import { importFetchedAccounts, importFetchedStatus } from './importer';
  10. import { openModal } from './modal';
  11. import { updateTimeline } from './timelines';
  12. /** @type {AbortController | undefined} */
  13. let fetchComposeSuggestionsAccountsController;
  14. /** @type {AbortController | undefined} */
  15. let fetchComposeSuggestionsTagsController;
  16. export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
  17. export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
  18. export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
  19. export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
  20. export const COMPOSE_REPLY = 'COMPOSE_REPLY';
  21. export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
  22. export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
  23. export const COMPOSE_MENTION = 'COMPOSE_MENTION';
  24. export const COMPOSE_RESET = 'COMPOSE_RESET';
  25. export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
  26. export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
  27. export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
  28. export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
  29. export const COMPOSE_UPLOAD_PROCESSING = 'COMPOSE_UPLOAD_PROCESSING';
  30. export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
  31. export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST';
  32. export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS';
  33. export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL';
  34. export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
  35. export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
  36. export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
  37. export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
  38. export const COMPOSE_SUGGESTION_IGNORE = 'COMPOSE_SUGGESTION_IGNORE';
  39. export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
  40. export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
  41. export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
  42. export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
  43. export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
  44. export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
  45. export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
  46. export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
  47. export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
  48. export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
  49. export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
  50. export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
  51. export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
  52. export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
  53. export const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD';
  54. export const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE';
  55. export const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD';
  56. export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE';
  57. export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE';
  58. export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
  59. export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL';
  60. export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
  61. export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS';
  62. export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
  63. export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
  64. const messages = defineMessages({
  65. uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
  66. uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
  67. open: { id: 'compose.published.open', defaultMessage: 'Open' },
  68. published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
  69. saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
  70. });
  71. export const ensureComposeIsVisible = (getState, routerHistory) => {
  72. if (!getState().getIn(['compose', 'mounted'])) {
  73. routerHistory.push('/publish');
  74. }
  75. };
  76. export function setComposeToStatus(status, text, spoiler_text) {
  77. return{
  78. type: COMPOSE_SET_STATUS,
  79. status,
  80. text,
  81. spoiler_text,
  82. };
  83. }
  84. export function changeCompose(text) {
  85. return {
  86. type: COMPOSE_CHANGE,
  87. text: text,
  88. };
  89. }
  90. export function replyCompose(status, routerHistory) {
  91. return (dispatch, getState) => {
  92. dispatch({
  93. type: COMPOSE_REPLY,
  94. status: status,
  95. });
  96. ensureComposeIsVisible(getState, routerHistory);
  97. };
  98. }
  99. export function cancelReplyCompose() {
  100. return {
  101. type: COMPOSE_REPLY_CANCEL,
  102. };
  103. }
  104. export function resetCompose() {
  105. return {
  106. type: COMPOSE_RESET,
  107. };
  108. }
  109. export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => {
  110. dispatch({
  111. type: COMPOSE_FOCUS,
  112. defaultText,
  113. });
  114. ensureComposeIsVisible(getState, routerHistory);
  115. };
  116. export function mentionCompose(account, routerHistory) {
  117. return (dispatch, getState) => {
  118. dispatch({
  119. type: COMPOSE_MENTION,
  120. account: account,
  121. });
  122. ensureComposeIsVisible(getState, routerHistory);
  123. };
  124. }
  125. export function directCompose(account, routerHistory) {
  126. return (dispatch, getState) => {
  127. dispatch({
  128. type: COMPOSE_DIRECT,
  129. account: account,
  130. });
  131. ensureComposeIsVisible(getState, routerHistory);
  132. };
  133. }
  134. export function submitCompose(routerHistory) {
  135. return function (dispatch, getState) {
  136. const status = getState().getIn(['compose', 'text'], '');
  137. const media = getState().getIn(['compose', 'media_attachments']);
  138. const statusId = getState().getIn(['compose', 'id'], null);
  139. if ((!status || !status.length) && media.size === 0) {
  140. return;
  141. }
  142. dispatch(submitComposeRequest());
  143. // If we're editing a post with media attachments, those have not
  144. // necessarily been changed on the server. Do it now in the same
  145. // API call.
  146. let media_attributes;
  147. if (statusId !== null) {
  148. media_attributes = media.map(item => {
  149. let focus;
  150. if (item.getIn(['meta', 'focus'])) {
  151. focus = `${item.getIn(['meta', 'focus', 'x']).toFixed(2)},${item.getIn(['meta', 'focus', 'y']).toFixed(2)}`;
  152. }
  153. return {
  154. id: item.get('id'),
  155. description: item.get('description'),
  156. focus,
  157. };
  158. });
  159. }
  160. api(getState).request({
  161. url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
  162. method: statusId === null ? 'post' : 'put',
  163. data: {
  164. status,
  165. in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
  166. media_ids: media.map(item => item.get('id')),
  167. media_attributes,
  168. sensitive: getState().getIn(['compose', 'sensitive']),
  169. spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
  170. visibility: getState().getIn(['compose', 'privacy']),
  171. poll: getState().getIn(['compose', 'poll'], null),
  172. language: getState().getIn(['compose', 'language']),
  173. },
  174. headers: {
  175. 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
  176. },
  177. }).then(function (response) {
  178. if (routerHistory && (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new') && window.history.state) {
  179. routerHistory.goBack();
  180. }
  181. dispatch(insertIntoTagHistory(response.data.tags, status));
  182. dispatch(submitComposeSuccess({ ...response.data }));
  183. // To make the app more responsive, immediately push the status
  184. // into the columns
  185. const insertIfOnline = timelineId => {
  186. const timeline = getState().getIn(['timelines', timelineId]);
  187. if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) {
  188. dispatch(updateTimeline(timelineId, { ...response.data }));
  189. }
  190. };
  191. if (statusId) {
  192. dispatch(importFetchedStatus({ ...response.data }));
  193. }
  194. if (statusId === null && response.data.visibility !== 'direct') {
  195. insertIfOnline('home');
  196. }
  197. if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') {
  198. insertIfOnline('community');
  199. insertIfOnline('public');
  200. insertIfOnline(`account:${response.data.account.id}`);
  201. }
  202. dispatch(showAlert({
  203. message: statusId === null ? messages.published : messages.saved,
  204. action: messages.open,
  205. dismissAfter: 10000,
  206. onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
  207. }));
  208. }).catch(function (error) {
  209. dispatch(submitComposeFail(error));
  210. });
  211. };
  212. }
  213. export function submitComposeRequest() {
  214. return {
  215. type: COMPOSE_SUBMIT_REQUEST,
  216. };
  217. }
  218. export function submitComposeSuccess(status) {
  219. return {
  220. type: COMPOSE_SUBMIT_SUCCESS,
  221. status: status,
  222. };
  223. }
  224. export function submitComposeFail(error) {
  225. return {
  226. type: COMPOSE_SUBMIT_FAIL,
  227. error: error,
  228. };
  229. }
  230. export function uploadCompose(files) {
  231. return function (dispatch, getState) {
  232. const uploadLimit = 4;
  233. const media = getState().getIn(['compose', 'media_attachments']);
  234. const pending = getState().getIn(['compose', 'pending_media_attachments']);
  235. const progress = new Array(files.length).fill(0);
  236. let total = Array.from(files).reduce((a, v) => a + v.size, 0);
  237. if (files.length + media.size + pending > uploadLimit) {
  238. dispatch(showAlert({ message: messages.uploadErrorLimit }));
  239. return;
  240. }
  241. if (getState().getIn(['compose', 'poll'])) {
  242. dispatch(showAlert({ message: messages.uploadErrorPoll }));
  243. return;
  244. }
  245. dispatch(uploadComposeRequest());
  246. for (const [i, file] of Array.from(files).entries()) {
  247. if (media.size + i > 3) break;
  248. const data = new FormData();
  249. data.append('file', file);
  250. api(getState).post('/api/v2/media', data, {
  251. onUploadProgress: function({ loaded }){
  252. progress[i] = loaded;
  253. dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
  254. },
  255. }).then(({ status, data }) => {
  256. // If server-side processing of the media attachment has not completed yet,
  257. // poll the server until it is, before showing the media attachment as uploaded
  258. if (status === 200) {
  259. dispatch(uploadComposeSuccess(data, file));
  260. } else if (status === 202) {
  261. dispatch(uploadComposeProcessing());
  262. let tryCount = 1;
  263. const poll = () => {
  264. api(getState).get(`/api/v1/media/${data.id}`).then(response => {
  265. if (response.status === 200) {
  266. dispatch(uploadComposeSuccess(response.data, file));
  267. } else if (response.status === 206) {
  268. const retryAfter = (Math.log2(tryCount) || 1) * 1000;
  269. tryCount += 1;
  270. setTimeout(() => poll(), retryAfter);
  271. }
  272. }).catch(error => dispatch(uploadComposeFail(error)));
  273. };
  274. poll();
  275. }
  276. }).catch(error => dispatch(uploadComposeFail(error)));
  277. }
  278. };
  279. }
  280. export const uploadComposeProcessing = () => ({
  281. type: COMPOSE_UPLOAD_PROCESSING,
  282. });
  283. export const uploadThumbnail = (id, file) => (dispatch, getState) => {
  284. dispatch(uploadThumbnailRequest());
  285. const total = file.size;
  286. const data = new FormData();
  287. data.append('thumbnail', file);
  288. api(getState).put(`/api/v1/media/${id}`, data, {
  289. onUploadProgress: ({ loaded }) => {
  290. dispatch(uploadThumbnailProgress(loaded, total));
  291. },
  292. }).then(({ data }) => {
  293. dispatch(uploadThumbnailSuccess(data));
  294. }).catch(error => {
  295. dispatch(uploadThumbnailFail(id, error));
  296. });
  297. };
  298. export const uploadThumbnailRequest = () => ({
  299. type: THUMBNAIL_UPLOAD_REQUEST,
  300. skipLoading: true,
  301. });
  302. export const uploadThumbnailProgress = (loaded, total) => ({
  303. type: THUMBNAIL_UPLOAD_PROGRESS,
  304. loaded,
  305. total,
  306. skipLoading: true,
  307. });
  308. export const uploadThumbnailSuccess = media => ({
  309. type: THUMBNAIL_UPLOAD_SUCCESS,
  310. media,
  311. skipLoading: true,
  312. });
  313. export const uploadThumbnailFail = error => ({
  314. type: THUMBNAIL_UPLOAD_FAIL,
  315. error,
  316. skipLoading: true,
  317. });
  318. export function initMediaEditModal(id) {
  319. return dispatch => {
  320. dispatch({
  321. type: INIT_MEDIA_EDIT_MODAL,
  322. id,
  323. });
  324. dispatch(openModal({
  325. modalType: 'FOCAL_POINT',
  326. modalProps: { id },
  327. }));
  328. };
  329. }
  330. export function onChangeMediaDescription(description) {
  331. return {
  332. type: COMPOSE_CHANGE_MEDIA_DESCRIPTION,
  333. description,
  334. };
  335. }
  336. export function onChangeMediaFocus(focusX, focusY) {
  337. return {
  338. type: COMPOSE_CHANGE_MEDIA_FOCUS,
  339. focusX,
  340. focusY,
  341. };
  342. }
  343. export function changeUploadCompose(id, params) {
  344. return (dispatch, getState) => {
  345. dispatch(changeUploadComposeRequest());
  346. let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
  347. // Editing already-attached media is deferred to editing the post itself.
  348. // For simplicity's sake, fake an API reply.
  349. if (media && !media.get('unattached')) {
  350. const { focus, ...other } = params;
  351. const data = { ...media.toJS(), ...other };
  352. if (focus) {
  353. const [x, y] = focus.split(',');
  354. data.meta = { focus: { x: parseFloat(x), y: parseFloat(y) } };
  355. }
  356. dispatch(changeUploadComposeSuccess(data, true));
  357. } else {
  358. api(getState).put(`/api/v1/media/${id}`, params).then(response => {
  359. dispatch(changeUploadComposeSuccess(response.data, false));
  360. }).catch(error => {
  361. dispatch(changeUploadComposeFail(id, error));
  362. });
  363. }
  364. };
  365. }
  366. export function changeUploadComposeRequest() {
  367. return {
  368. type: COMPOSE_UPLOAD_CHANGE_REQUEST,
  369. skipLoading: true,
  370. };
  371. }
  372. export function changeUploadComposeSuccess(media, attached) {
  373. return {
  374. type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
  375. media: media,
  376. attached: attached,
  377. skipLoading: true,
  378. };
  379. }
  380. export function changeUploadComposeFail(error) {
  381. return {
  382. type: COMPOSE_UPLOAD_CHANGE_FAIL,
  383. error: error,
  384. skipLoading: true,
  385. };
  386. }
  387. export function uploadComposeRequest() {
  388. return {
  389. type: COMPOSE_UPLOAD_REQUEST,
  390. skipLoading: true,
  391. };
  392. }
  393. export function uploadComposeProgress(loaded, total) {
  394. return {
  395. type: COMPOSE_UPLOAD_PROGRESS,
  396. loaded: loaded,
  397. total: total,
  398. };
  399. }
  400. export function uploadComposeSuccess(media, file) {
  401. return {
  402. type: COMPOSE_UPLOAD_SUCCESS,
  403. media: media,
  404. file: file,
  405. skipLoading: true,
  406. };
  407. }
  408. export function uploadComposeFail(error) {
  409. return {
  410. type: COMPOSE_UPLOAD_FAIL,
  411. error: error,
  412. skipLoading: true,
  413. };
  414. }
  415. export function undoUploadCompose(media_id) {
  416. return {
  417. type: COMPOSE_UPLOAD_UNDO,
  418. media_id: media_id,
  419. };
  420. }
  421. export function clearComposeSuggestions() {
  422. if (fetchComposeSuggestionsAccountsController) {
  423. fetchComposeSuggestionsAccountsController.abort();
  424. }
  425. return {
  426. type: COMPOSE_SUGGESTIONS_CLEAR,
  427. };
  428. }
  429. const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
  430. if (fetchComposeSuggestionsAccountsController) {
  431. fetchComposeSuggestionsAccountsController.abort();
  432. }
  433. fetchComposeSuggestionsAccountsController = new AbortController();
  434. api(getState).get('/api/v1/accounts/search', {
  435. signal: fetchComposeSuggestionsAccountsController.signal,
  436. params: {
  437. q: token.slice(1),
  438. resolve: false,
  439. limit: 4,
  440. },
  441. }).then(response => {
  442. dispatch(importFetchedAccounts(response.data));
  443. dispatch(readyComposeSuggestionsAccounts(token, response.data));
  444. }).catch(error => {
  445. if (!axios.isCancel(error)) {
  446. dispatch(showAlertForError(error));
  447. }
  448. }).finally(() => {
  449. fetchComposeSuggestionsAccountsController = undefined;
  450. });
  451. }, 200, { leading: true, trailing: true });
  452. const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
  453. const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
  454. dispatch(readyComposeSuggestionsEmojis(token, results));
  455. };
  456. const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
  457. if (fetchComposeSuggestionsTagsController) {
  458. fetchComposeSuggestionsTagsController.abort();
  459. }
  460. dispatch(updateSuggestionTags(token));
  461. fetchComposeSuggestionsTagsController = new AbortController();
  462. api(getState).get('/api/v2/search', {
  463. signal: fetchComposeSuggestionsTagsController.signal,
  464. params: {
  465. type: 'hashtags',
  466. q: token.slice(1),
  467. resolve: false,
  468. limit: 4,
  469. exclude_unreviewed: true,
  470. },
  471. }).then(({ data }) => {
  472. dispatch(readyComposeSuggestionsTags(token, data.hashtags));
  473. }).catch(error => {
  474. if (!axios.isCancel(error)) {
  475. dispatch(showAlertForError(error));
  476. }
  477. }).finally(() => {
  478. fetchComposeSuggestionsTagsController = undefined;
  479. });
  480. }, 200, { leading: true, trailing: true });
  481. export function fetchComposeSuggestions(token) {
  482. return (dispatch, getState) => {
  483. switch (token[0]) {
  484. case ':':
  485. fetchComposeSuggestionsEmojis(dispatch, getState, token);
  486. break;
  487. case '#':
  488. fetchComposeSuggestionsTags(dispatch, getState, token);
  489. break;
  490. default:
  491. fetchComposeSuggestionsAccounts(dispatch, getState, token);
  492. break;
  493. }
  494. };
  495. }
  496. export function readyComposeSuggestionsEmojis(token, emojis) {
  497. return {
  498. type: COMPOSE_SUGGESTIONS_READY,
  499. token,
  500. emojis,
  501. };
  502. }
  503. export function readyComposeSuggestionsAccounts(token, accounts) {
  504. return {
  505. type: COMPOSE_SUGGESTIONS_READY,
  506. token,
  507. accounts,
  508. };
  509. }
  510. export const readyComposeSuggestionsTags = (token, tags) => ({
  511. type: COMPOSE_SUGGESTIONS_READY,
  512. token,
  513. tags,
  514. });
  515. export function selectComposeSuggestion(position, token, suggestion, path) {
  516. return (dispatch, getState) => {
  517. let completion, startPosition;
  518. if (suggestion.type === 'emoji') {
  519. completion = suggestion.native || suggestion.colons;
  520. startPosition = position - 1;
  521. dispatch(useEmoji(suggestion));
  522. } else if (suggestion.type === 'hashtag') {
  523. completion = `#${suggestion.name}`;
  524. startPosition = position - 1;
  525. } else if (suggestion.type === 'account') {
  526. completion = getState().getIn(['accounts', suggestion.id, 'acct']);
  527. startPosition = position;
  528. }
  529. // We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
  530. // the suggestions are dismissed and the cursor moves forward.
  531. if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
  532. dispatch({
  533. type: COMPOSE_SUGGESTION_SELECT,
  534. position: startPosition,
  535. token,
  536. completion,
  537. path,
  538. });
  539. } else {
  540. dispatch({
  541. type: COMPOSE_SUGGESTION_IGNORE,
  542. position: startPosition,
  543. token,
  544. completion,
  545. path,
  546. });
  547. }
  548. };
  549. }
  550. export function updateSuggestionTags(token) {
  551. return {
  552. type: COMPOSE_SUGGESTION_TAGS_UPDATE,
  553. token,
  554. };
  555. }
  556. export function updateTagHistory(tags) {
  557. return {
  558. type: COMPOSE_TAG_HISTORY_UPDATE,
  559. tags,
  560. };
  561. }
  562. export function hydrateCompose() {
  563. return (dispatch, getState) => {
  564. const me = getState().getIn(['meta', 'me']);
  565. const history = tagHistory.get(me);
  566. if (history !== null) {
  567. dispatch(updateTagHistory(history));
  568. }
  569. };
  570. }
  571. function insertIntoTagHistory(recognizedTags, text) {
  572. return (dispatch, getState) => {
  573. const state = getState();
  574. const oldHistory = state.getIn(['compose', 'tagHistory']);
  575. const me = state.getIn(['meta', 'me']);
  576. // FIXME: Matching input hashtags with recognized hashtags has become more
  577. // complicated because of new normalization rules, it's no longer just
  578. // a case sensitivity issue
  579. const names = recognizedTags.map(tag => {
  580. const matches = text.match(new RegExp(`#${tag.name}`, 'i'));
  581. if (matches && matches.length > 0) {
  582. return matches[0].slice(1);
  583. } else {
  584. return tag.name;
  585. }
  586. });
  587. const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1);
  588. names.push(...intersectedOldHistory.toJS());
  589. const newHistory = names.slice(0, 1000);
  590. tagHistory.set(me, newHistory);
  591. dispatch(updateTagHistory(newHistory));
  592. };
  593. }
  594. export function mountCompose() {
  595. return {
  596. type: COMPOSE_MOUNT,
  597. };
  598. }
  599. export function unmountCompose() {
  600. return {
  601. type: COMPOSE_UNMOUNT,
  602. };
  603. }
  604. export function changeComposeSensitivity() {
  605. return {
  606. type: COMPOSE_SENSITIVITY_CHANGE,
  607. };
  608. }
  609. export const changeComposeLanguage = language => ({
  610. type: COMPOSE_LANGUAGE_CHANGE,
  611. language,
  612. });
  613. export function changeComposeSpoilerness() {
  614. return {
  615. type: COMPOSE_SPOILERNESS_CHANGE,
  616. };
  617. }
  618. export function changeComposeSpoilerText(text) {
  619. return {
  620. type: COMPOSE_SPOILER_TEXT_CHANGE,
  621. text,
  622. };
  623. }
  624. export function changeComposeVisibility(value) {
  625. return {
  626. type: COMPOSE_VISIBILITY_CHANGE,
  627. value,
  628. };
  629. }
  630. export function insertEmojiCompose(position, emoji, needsSpace) {
  631. return {
  632. type: COMPOSE_EMOJI_INSERT,
  633. position,
  634. emoji,
  635. needsSpace,
  636. };
  637. }
  638. export function changeComposing(value) {
  639. return {
  640. type: COMPOSE_COMPOSING_CHANGE,
  641. value,
  642. };
  643. }
  644. export function addPoll() {
  645. return {
  646. type: COMPOSE_POLL_ADD,
  647. };
  648. }
  649. export function removePoll() {
  650. return {
  651. type: COMPOSE_POLL_REMOVE,
  652. };
  653. }
  654. export function addPollOption(title) {
  655. return {
  656. type: COMPOSE_POLL_OPTION_ADD,
  657. title,
  658. };
  659. }
  660. export function changePollOption(index, title) {
  661. return {
  662. type: COMPOSE_POLL_OPTION_CHANGE,
  663. index,
  664. title,
  665. };
  666. }
  667. export function removePollOption(index) {
  668. return {
  669. type: COMPOSE_POLL_OPTION_REMOVE,
  670. index,
  671. };
  672. }
  673. export function changePollSettings(expiresIn, isMultiple) {
  674. return {
  675. type: COMPOSE_POLL_SETTINGS_CHANGE,
  676. expiresIn,
  677. isMultiple,
  678. };
  679. }