The code powering m.abunchtell.com https://m.abunchtell.com
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 
 

348 lignes
13 KiB

  1. import React from 'react';
  2. import ImmutablePropTypes from 'react-immutable-proptypes';
  3. import PropTypes from 'prop-types';
  4. import { isRtl } from '../rtl';
  5. import { FormattedMessage } from 'react-intl';
  6. import Permalink from './permalink';
  7. import classnames from 'classnames';
  8. import PollContainer from 'mastodon/containers/poll_container';
  9. import Icon from 'mastodon/components/icon';
  10. import { autoPlayGif } from 'mastodon/initial_state';
  11. import { decode as decodeIDNA } from 'mastodon/utils/idna';
  12. const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
  13. // Regex matching what "looks like a link", that is, something that starts with
  14. // an optional "http://" or "https://" scheme and then what could look like a
  15. // domain main, that is, at least two sequences of characters not including spaces
  16. // and separated by "." or an homoglyph. The idea is not to match valid URLs or
  17. // domain names, but what could be confused for a valid URL or domain name,
  18. // especially to the untrained eye.
  19. const h_confusables = 'h\u13c2\u1d58d\u1d4f1\u1d691\u0068\uff48\u1d525\u210e\u1d489\u1d629\u0570\u1d4bd\u1d65d\u1d421\u1d5c1\u1d5f5\u04bb\u1d559';
  20. const t_confusables = 't\u1d42d\u1d5cd\u1d531\u1d565\u1d4c9\u1d669\u1d4fd\u1d69d\u0074\u1d461\u1d601\u1d495\u1d635\u1d599';
  21. const p_confusables = 'p\u0440\u03c1\u1d52d\u1d631\u1d665\u1d429\uff50\u1d6e0\u1d45d\u1d561\u1d595\u1d71a\u1d699\u1d78e\u2ca3\u1d754\u1d6d2\u1d491\u1d7c8\u1d746\u1d4c5\u1d70c\u1d5c9\u0070\u1d780\u03f1\u1d5fd\u2374\u1d7ba\u1d4f9';
  22. const s_confusables = 's\u1d530\u118c1\u1d494\u1d634\u1d4c8\u1d668\uabaa\u1d42c\u1d5cc\u1d460\u1d600\ua731\u0073\uff53\u1d564\u0455\u1d598\u1d4fc\u1d69c\u10448\u01bd';
  23. const column_confusables = ':\u0903\u0a83\u0703\u1803\u05c3\u0704\u0589\u1809\ua789\u16ec\ufe30\u02d0\u2236\u02f8\u003a\uff1a\u205a\ua4fd';
  24. const slash_confusables = '/\u2041\u2f03\u2044\u2cc6\u27cb\u30ce\u002f\u2571\u31d3\u3033\u1735\u2215\u29f8\u1d23a\u4e3f';
  25. const dot_confusables = '.\u002e\u0660\u06f0\u0701\u0702\u2024\ua4f8\ua60e\u10a50\u1d16d';
  26. const linkRegex = new RegExp(`^\\s*(([${h_confusables}][${t_confusables}][${t_confusables}][${p_confusables}][${s_confusables}]?[${column_confusables}][${slash_confusables}][${slash_confusables}]))?[^:/\\n ]+([${dot_confusables}][^:/\\n ]+)+`);
  27. const isLinkMisleading = (link) => {
  28. let linkTextParts = [];
  29. // Reconstruct visible text, as we do not have much control over how links
  30. // from remote software look, and we can't rely on `innerText` because the
  31. // `invisible` class does not set `display` to `none`.
  32. const walk = (node) => {
  33. switch (node.nodeType) {
  34. case Node.TEXT_NODE:
  35. linkTextParts.push(node.textContent);
  36. break;
  37. case Node.ELEMENT_NODE:
  38. if (node.classList.contains('invisible')) return;
  39. const children = node.childNodes;
  40. for (let i = 0; i < children.length; i++) {
  41. walk(children[i]);
  42. }
  43. break;
  44. }
  45. };
  46. walk(link);
  47. const linkText = linkTextParts.join('');
  48. const targetURL = new URL(link.href);
  49. // The following may not work with international domain names
  50. if (linkText === targetURL.origin || linkText === targetURL.host || 'www.' + linkText === targetURL.host || linkText.startsWith(targetURL.origin + '/') || linkText.startsWith(targetURL.host + '/')) {
  51. return false;
  52. }
  53. // The link hasn't been recognized, maybe it features an international domain name
  54. const hostname = decodeIDNA(targetURL.hostname);
  55. const host = targetURL.host.replace(targetURL.hostname, hostname);
  56. const origin = targetURL.origin.replace(targetURL.host, host);
  57. if (linkText === origin || linkText === host || linkText.startsWith(origin + '/') || linkText.startsWith(host + '/')) {
  58. return false;
  59. }
  60. // If the link text looks like an URL or auto-generated link, it is misleading
  61. return linkRegex.test(linkText);
  62. };
  63. export default class StatusContent extends React.PureComponent {
  64. static contextTypes = {
  65. router: PropTypes.object,
  66. };
  67. static propTypes = {
  68. status: ImmutablePropTypes.map.isRequired,
  69. expanded: PropTypes.bool,
  70. onExpandedToggle: PropTypes.func,
  71. onClick: PropTypes.func,
  72. collapsable: PropTypes.bool,
  73. };
  74. state = {
  75. hidden: true,
  76. collapsed: null, // `collapsed: null` indicates that an element doesn't need collapsing, while `true` or `false` indicates that it does (and is/isn't).
  77. };
  78. _updateStatusLinks () {
  79. const node = this.node;
  80. if (!node) {
  81. return;
  82. }
  83. const links = node.querySelectorAll('a');
  84. for (var i = 0; i < links.length; ++i) {
  85. let link = links[i];
  86. if (link.classList.contains('status-link')) {
  87. continue;
  88. }
  89. link.classList.add('status-link');
  90. let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
  91. if (mention) {
  92. link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
  93. link.setAttribute('title', mention.get('acct'));
  94. } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
  95. link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
  96. } else {
  97. link.setAttribute('title', link.href);
  98. link.classList.add('unhandled-link');
  99. if (isLinkMisleading(link)) {
  100. while (link.firstChild) {
  101. link.removeChild(link.firstChild);
  102. }
  103. const prefix = (link.href.match(/https?:\/\/(www\.)?/) || [''])[0];
  104. const text = link.href.substr(prefix.length, 30);
  105. const suffix = link.href.substr(prefix.length + 30);
  106. const cutoff = !!suffix;
  107. const prefixTag = document.createElement('span');
  108. prefixTag.classList.add('invisible');
  109. prefixTag.textContent = prefix;
  110. link.appendChild(prefixTag);
  111. const textTag = document.createElement('span');
  112. if (cutoff) {
  113. textTag.classList.add('ellipsis');
  114. }
  115. textTag.textContent = text;
  116. link.appendChild(textTag);
  117. const suffixTag = document.createElement('span');
  118. suffixTag.classList.add('invisible');
  119. suffixTag.textContent = suffix;
  120. link.appendChild(suffixTag);
  121. }
  122. }
  123. link.setAttribute('target', '_blank');
  124. link.setAttribute('rel', 'noopener');
  125. }
  126. if (
  127. this.props.collapsable
  128. && this.props.onClick
  129. && this.state.collapsed === null
  130. && node.clientHeight > MAX_HEIGHT
  131. && this.props.status.get('spoiler_text').length === 0
  132. ) {
  133. this.setState({ collapsed: true });
  134. }
  135. }
  136. _updateStatusEmojis () {
  137. const node = this.node;
  138. if (!node || autoPlayGif) {
  139. return;
  140. }
  141. const emojis = node.querySelectorAll('.custom-emoji');
  142. for (var i = 0; i < emojis.length; i++) {
  143. let emoji = emojis[i];
  144. if (emoji.classList.contains('status-emoji')) {
  145. continue;
  146. }
  147. emoji.classList.add('status-emoji');
  148. emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
  149. emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
  150. }
  151. }
  152. componentDidMount () {
  153. this._updateStatusLinks();
  154. this._updateStatusEmojis();
  155. }
  156. componentDidUpdate () {
  157. this._updateStatusLinks();
  158. this._updateStatusEmojis();
  159. }
  160. onMentionClick = (mention, e) => {
  161. if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  162. e.preventDefault();
  163. this.context.router.history.push(`/accounts/${mention.get('id')}`);
  164. }
  165. }
  166. onHashtagClick = (hashtag, e) => {
  167. hashtag = hashtag.replace(/^#/, '').toLowerCase();
  168. if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  169. e.preventDefault();
  170. this.context.router.history.push(`/timelines/tag/${hashtag}`);
  171. }
  172. }
  173. handleEmojiMouseEnter = ({ target }) => {
  174. target.src = target.getAttribute('data-original');
  175. }
  176. handleEmojiMouseLeave = ({ target }) => {
  177. target.src = target.getAttribute('data-static');
  178. }
  179. handleMouseDown = (e) => {
  180. this.startXY = [e.clientX, e.clientY];
  181. }
  182. handleMouseUp = (e) => {
  183. if (!this.startXY) {
  184. return;
  185. }
  186. const [ startX, startY ] = this.startXY;
  187. const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
  188. let element = e.target;
  189. while (element) {
  190. if (element.localName === 'button' || element.localName === 'a' || element.localName === 'label') {
  191. return;
  192. }
  193. element = element.parentNode;
  194. }
  195. if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
  196. this.props.onClick();
  197. }
  198. this.startXY = null;
  199. }
  200. handleSpoilerClick = (e) => {
  201. e.preventDefault();
  202. if (this.props.onExpandedToggle) {
  203. // The parent manages the state
  204. this.props.onExpandedToggle();
  205. } else {
  206. this.setState({ hidden: !this.state.hidden });
  207. }
  208. }
  209. handleCollapsedClick = (e) => {
  210. e.preventDefault();
  211. this.setState({ collapsed: !this.state.collapsed });
  212. }
  213. setRef = (c) => {
  214. this.node = c;
  215. }
  216. render () {
  217. const { status } = this.props;
  218. if (status.get('content').length === 0) {
  219. return null;
  220. }
  221. const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
  222. const content = { __html: status.get('contentHtml') };
  223. const spoilerContent = { __html: status.get('spoilerHtml') };
  224. const directionStyle = { direction: 'ltr' };
  225. const classNames = classnames('status__content', {
  226. 'status__content--with-action': this.props.onClick && this.context.router,
  227. 'status__content--with-spoiler': status.get('spoiler_text').length > 0,
  228. 'status__content--collapsed': this.state.collapsed === true,
  229. });
  230. if (isRtl(status.get('search_index'))) {
  231. directionStyle.direction = 'rtl';
  232. }
  233. const readMoreButton = (
  234. <button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'>
  235. <FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' fixedWidth />
  236. </button>
  237. );
  238. if (status.get('spoiler_text').length > 0) {
  239. let mentionsPlaceholder = '';
  240. const mentionLinks = status.get('mentions').map(item => (
  241. <Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'>
  242. @<span>{item.get('username')}</span>
  243. </Permalink>
  244. )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
  245. const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
  246. if (hidden) {
  247. mentionsPlaceholder = <div>{mentionLinks}</div>;
  248. }
  249. return (
  250. <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
  251. <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
  252. <span dangerouslySetInnerHTML={spoilerContent} lang={status.get('language')} />
  253. {' '}
  254. <button tabIndex='0' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick}>{toggleText}</button>
  255. </p>
  256. {mentionsPlaceholder}
  257. <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
  258. {!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
  259. </div>
  260. );
  261. } else if (this.props.onClick) {
  262. return (
  263. <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
  264. <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
  265. {!!this.state.collapsed && readMoreButton}
  266. {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
  267. </div>
  268. );
  269. } else {
  270. return (
  271. <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
  272. <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
  273. {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
  274. </div>
  275. );
  276. }
  277. }
  278. }