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.
 
 
 
 

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