announcements.js 15 KB

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