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.
 
 
 
 

437 lines
14 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 } 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. link.setAttribute('title', link.href);
  77. link.classList.add('unhandled-link');
  78. }
  79. link.setAttribute('target', '_blank');
  80. link.setAttribute('rel', 'noopener noreferrer');
  81. }
  82. }
  83. onMentionClick = (mention, e) => {
  84. if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  85. e.preventDefault();
  86. this.context.router.history.push(`/accounts/${mention.get('id')}`);
  87. }
  88. }
  89. onHashtagClick = (hashtag, e) => {
  90. hashtag = hashtag.replace(/^#/, '');
  91. if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
  92. e.preventDefault();
  93. this.context.router.history.push(`/timelines/tag/${hashtag}`);
  94. }
  95. }
  96. handleEmojiMouseEnter = ({ target }) => {
  97. target.src = target.getAttribute('data-original');
  98. }
  99. handleEmojiMouseLeave = ({ target }) => {
  100. target.src = target.getAttribute('data-static');
  101. }
  102. render () {
  103. const { announcement } = this.props;
  104. return (
  105. <div
  106. className='announcements__item__content'
  107. ref={this.setRef}
  108. dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
  109. />
  110. );
  111. }
  112. }
  113. const assetHost = process.env.CDN_HOST || '';
  114. class Emoji extends React.PureComponent {
  115. static propTypes = {
  116. emoji: PropTypes.string.isRequired,
  117. emojiMap: ImmutablePropTypes.map.isRequired,
  118. hovered: PropTypes.bool.isRequired,
  119. };
  120. render () {
  121. const { emoji, emojiMap, hovered } = this.props;
  122. if (unicodeMapping[emoji]) {
  123. const { filename, shortCode } = unicodeMapping[this.props.emoji];
  124. const title = shortCode ? `:${shortCode}:` : '';
  125. return (
  126. <img
  127. draggable='false'
  128. className='emojione'
  129. alt={emoji}
  130. title={title}
  131. src={`${assetHost}/emoji/${filename}.svg`}
  132. />
  133. );
  134. } else if (emojiMap.get(emoji)) {
  135. const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
  136. const shortCode = `:${emoji}:`;
  137. return (
  138. <img
  139. draggable='false'
  140. className='emojione custom-emoji'
  141. alt={shortCode}
  142. title={shortCode}
  143. src={filename}
  144. />
  145. );
  146. } else {
  147. return null;
  148. }
  149. }
  150. }
  151. class Reaction extends ImmutablePureComponent {
  152. static propTypes = {
  153. announcementId: PropTypes.string.isRequired,
  154. reaction: ImmutablePropTypes.map.isRequired,
  155. addReaction: PropTypes.func.isRequired,
  156. removeReaction: PropTypes.func.isRequired,
  157. emojiMap: ImmutablePropTypes.map.isRequired,
  158. style: PropTypes.object,
  159. };
  160. state = {
  161. hovered: false,
  162. };
  163. handleClick = () => {
  164. const { reaction, announcementId, addReaction, removeReaction } = this.props;
  165. if (reaction.get('me')) {
  166. removeReaction(announcementId, reaction.get('name'));
  167. } else {
  168. addReaction(announcementId, reaction.get('name'));
  169. }
  170. }
  171. handleMouseEnter = () => this.setState({ hovered: true })
  172. handleMouseLeave = () => this.setState({ hovered: false })
  173. render () {
  174. const { reaction } = this.props;
  175. let shortCode = reaction.get('name');
  176. if (unicodeMapping[shortCode]) {
  177. shortCode = unicodeMapping[shortCode].shortCode;
  178. }
  179. return (
  180. <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}>
  181. <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
  182. <span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
  183. </button>
  184. );
  185. }
  186. }
  187. class ReactionsBar extends ImmutablePureComponent {
  188. static propTypes = {
  189. announcementId: PropTypes.string.isRequired,
  190. reactions: ImmutablePropTypes.list.isRequired,
  191. addReaction: PropTypes.func.isRequired,
  192. removeReaction: PropTypes.func.isRequired,
  193. emojiMap: ImmutablePropTypes.map.isRequired,
  194. };
  195. handleEmojiPick = data => {
  196. const { addReaction, announcementId } = this.props;
  197. addReaction(announcementId, data.native.replace(/:/g, ''));
  198. }
  199. willEnter () {
  200. return { scale: reduceMotion ? 1 : 0 };
  201. }
  202. willLeave () {
  203. return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
  204. }
  205. render () {
  206. const { reactions } = this.props;
  207. const visibleReactions = reactions.filter(x => x.get('count') > 0);
  208. const styles = visibleReactions.map(reaction => ({
  209. key: reaction.get('name'),
  210. data: reaction,
  211. style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
  212. })).toArray();
  213. return (
  214. <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
  215. {items => (
  216. <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
  217. {items.map(({ key, data, style }) => (
  218. <Reaction
  219. key={key}
  220. reaction={data}
  221. style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
  222. announcementId={this.props.announcementId}
  223. addReaction={this.props.addReaction}
  224. removeReaction={this.props.removeReaction}
  225. emojiMap={this.props.emojiMap}
  226. />
  227. ))}
  228. {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
  229. </div>
  230. )}
  231. </TransitionMotion>
  232. );
  233. }
  234. }
  235. class Announcement extends ImmutablePureComponent {
  236. static propTypes = {
  237. announcement: ImmutablePropTypes.map.isRequired,
  238. emojiMap: ImmutablePropTypes.map.isRequired,
  239. addReaction: PropTypes.func.isRequired,
  240. removeReaction: PropTypes.func.isRequired,
  241. intl: PropTypes.object.isRequired,
  242. selected: PropTypes.bool,
  243. };
  244. state = {
  245. unread: !this.props.announcement.get('read'),
  246. };
  247. componentDidUpdate () {
  248. const { selected, announcement } = this.props;
  249. if (!selected && this.state.unread !== !announcement.get('read')) {
  250. this.setState({ unread: !announcement.get('read') });
  251. }
  252. }
  253. render () {
  254. const { announcement } = this.props;
  255. const { unread } = this.state;
  256. const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
  257. const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
  258. const now = new Date();
  259. const hasTimeRange = startsAt && endsAt;
  260. const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
  261. const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
  262. const skipTime = announcement.get('all_day');
  263. return (
  264. <div className='announcements__item'>
  265. <strong className='announcements__item__range'>
  266. <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
  267. {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>}
  268. </strong>
  269. <Content announcement={announcement} />
  270. <ReactionsBar
  271. reactions={announcement.get('reactions')}
  272. announcementId={announcement.get('id')}
  273. addReaction={this.props.addReaction}
  274. removeReaction={this.props.removeReaction}
  275. emojiMap={this.props.emojiMap}
  276. />
  277. {unread && <span className='announcements__item__unread' />}
  278. </div>
  279. );
  280. }
  281. }
  282. export default @injectIntl
  283. class Announcements extends ImmutablePureComponent {
  284. static propTypes = {
  285. announcements: ImmutablePropTypes.list,
  286. emojiMap: ImmutablePropTypes.map.isRequired,
  287. dismissAnnouncement: PropTypes.func.isRequired,
  288. addReaction: PropTypes.func.isRequired,
  289. removeReaction: PropTypes.func.isRequired,
  290. intl: PropTypes.object.isRequired,
  291. };
  292. state = {
  293. index: 0,
  294. };
  295. componentDidMount () {
  296. this._markAnnouncementAsRead();
  297. }
  298. componentDidUpdate () {
  299. this._markAnnouncementAsRead();
  300. }
  301. _markAnnouncementAsRead () {
  302. const { dismissAnnouncement, announcements } = this.props;
  303. const { index } = this.state;
  304. const announcement = announcements.get(index);
  305. if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
  306. }
  307. handleChangeIndex = index => {
  308. this.setState({ index: index % this.props.announcements.size });
  309. }
  310. handleNextClick = () => {
  311. this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
  312. }
  313. handlePrevClick = () => {
  314. this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
  315. }
  316. render () {
  317. const { announcements, intl } = this.props;
  318. const { index } = this.state;
  319. if (announcements.isEmpty()) {
  320. return null;
  321. }
  322. return (
  323. <div className='announcements'>
  324. <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
  325. <div className='announcements__container'>
  326. <ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}>
  327. {announcements.map((announcement, idx) => (
  328. <Announcement
  329. key={announcement.get('id')}
  330. announcement={announcement}
  331. emojiMap={this.props.emojiMap}
  332. addReaction={this.props.addReaction}
  333. removeReaction={this.props.removeReaction}
  334. intl={intl}
  335. selected={index === idx}
  336. />
  337. ))}
  338. </ReactSwipeableViews>
  339. {announcements.size > 1 && (
  340. <div className='announcements__pagination'>
  341. <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
  342. <span>{index + 1} / {announcements.size}</span>
  343. <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
  344. </div>
  345. )}
  346. </div>
  347. </div>
  348. );
  349. }
  350. }