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.

456 lines
15 KiB

  1. import React from 'react';
  2. import ImmutablePureComponent from 'react-immutable-pure-component';
  3. import ReactSwipeableViews from 'react-swipeable-views';
  4. import ImmutablePropTypes from 'react-immutable-proptypes';
  5. import PropTypes from 'prop-types';
  6. import IconButton from 'mastodon/components/icon_button';
  7. import Icon from 'mastodon/components/icon';
  8. import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
  9. import { autoPlayGif, reduceMotion, disableSwiping } from 'mastodon/initial_state';
  10. import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
  11. import { mascot } from 'mastodon/initial_state';
  12. import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
  13. import classNames from 'classnames';
  14. import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
  15. import AnimatedNumber from 'mastodon/components/animated_number';
  16. import TransitionMotion from 'react-motion/lib/TransitionMotion';
  17. import spring from 'react-motion/lib/spring';
  18. const messages = defineMessages({
  19. close: { id: 'lightbox.close', defaultMessage: 'Close' },
  20. previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
  21. next: { id: 'lightbox.next', defaultMessage: 'Next' },
  22. });
  23. class Content extends ImmutablePureComponent {
  24. static contextTypes = {
  25. router: PropTypes.object,
  26. };
  27. static propTypes = {
  28. announcement: ImmutablePropTypes.map.isRequired,
  29. };
  30. setRef = c => {
  31. this.node = c;
  32. }
  33. componentDidMount () {
  34. this._updateLinks();
  35. this._updateEmojis();
  36. }
  37. componentDidUpdate () {
  38. this._updateLinks();
  39. this._updateEmojis();
  40. }
  41. _updateEmojis () {
  42. const node = this.node;
  43. if (!node || autoPlayGif) {
  44. return;
  45. }
  46. const emojis = node.querySelectorAll('.custom-emoji');
  47. for (var i = 0; i < emojis.length; i++) {
  48. let emoji = emojis[i];
  49. if (emoji.classList.contains('status-emoji')) {
  50. continue;
  51. }
  52. emoji.classList.add('status-emoji');
  53. emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
  54. emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
  55. }
  56. }
  57. _updateLinks () {
  58. const node = this.node;
  59. if (!node) {
  60. return;
  61. }
  62. const links = node.querySelectorAll('a');
  63. for (var i = 0; i < links.length; ++i) {
  64. let link = links[i];
  65. if (link.classList.contains('status-link')) {
  66. continue;
  67. }
  68. link.classList.add('status-link');
  69. let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
  70. if (mention) {
  71. link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
  72. link.setAttribute('title', mention.get('acct'));
  73. } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
  74. link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
  75. } else {
  76. let status = this.props.announcement.get('statuses').find(item => link.href === item.get('url'));
  77. if (status) {
  78. link.addEventListener('click', this.onStatusClick.bind(this, status), false);
  79. }
  80. link.setAttribute('title', link.href);
  81. link.classList.add('unhandled-link');
  82. }
  83. link.setAttribute('target', '_blank');
  84. link.setAttribute('rel', 'noopener noreferrer');
  85. }
  86. }
  87. onMentionClick = (mention, e) => {
  88. if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  89. e.preventDefault();
  90. this.context.router.history.push(`/accounts/${mention.get('id')}`);
  91. }
  92. }
  93. onHashtagClick = (hashtag, e) => {
  94. hashtag = hashtag.replace(/^#/, '');
  95. if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  96. e.preventDefault();
  97. this.context.router.history.push(`/timelines/tag/${hashtag}`);
  98. }
  99. }
  100. onStatusClick = (status, e) => {
  101. if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  102. e.preventDefault();
  103. this.context.router.history.push(`/statuses/${status.get('id')}`);
  104. }
  105. }
  106. handleEmojiMouseEnter = ({ target }) => {
  107. target.src = target.getAttribute('data-original');
  108. }
  109. handleEmojiMouseLeave = ({ target }) => {
  110. target.src = target.getAttribute('data-static');
  111. }
  112. render () {
  113. const { announcement } = this.props;
  114. return (
  115. <div
  116. className='announcements__item__content'
  117. ref={this.setRef}
  118. dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
  119. />
  120. );
  121. }
  122. }
  123. const assetHost = process.env.CDN_HOST || '';
  124. class Emoji extends React.PureComponent {
  125. static propTypes = {
  126. emoji: PropTypes.string.isRequired,
  127. emojiMap: ImmutablePropTypes.map.isRequired,
  128. hovered: PropTypes.bool.isRequired,
  129. };
  130. render () {
  131. const { emoji, emojiMap, hovered } = this.props;
  132. if (unicodeMapping[emoji]) {
  133. const { filename, shortCode } = unicodeMapping[this.props.emoji];
  134. const title = shortCode ? `:${shortCode}:` : '';
  135. return (
  136. <img
  137. draggable='false'
  138. className='emojione'
  139. alt={emoji}
  140. title={title}
  141. src={`${assetHost}/emoji/${filename}.svg`}
  142. />
  143. );
  144. } else if (emojiMap.get(emoji)) {
  145. const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
  146. const shortCode = `:${emoji}:`;
  147. return (
  148. <img
  149. draggable='false'
  150. className='emojione custom-emoji'
  151. alt={shortCode}
  152. title={shortCode}
  153. src={filename}
  154. />
  155. );
  156. } else {
  157. return null;
  158. }
  159. }
  160. }
  161. class Reaction extends ImmutablePureComponent {
  162. static propTypes = {
  163. announcementId: PropTypes.string.isRequired,
  164. reaction: ImmutablePropTypes.map.isRequired,
  165. addReaction: PropTypes.func.isRequired,
  166. removeReaction: PropTypes.func.isRequired,
  167. emojiMap: ImmutablePropTypes.map.isRequired,
  168. style: PropTypes.object,
  169. };
  170. state = {
  171. hovered: false,
  172. };
  173. handleClick = () => {
  174. const { reaction, announcementId, addReaction, removeReaction } = this.props;
  175. if (reaction.get('me')) {
  176. removeReaction(announcementId, reaction.get('name'));
  177. } else {
  178. addReaction(announcementId, reaction.get('name'));
  179. }
  180. }
  181. handleMouseEnter = () => this.setState({ hovered: true })
  182. handleMouseLeave = () => this.setState({ hovered: false })
  183. render () {
  184. const { reaction } = this.props;
  185. let shortCode = reaction.get('name');
  186. if (unicodeMapping[shortCode]) {
  187. shortCode = unicodeMapping[shortCode].shortCode;
  188. }
  189. return (
  190. <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
  191. <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
  192. <span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
  193. </button>
  194. );
  195. }
  196. }
  197. class ReactionsBar extends ImmutablePureComponent {
  198. static propTypes = {
  199. announcementId: PropTypes.string.isRequired,
  200. reactions: ImmutablePropTypes.list.isRequired,
  201. addReaction: PropTypes.func.isRequired,
  202. removeReaction: PropTypes.func.isRequired,
  203. emojiMap: ImmutablePropTypes.map.isRequired,
  204. };
  205. handleEmojiPick = data => {
  206. const { addReaction, announcementId } = this.props;
  207. addReaction(announcementId, data.native.replace(/:/g, ''));
  208. }
  209. willEnter () {
  210. return { scale: reduceMotion ? 1 : 0 };
  211. }
  212. willLeave () {
  213. return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
  214. }
  215. render () {
  216. const { reactions } = this.props;
  217. const visibleReactions = reactions.filter(x => x.get('count') > 0);
  218. const styles = visibleReactions.map(reaction => ({
  219. key: reaction.get('name'),
  220. data: reaction,
  221. style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
  222. })).toArray();
  223. return (
  224. <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
  225. {items => (
  226. <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
  227. {items.map(({ key, data, style }) => (
  228. <Reaction
  229. key={key}
  230. reaction={data}
  231. style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
  232. announcementId={this.props.announcementId}
  233. addReaction={this.props.addReaction}
  234. removeReaction={this.props.removeReaction}
  235. emojiMap={this.props.emojiMap}
  236. />
  237. ))}
  238. {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
  239. </div>
  240. )}
  241. </TransitionMotion>
  242. );
  243. }
  244. }
  245. class Announcement extends ImmutablePureComponent {
  246. static propTypes = {
  247. announcement: ImmutablePropTypes.map.isRequired,
  248. emojiMap: ImmutablePropTypes.map.isRequired,
  249. addReaction: PropTypes.func.isRequired,
  250. removeReaction: PropTypes.func.isRequired,
  251. intl: PropTypes.object.isRequired,
  252. selected: PropTypes.bool,
  253. };
  254. state = {
  255. unread: !this.props.announcement.get('read'),
  256. };
  257. componentDidUpdate () {
  258. const { selected, announcement } = this.props;
  259. if (!selected && this.state.unread !== !announcement.get('read')) {
  260. this.setState({ unread: !announcement.get('read') });
  261. }
  262. }
  263. render () {
  264. const { announcement } = this.props;
  265. const { unread } = this.state;
  266. const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
  267. const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
  268. const now = new Date();
  269. const hasTimeRange = startsAt && endsAt;
  270. const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
  271. const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
  272. const skipTime = announcement.get('all_day');
  273. return (
  274. <div className='announcements__item'>
  275. <strong className='announcements__item__range'>
  276. <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
  277. {hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>}
  278. </strong>
  279. <Content announcement={announcement} />
  280. <ReactionsBar
  281. reactions={announcement.get('reactions')}
  282. announcementId={announcement.get('id')}
  283. addReaction={this.props.addReaction}
  284. removeReaction={this.props.removeReaction}
  285. emojiMap={this.props.emojiMap}
  286. />
  287. {unread && <span className='announcements__item__unread' />}
  288. </div>
  289. );
  290. }
  291. }
  292. export default @injectIntl
  293. class Announcements extends ImmutablePureComponent {
  294. static propTypes = {
  295. announcements: ImmutablePropTypes.list,
  296. emojiMap: ImmutablePropTypes.map.isRequired,
  297. dismissAnnouncement: PropTypes.func.isRequired,
  298. addReaction: PropTypes.func.isRequired,
  299. removeReaction: PropTypes.func.isRequired,
  300. intl: PropTypes.object.isRequired,
  301. };
  302. state = {
  303. index: 0,
  304. };
  305. static getDerivedStateFromProps(props, state) {
  306. if (props.announcements.size > 0 && state.index >= props.announcements.size) {
  307. return { index: props.announcements.size - 1 };
  308. } else {
  309. return null;
  310. }
  311. }
  312. componentDidMount () {
  313. this._markAnnouncementAsRead();
  314. }
  315. componentDidUpdate () {
  316. this._markAnnouncementAsRead();
  317. }
  318. _markAnnouncementAsRead () {
  319. const { dismissAnnouncement, announcements } = this.props;
  320. const { index } = this.state;
  321. const announcement = announcements.get(index);
  322. if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
  323. }
  324. handleChangeIndex = index => {
  325. this.setState({ index: index % this.props.announcements.size });
  326. }
  327. handleNextClick = () => {
  328. this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
  329. }
  330. handlePrevClick = () => {
  331. this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
  332. }
  333. render () {
  334. const { announcements, intl } = this.props;
  335. const { index } = this.state;
  336. if (announcements.isEmpty()) {
  337. return null;
  338. }
  339. return (
  340. <div className='announcements'>
  341. <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
  342. <div className='announcements__container'>
  343. <ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}>
  344. {announcements.map((announcement, idx) => (
  345. <Announcement
  346. key={announcement.get('id')}
  347. announcement={announcement}
  348. emojiMap={this.props.emojiMap}
  349. addReaction={this.props.addReaction}
  350. removeReaction={this.props.removeReaction}
  351. intl={intl}
  352. selected={index === idx}
  353. disabled={disableSwiping}
  354. />
  355. ))}
  356. </ReactSwipeableViews>
  357. {announcements.size > 1 && (
  358. <div className='announcements__pagination'>
  359. <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
  360. <span>{index + 1} / {announcements.size}</span>
  361. <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
  362. </div>
  363. )}
  364. </div>
  365. </div>
  366. );
  367. }
  368. }