about summary refs log tree commit diff
path: root/app/javascript/mastodon/features/getting_started/components/announcements.js
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2020-01-23 22:00:13 +0100
committerGitHub <noreply@github.com>2020-01-23 22:00:13 +0100
commitf52c988e12e464e7baefc2fdb48ddf4a95584664 (patch)
tree1849998c1180280751f829769c10c84986bf0953 /app/javascript/mastodon/features/getting_started/components/announcements.js
parent81cc86bb1ffb662843938379eeb522e3a6f11b79 (diff)
Add announcements (#12662)
* Add announcements

Fix #11006

* Add reactions to announcements

* Add admin UI for announcements

* Add unit tests

* Fix issues

- Add `with_dismissed` param to announcements API
- Fix end date not being formatted when time range is given
- Fix announcement delete causing reactions to send streaming updates
- Fix announcements container growing too wide and mascot too small
- Fix `all_day` being settable when no time range is given
- Change text "Update" to "Announcement"

* Fix scheduler unpublishing announcements before they are due

* Fix filter params not being passed to announcements filter
Diffstat (limited to 'app/javascript/mastodon/features/getting_started/components/announcements.js')
-rw-r--r--app/javascript/mastodon/features/getting_started/components/announcements.js395
1 files changed, 395 insertions, 0 deletions
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js
new file mode 100644
index 000000000..ee444e3f0
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/components/announcements.js
@@ -0,0 +1,395 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'mastodon/components/icon_button';
+import Icon from 'mastodon/components/icon';
+import { defineMessages, injectIntl, FormattedMessage, FormattedDate, FormattedNumber } from 'react-intl';
+import { autoPlayGif } from 'mastodon/initial_state';
+import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
+import { mascot } from 'mastodon/initial_state';
+import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
+import classNames from 'classnames';
+import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+class Content extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    announcement: ImmutablePropTypes.map.isRequired,
+  };
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  componentDidMount () {
+    this._updateLinks();
+    this._updateEmojis();
+  }
+
+  componentDidUpdate () {
+    this._updateLinks();
+    this._updateEmojis();
+  }
+
+  _updateEmojis () {
+    const node = this.node;
+
+    if (!node || autoPlayGif) {
+      return;
+    }
+
+    const emojis = node.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+
+      if (emoji.classList.contains('status-emoji')) {
+        continue;
+      }
+
+      emoji.classList.add('status-emoji');
+
+      emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+      emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+    }
+  }
+
+  _updateLinks () {
+    const node = this.node;
+
+    if (!node) {
+      return;
+    }
+
+    const links = node.querySelectorAll('a');
+
+    for (var i = 0; i < links.length; ++i) {
+      let link = links[i];
+
+      if (link.classList.contains('status-link')) {
+        continue;
+      }
+
+      link.classList.add('status-link');
+
+      let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
+
+      if (mention) {
+        link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+        link.setAttribute('title', mention.get('acct'));
+      } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+        link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+      } else {
+        link.setAttribute('title', link.href);
+        link.classList.add('unhandled-link');
+      }
+
+      link.setAttribute('target', '_blank');
+      link.setAttribute('rel', 'noopener noreferrer');
+    }
+  }
+
+  onMentionClick = (mention, e) => {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${mention.get('id')}`);
+    }
+  }
+
+  onHashtagClick = (hashtag, e) => {
+    hashtag = hashtag.replace(/^#/, '');
+
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/timelines/tag/${hashtag}`);
+    }
+  }
+
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
+  render () {
+    const { announcement } = this.props;
+
+    return (
+      <div
+        className='announcements__item__content'
+        ref={this.setRef}
+        dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
+      />
+    );
+  }
+
+}
+
+const assetHost = process.env.CDN_HOST || '';
+
+class Emoji extends React.PureComponent {
+
+  static propTypes = {
+    emoji: PropTypes.string.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    hovered: PropTypes.bool.isRequired,
+  };
+
+  render () {
+    const { emoji, emojiMap, hovered } = this.props;
+
+    if (unicodeMapping[emoji]) {
+      const { filename, shortCode } = unicodeMapping[this.props.emoji];
+      const title = shortCode ? `:${shortCode}:` : '';
+
+      return (
+        <img
+          draggable='false'
+          className='emojione'
+          alt={emoji}
+          title={title}
+          src={`${assetHost}/emoji/${filename}.svg`}
+        />
+      );
+    } else if (emojiMap.get(emoji)) {
+      const filename  = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+      const shortCode = `:${emoji}:`;
+
+      return (
+        <img
+          draggable='false'
+          className='emojione custom-emoji'
+          alt={shortCode}
+          title={shortCode}
+          src={filename}
+        />
+      );
+    } else {
+      return null;
+    }
+  }
+
+}
+
+class Reaction extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcementId: PropTypes.string.isRequired,
+    reaction: ImmutablePropTypes.map.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+  };
+
+  state = {
+    hovered: false,
+  };
+
+  handleClick = () => {
+    const { reaction, announcementId, addReaction, removeReaction } = this.props;
+
+    if (reaction.get('me')) {
+      removeReaction(announcementId, reaction.get('name'));
+    } else {
+      addReaction(announcementId, reaction.get('name'));
+    }
+  }
+
+  handleMouseEnter = () => this.setState({ hovered: true })
+
+  handleMouseLeave = () => this.setState({ hovered: false })
+
+  render () {
+    const { reaction } = this.props;
+
+    let shortCode = reaction.get('name');
+
+    if (unicodeMapping[shortCode]) {
+      shortCode = unicodeMapping[shortCode].shortCode;
+    }
+
+    return (
+      <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`}>
+        <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
+        <span className='reactions-bar__item__count'><FormattedNumber value={reaction.get('count')} /></span>
+      </button>
+    );
+  }
+
+}
+
+class ReactionsBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcementId: PropTypes.string.isRequired,
+    reactions: ImmutablePropTypes.list.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleEmojiPick = data => {
+    const { addReaction, announcementId } = this.props;
+    addReaction(announcementId, data.native.replace(/:/g, ''));
+  }
+
+  render () {
+    const { reactions } = this.props;
+    const visibleReactions = reactions.filter(x => x.get('count') > 0);
+
+    return (
+      <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
+        {visibleReactions.map(reaction => (
+          <Reaction
+            key={reaction.get('name')}
+            reaction={reaction}
+            announcementId={this.props.announcementId}
+            addReaction={this.props.addReaction}
+            removeReaction={this.props.removeReaction}
+            emojiMap={this.props.emojiMap}
+          />
+        ))}
+
+        <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />
+      </div>
+    );
+  }
+
+}
+
+class Announcement extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcement: ImmutablePropTypes.map.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    dismissAnnouncement: PropTypes.func.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleDismissClick = () => {
+    const { dismissAnnouncement, announcement } = this.props;
+    dismissAnnouncement(announcement.get('id'));
+  }
+
+  render () {
+    const { announcement, intl } = this.props;
+    const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
+    const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
+    const now = new Date();
+    const hasTimeRange = startsAt && endsAt;
+    const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
+    const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
+    const skipTime = announcement.get('all_day');
+
+    return (
+      <div className='announcements__item'>
+        <strong className='announcements__item__range'>
+          <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
+          {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>}
+        </strong>
+
+        <Content announcement={announcement} />
+
+        <ReactionsBar
+          reactions={announcement.get('reactions')}
+          announcementId={announcement.get('id')}
+          addReaction={this.props.addReaction}
+          removeReaction={this.props.removeReaction}
+          emojiMap={this.props.emojiMap}
+        />
+
+        <IconButton title={intl.formatMessage(messages.close)} icon='times' className='announcements__item__dismiss-icon' onClick={this.handleDismissClick} />
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class Announcements extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcements: ImmutablePropTypes.list,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    fetchAnnouncements: PropTypes.func.isRequired,
+    dismissAnnouncement: PropTypes.func.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    index: 0,
+  };
+
+  componentDidMount () {
+    const { fetchAnnouncements } = this.props;
+    fetchAnnouncements();
+  }
+
+  handleChangeIndex = index => {
+    this.setState({ index: index % this.props.announcements.size });
+  }
+
+  handleNextClick = () => {
+    this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
+  }
+
+  handlePrevClick = () => {
+    this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
+  }
+
+  render () {
+    const { announcements, intl } = this.props;
+    const { index } = this.state;
+
+    if (announcements.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div className='announcements'>
+        <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
+
+        <div className='announcements__container'>
+          <ReactSwipeableViews index={index} onChangeIndex={this.handleChangeIndex}>
+            {announcements.map(announcement => (
+              <Announcement
+                key={announcement.get('id')}
+                announcement={announcement}
+                emojiMap={this.props.emojiMap}
+                dismissAnnouncement={this.props.dismissAnnouncement}
+                addReaction={this.props.addReaction}
+                removeReaction={this.props.removeReaction}
+                intl={intl}
+              />
+            ))}
+          </ReactSwipeableViews>
+
+          <div className='announcements__pagination'>
+            <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
+            <span>{index + 1} / {announcements.size}</span>
+            <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}