The code powering m.abunchtell.com https://m.abunchtell.com
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.
 
 
 
 

394 lines
13 KiB

  1. import {
  2. COMPOSE_MOUNT,
  3. COMPOSE_UNMOUNT,
  4. COMPOSE_CHANGE,
  5. COMPOSE_REPLY,
  6. COMPOSE_REPLY_CANCEL,
  7. COMPOSE_DIRECT,
  8. COMPOSE_MENTION,
  9. COMPOSE_SUBMIT_REQUEST,
  10. COMPOSE_SUBMIT_SUCCESS,
  11. COMPOSE_SUBMIT_FAIL,
  12. COMPOSE_UPLOAD_REQUEST,
  13. COMPOSE_UPLOAD_SUCCESS,
  14. COMPOSE_UPLOAD_FAIL,
  15. COMPOSE_UPLOAD_UNDO,
  16. COMPOSE_UPLOAD_PROGRESS,
  17. COMPOSE_SUGGESTIONS_CLEAR,
  18. COMPOSE_SUGGESTIONS_READY,
  19. COMPOSE_SUGGESTION_SELECT,
  20. COMPOSE_TAG_HISTORY_UPDATE,
  21. COMPOSE_SENSITIVITY_CHANGE,
  22. COMPOSE_SPOILERNESS_CHANGE,
  23. COMPOSE_SPOILER_TEXT_CHANGE,
  24. COMPOSE_VISIBILITY_CHANGE,
  25. COMPOSE_COMPOSING_CHANGE,
  26. COMPOSE_EMOJI_INSERT,
  27. COMPOSE_UPLOAD_CHANGE_REQUEST,
  28. COMPOSE_UPLOAD_CHANGE_SUCCESS,
  29. COMPOSE_UPLOAD_CHANGE_FAIL,
  30. COMPOSE_RESET,
  31. COMPOSE_POLL_ADD,
  32. COMPOSE_POLL_REMOVE,
  33. COMPOSE_POLL_OPTION_ADD,
  34. COMPOSE_POLL_OPTION_CHANGE,
  35. COMPOSE_POLL_OPTION_REMOVE,
  36. COMPOSE_POLL_SETTINGS_CHANGE,
  37. } from '../actions/compose';
  38. import { TIMELINE_DELETE } from '../actions/timelines';
  39. import { STORE_HYDRATE } from '../actions/store';
  40. import { REDRAFT } from '../actions/statuses';
  41. import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
  42. import uuid from '../uuid';
  43. import { me } from '../initial_state';
  44. import { unescapeHTML } from '../utils/html';
  45. const initialState = ImmutableMap({
  46. mounted: 0,
  47. sensitive: false,
  48. spoiler: false,
  49. spoiler_text: '',
  50. privacy: null,
  51. text: '',
  52. focusDate: null,
  53. caretPosition: null,
  54. preselectDate: null,
  55. in_reply_to: null,
  56. is_composing: false,
  57. is_submitting: false,
  58. is_changing_upload: false,
  59. is_uploading: false,
  60. progress: 0,
  61. media_attachments: ImmutableList(),
  62. poll: null,
  63. suggestion_token: null,
  64. suggestions: ImmutableList(),
  65. default_privacy: 'public',
  66. default_sensitive: false,
  67. resetFileKey: Math.floor((Math.random() * 0x10000)),
  68. idempotencyKey: null,
  69. tagHistory: ImmutableList(),
  70. });
  71. const initialPoll = ImmutableMap({
  72. options: ImmutableList(['', '']),
  73. expires_in: 24 * 3600,
  74. multiple: false,
  75. });
  76. function statusToTextMentions(state, status) {
  77. let set = ImmutableOrderedSet([]);
  78. if (status.getIn(['account', 'id']) !== me) {
  79. set = set.add(`@${status.getIn(['account', 'acct'])} `);
  80. }
  81. return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
  82. };
  83. function clearAll(state) {
  84. return state.withMutations(map => {
  85. map.set('text', '');
  86. map.set('spoiler', false);
  87. map.set('spoiler_text', '');
  88. map.set('is_submitting', false);
  89. map.set('is_changing_upload', false);
  90. map.set('in_reply_to', null);
  91. map.set('privacy', state.get('default_privacy'));
  92. map.set('sensitive', false);
  93. map.update('media_attachments', list => list.clear());
  94. map.set('poll', null);
  95. map.set('idempotencyKey', uuid());
  96. });
  97. };
  98. function appendMedia(state, media) {
  99. const prevSize = state.get('media_attachments').size;
  100. return state.withMutations(map => {
  101. map.update('media_attachments', list => list.push(media));
  102. map.set('is_uploading', false);
  103. map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
  104. map.set('idempotencyKey', uuid());
  105. if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
  106. map.set('sensitive', true);
  107. }
  108. });
  109. };
  110. function removeMedia(state, mediaId) {
  111. const prevSize = state.get('media_attachments').size;
  112. return state.withMutations(map => {
  113. map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId));
  114. map.set('idempotencyKey', uuid());
  115. if (prevSize === 1) {
  116. map.set('sensitive', false);
  117. }
  118. });
  119. };
  120. const insertSuggestion = (state, position, token, completion, path) => {
  121. return state.withMutations(map => {
  122. map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
  123. map.set('suggestion_token', null);
  124. map.set('suggestions', ImmutableList());
  125. if (path.length === 1 && path[0] === 'text') {
  126. map.set('focusDate', new Date());
  127. map.set('caretPosition', position + completion.length + 1);
  128. }
  129. map.set('idempotencyKey', uuid());
  130. });
  131. };
  132. const sortHashtagsByUse = (state, tags) => {
  133. const personalHistory = state.get('tagHistory');
  134. return tags.sort((a, b) => {
  135. const usedA = personalHistory.includes(a.name);
  136. const usedB = personalHistory.includes(b.name);
  137. if (usedA === usedB) {
  138. return 0;
  139. } else if (usedA && !usedB) {
  140. return -1;
  141. } else {
  142. return 1;
  143. }
  144. });
  145. };
  146. const insertEmoji = (state, position, emojiData, needsSpace) => {
  147. const oldText = state.get('text');
  148. const emoji = needsSpace ? ' ' + emojiData.native : emojiData.native;
  149. return state.merge({
  150. text: `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`,
  151. focusDate: new Date(),
  152. caretPosition: position + emoji.length + 1,
  153. idempotencyKey: uuid(),
  154. });
  155. };
  156. const privacyPreference = (a, b) => {
  157. const order = ['public', 'unlisted', 'private', 'direct'];
  158. return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
  159. };
  160. const hydrate = (state, hydratedState) => {
  161. state = clearAll(state.merge(hydratedState));
  162. if (hydratedState.has('text')) {
  163. state = state.set('text', hydratedState.get('text'));
  164. }
  165. return state;
  166. };
  167. const domParser = new DOMParser();
  168. const expandMentions = status => {
  169. const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement;
  170. status.get('mentions').forEach(mention => {
  171. fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`;
  172. });
  173. return fragment.innerHTML;
  174. };
  175. const expiresInFromExpiresAt = expires_at => {
  176. if (!expires_at) return 24 * 3600;
  177. const delta = (new Date(expires_at).getTime() - Date.now()) / 1000;
  178. return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
  179. };
  180. const normalizeSuggestions = (state, { accounts, emojis, tags }) => {
  181. if (accounts) {
  182. return accounts.map(item => ({ id: item.id, type: 'account' }));
  183. } else if (emojis) {
  184. return emojis.map(item => ({ ...item, type: 'emoji' }));
  185. } else {
  186. return sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' })));
  187. }
  188. };
  189. export default function compose(state = initialState, action) {
  190. switch(action.type) {
  191. case STORE_HYDRATE:
  192. return hydrate(state, action.state.get('compose'));
  193. case COMPOSE_MOUNT:
  194. return state.set('mounted', state.get('mounted') + 1);
  195. case COMPOSE_UNMOUNT:
  196. return state
  197. .set('mounted', Math.max(state.get('mounted') - 1, 0))
  198. .set('is_composing', false);
  199. case COMPOSE_SENSITIVITY_CHANGE:
  200. return state.withMutations(map => {
  201. if (!state.get('spoiler')) {
  202. map.set('sensitive', !state.get('sensitive'));
  203. }
  204. map.set('idempotencyKey', uuid());
  205. });
  206. case COMPOSE_SPOILERNESS_CHANGE:
  207. return state.withMutations(map => {
  208. map.set('spoiler_text', '');
  209. map.set('spoiler', !state.get('spoiler'));
  210. map.set('idempotencyKey', uuid());
  211. if (!state.get('sensitive') && state.get('media_attachments').size >= 1) {
  212. map.set('sensitive', true);
  213. }
  214. });
  215. case COMPOSE_SPOILER_TEXT_CHANGE:
  216. if (!state.get('spoiler')) return state;
  217. return state
  218. .set('spoiler_text', action.text)
  219. .set('idempotencyKey', uuid());
  220. case COMPOSE_VISIBILITY_CHANGE:
  221. return state
  222. .set('privacy', action.value)
  223. .set('idempotencyKey', uuid());
  224. case COMPOSE_CHANGE:
  225. return state
  226. .set('text', action.text)
  227. .set('idempotencyKey', uuid());
  228. case COMPOSE_COMPOSING_CHANGE:
  229. return state.set('is_composing', action.value);
  230. case COMPOSE_REPLY:
  231. return state.withMutations(map => {
  232. map.set('in_reply_to', action.status.get('id'));
  233. map.set('text', statusToTextMentions(state, action.status));
  234. map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
  235. map.set('focusDate', new Date());
  236. map.set('caretPosition', null);
  237. map.set('preselectDate', new Date());
  238. map.set('idempotencyKey', uuid());
  239. if (action.status.get('spoiler_text').length > 0) {
  240. map.set('spoiler', true);
  241. map.set('spoiler_text', action.status.get('spoiler_text'));
  242. } else {
  243. map.set('spoiler', false);
  244. map.set('spoiler_text', '');
  245. }
  246. });
  247. case COMPOSE_REPLY_CANCEL:
  248. case COMPOSE_RESET:
  249. return state.withMutations(map => {
  250. map.set('in_reply_to', null);
  251. map.set('text', '');
  252. map.set('spoiler', false);
  253. map.set('spoiler_text', '');
  254. map.set('privacy', state.get('default_privacy'));
  255. map.set('poll', null);
  256. map.set('idempotencyKey', uuid());
  257. });
  258. case COMPOSE_SUBMIT_REQUEST:
  259. return state.set('is_submitting', true);
  260. case COMPOSE_UPLOAD_CHANGE_REQUEST:
  261. return state.set('is_changing_upload', true);
  262. case COMPOSE_SUBMIT_SUCCESS:
  263. return clearAll(state);
  264. case COMPOSE_SUBMIT_FAIL:
  265. return state.set('is_submitting', false);
  266. case COMPOSE_UPLOAD_CHANGE_FAIL:
  267. return state.set('is_changing_upload', false);
  268. case COMPOSE_UPLOAD_REQUEST:
  269. return state.set('is_uploading', true);
  270. case COMPOSE_UPLOAD_SUCCESS:
  271. return appendMedia(state, fromJS(action.media));
  272. case COMPOSE_UPLOAD_FAIL:
  273. return state.set('is_uploading', false);
  274. case COMPOSE_UPLOAD_UNDO:
  275. return removeMedia(state, action.media_id);
  276. case COMPOSE_UPLOAD_PROGRESS:
  277. return state.set('progress', Math.round((action.loaded / action.total) * 100));
  278. case COMPOSE_MENTION:
  279. return state.withMutations(map => {
  280. map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
  281. map.set('focusDate', new Date());
  282. map.set('caretPosition', null);
  283. map.set('idempotencyKey', uuid());
  284. });
  285. case COMPOSE_DIRECT:
  286. return state.withMutations(map => {
  287. map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
  288. map.set('privacy', 'direct');
  289. map.set('focusDate', new Date());
  290. map.set('caretPosition', null);
  291. map.set('idempotencyKey', uuid());
  292. });
  293. case COMPOSE_SUGGESTIONS_CLEAR:
  294. return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
  295. case COMPOSE_SUGGESTIONS_READY:
  296. return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
  297. case COMPOSE_SUGGESTION_SELECT:
  298. return insertSuggestion(state, action.position, action.token, action.completion, action.path);
  299. case COMPOSE_TAG_HISTORY_UPDATE:
  300. return state.set('tagHistory', fromJS(action.tags));
  301. case TIMELINE_DELETE:
  302. if (action.id === state.get('in_reply_to')) {
  303. return state.set('in_reply_to', null);
  304. } else {
  305. return state;
  306. }
  307. case COMPOSE_EMOJI_INSERT:
  308. return insertEmoji(state, action.position, action.emoji, action.needsSpace);
  309. case COMPOSE_UPLOAD_CHANGE_SUCCESS:
  310. return state
  311. .set('is_changing_upload', false)
  312. .update('media_attachments', list => list.map(item => {
  313. if (item.get('id') === action.media.id) {
  314. return fromJS(action.media);
  315. }
  316. return item;
  317. }));
  318. case REDRAFT:
  319. return state.withMutations(map => {
  320. map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
  321. map.set('in_reply_to', action.status.get('in_reply_to_id'));
  322. map.set('privacy', action.status.get('visibility'));
  323. map.set('media_attachments', action.status.get('media_attachments'));
  324. map.set('focusDate', new Date());
  325. map.set('caretPosition', null);
  326. map.set('idempotencyKey', uuid());
  327. map.set('sensitive', action.status.get('sensitive'));
  328. if (action.status.get('spoiler_text').length > 0) {
  329. map.set('spoiler', true);
  330. map.set('spoiler_text', action.status.get('spoiler_text'));
  331. } else {
  332. map.set('spoiler', false);
  333. map.set('spoiler_text', '');
  334. }
  335. if (action.status.get('poll')) {
  336. map.set('poll', ImmutableMap({
  337. options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
  338. multiple: action.status.getIn(['poll', 'multiple']),
  339. expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
  340. }));
  341. }
  342. });
  343. case COMPOSE_POLL_ADD:
  344. return state.set('poll', initialPoll);
  345. case COMPOSE_POLL_REMOVE:
  346. return state.set('poll', null);
  347. case COMPOSE_POLL_OPTION_ADD:
  348. return state.updateIn(['poll', 'options'], options => options.push(action.title));
  349. case COMPOSE_POLL_OPTION_CHANGE:
  350. return state.setIn(['poll', 'options', action.index], action.title);
  351. case COMPOSE_POLL_OPTION_REMOVE:
  352. return state.updateIn(['poll', 'options'], options => options.delete(action.index));
  353. case COMPOSE_POLL_SETTINGS_CHANGE:
  354. return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
  355. default:
  356. return state;
  357. }
  358. };