diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb
index 8852eab77..018ee9c6e 100644
--- a/app/chewy/statuses_index.rb
+++ b/app/chewy/statuses_index.rb
@@ -57,6 +57,11 @@ class StatusesIndex < Chewy::Index
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end
+ crutch :emoji_reactions do |collection|
+ data = ::EmojiReaction.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
+ data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
+ end
+
root date_detection: false do
field :id, type: 'long'
field :account_id, type: 'long'
diff --git a/app/controllers/api/v1/emoji_reactions_controller.rb b/app/controllers/api/v1/emoji_reactions_controller.rb
new file mode 100644
index 000000000..e93632c7f
--- /dev/null
+++ b/app/controllers/api/v1/emoji_reactions_controller.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+class Api::V1::EmojiReactionsController < Api::BaseController
+ before_action -> { doorkeeper_authorize! :read, :'read:favourites' }
+ before_action :require_user!
+ after_action :insert_pagination_headers
+
+ def index
+ @statuses = load_statuses
+ accountIds = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+ render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), account_relationships: AccountRelationshipsPresenter.new(accountIds, current_user&.account_id)
+ end
+
+ private
+
+ def load_statuses
+ cached_emoji_reactions
+ end
+
+ def cached_emoji_reactions
+ cache_collection(Status.where(id: results.pluck(:status_id)), Status)
+ end
+
+ def results
+ @_results ||= filtered_emoji_reactions.joins('INNER JOIN statuses ON statuses.deleted_at IS NULL AND statuses.id = emoji_reactions.status_id').to_a_paginated_by_id(
+ limit_param(DEFAULT_STATUSES_LIMIT),
+ params_slice(:max_id, :since_id, :min_id)
+ )
+ end
+
+ def filtered_emoji_reactions
+ account_emoji_reactions.tap do |emoji_reactions|
+ emoji_reactions.merge!(emojis_scope) if emojis_requested?
+ end
+ end
+
+ def account_emoji_reactions
+ current_account.emoji_reactions
+ end
+
+ def insert_pagination_headers
+ set_pagination_headers(next_path, prev_path)
+ end
+
+ def next_path
+ api_v1_emoji_reactions_url pagination_params(max_id: pagination_max_id) if records_continue?
+ end
+
+ def prev_path
+ api_v1_emoji_reactions_url pagination_params(min_id: pagination_since_id) unless results.empty?
+ end
+
+ def pagination_max_id
+ results.last.id
+ end
+
+ def pagination_since_id
+ results.first.id
+ end
+
+ def records_continue?
+ results.size == limit_param(DEFAULT_STATUSES_LIMIT)
+ end
+
+ def emojis_requested?
+ emoji_reactions_params[:emojis].present?
+ end
+
+ def emojis_scope
+ emoji_reactions = EmojiReaction.none
+
+ emoji_reactions_params[:emojis].each do |emoji|
+ shortcode, domain = emoji.split("@")
+ custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain)
+
+ emoji_reactions = emoji_reactions.or(EmojiReaction.where(name: shortcode, custom_emoji: custom_emoji))
+ end
+
+ emoji_reactions
+ end
+
+ def pagination_params(core_params)
+ params.slice(:limit).permit(:limit).merge(core_params)
+ end
+
+ def emoji_reactions_params
+ params.permit(emojis: [])
+ end
+end
diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb
index 47f2e6440..b8a5075fa 100644
--- a/app/controllers/api/v1/push/subscriptions_controller.rb
+++ b/app/controllers/api/v1/push/subscriptions_controller.rb
@@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def data_params
return {} if params[:data].blank?
- params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
+ params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status, :emoji_reaction])
end
end
diff --git a/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb
new file mode 100644
index 000000000..b484ce7b4
--- /dev/null
+++ b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::EmojiReactionedByAccountsController < Api::BaseController
+ include Authorization
+
+ before_action -> { authorize_if_got_token! :read, :'read:accounts' }
+ before_action :set_status
+ after_action :insert_pagination_headers
+
+ def index
+ @accounts = load_accounts
+ render json: @accounts, each_serializer: REST::AccountSerializer
+ end
+
+ private
+
+ def load_accounts
+ scope = default_accounts
+ scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
+ scope.merge(paginated_emoji_reactions).to_a
+ end
+
+ def default_accounts
+ Account
+ .without_suspended
+ .includes(:emoji_reactions, :account_stat)
+ .references(:emoji_reactions)
+ .where(emoji_reactions: { status_id: @status.id })
+ end
+
+ def paginated_emoji_reactions
+ EmojiReaction.paginate_by_max_id(
+ limit_param(DEFAULT_ACCOUNTS_LIMIT),
+ params[:max_id],
+ params[:since_id]
+ )
+ end
+
+ def insert_pagination_headers
+ set_pagination_headers(next_path, prev_path)
+ end
+
+ def next_path
+ if records_continue?
+ api_v1_status_emoji_reactioned_by_index_url pagination_params(max_id: pagination_max_id)
+ end
+ end
+
+ def prev_path
+ unless @accounts.empty?
+ api_v1_status_emoji_reactioned_by_index_url pagination_params(since_id: pagination_since_id)
+ end
+ end
+
+ def pagination_max_id
+ @accounts.last.emoji_reactions.last.id
+ end
+
+ def pagination_since_id
+ @accounts.first.emoji_reactions.first.id
+ end
+
+ def records_continue?
+ @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+ end
+
+ def set_status
+ @status = Status.include_expired(current_account).find(params[:status_id])
+ authorize @status, :show?
+ rescue Mastodon::NotPermittedError
+ not_found
+ end
+
+ def pagination_params(core_params)
+ params.slice(:limit).permit(:limit).merge(core_params)
+ end
+end
diff --git a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb
new file mode 100644
index 000000000..64fca6e40
--- /dev/null
+++ b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::EmojiReactionsController < Api::BaseController
+ include Authorization
+
+ before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
+ before_action :require_user!
+ before_action :set_status
+
+ def update
+ EmojiReactionService.new.call(current_account, @status, params[:id])
+ render json: @status, serializer: REST::StatusSerializer
+ end
+
+ def destroy
+ #UnEmojiReactionWorker.perform_async(current_account.id, @status.id)
+ UnEmojiReactionService.new.call(current_account, @status)
+
+ render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, emoji_reactions_map: { @status.id => false })
+ end
+
+ private
+
+ def set_status
+ @status = Status.find(params[:status_id])
+ authorize @status, :show?
+ rescue Mastodon::NotPermittedError
+ not_found
+ end
+end
diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb
index bed57fc54..089421c59 100644
--- a/app/controllers/api/web/push_subscriptions_controller.rb
+++ b/app/controllers/api/web/push_subscriptions_controller.rb
@@ -26,6 +26,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
mention: alerts_enabled,
poll: alerts_enabled,
status: alerts_enabled,
+ emoji_reaction: alerts_enabled,
},
}
@@ -61,6 +62,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
end
def data_params
- @data_params ||= params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
+ @data_params ||= params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status, :emoji_reaction])
end
end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 8d88091b2..f46e23584 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -67,7 +67,8 @@ class Settings::PreferencesController < Settings::BaseController
:setting_show_tab_bar_label,
:setting_show_target,
:setting_enable_limited_timeline,
- notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag),
+ :setting_enable_reaction,
+ notification_emails: %i(follow follow_request reblog favourite emoji_reaction mention digest report pending_account trending_tag),
interactions: %i(must_be_follower must_be_following must_be_following_dm)
)
end
diff --git a/app/javascript/mastodon/actions/emoji_reactions.js b/app/javascript/mastodon/actions/emoji_reactions.js
new file mode 100644
index 000000000..8ad6f586d
--- /dev/null
+++ b/app/javascript/mastodon/actions/emoji_reactions.js
@@ -0,0 +1,94 @@
+import { fetchRelationships } from './accounts';
+import api, { getLinks } from '../api';
+import { importFetchedStatuses } from './importer';
+import { uniq } from '../utils/uniq';
+
+export const EMOJI_REACTIONED_STATUSES_FETCH_REQUEST = 'EMOJI_REACTIONED_STATUSES_FETCH_REQUEST';
+export const EMOJI_REACTIONED_STATUSES_FETCH_SUCCESS = 'EMOJI_REACTIONED_STATUSES_FETCH_SUCCESS';
+export const EMOJI_REACTIONED_STATUSES_FETCH_FAIL = 'EMOJI_REACTIONED_STATUSES_FETCH_FAIL';
+
+export const EMOJI_REACTIONED_STATUSES_EXPAND_REQUEST = 'EMOJI_REACTIONED_STATUSES_EXPAND_REQUEST';
+export const EMOJI_REACTIONED_STATUSES_EXPAND_SUCCESS = 'EMOJI_REACTIONED_STATUSES_EXPAND_SUCCESS';
+export const EMOJI_REACTIONED_STATUSES_EXPAND_FAIL = 'EMOJI_REACTIONED_STATUSES_EXPAND_FAIL';
+
+export function fetchEmojiReactionedStatuses() {
+ return (dispatch, getState) => {
+ if (getState().getIn(['status_lists', 'emoji_reactions', 'isLoading'])) {
+ return;
+ }
+
+ dispatch(fetchEmojiReactionedStatusesRequest());
+
+ api(getState).get('/api/v1/emoji_reactions').then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(importFetchedStatuses(response.data));
+ dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id))));
+ dispatch(fetchEmojiReactionedStatusesSuccess(response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(fetchEmojiReactionedStatusesFail(error));
+ });
+ };
+};
+
+export function fetchEmojiReactionedStatusesRequest() {
+ return {
+ type: EMOJI_REACTIONED_STATUSES_FETCH_REQUEST,
+ };
+};
+
+export function fetchEmojiReactionedStatusesSuccess(statuses, next) {
+ return {
+ type: EMOJI_REACTIONED_STATUSES_FETCH_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function fetchEmojiReactionedStatusesFail(error) {
+ return {
+ type: EMOJI_REACTIONED_STATUSES_FETCH_FAIL,
+ error,
+ };
+};
+
+export function expandEmojiReactionedStatuses() {
+ return (dispatch, getState) => {
+ const url = getState().getIn(['status_lists', 'emoji_reactions', 'next'], null);
+
+ if (url === null || getState().getIn(['status_lists', 'emoji_reactions', 'isLoading'])) {
+ return;
+ }
+
+ dispatch(expandEmojiReactionedStatusesRequest());
+
+ api(getState).get(url).then(response => {
+ const next = getLinks(response).refs.find(link => link.rel === 'next');
+ dispatch(importFetchedStatuses(response.data));
+ dispatch(fetchRelationships(uniq(response.data.map(item => item.reblog ? item.reblog.account.id : item.account.id))));
+ dispatch(expandEmojiReactionedStatusesSuccess(response.data, next ? next.uri : null));
+ }).catch(error => {
+ dispatch(expandEmojiReactionedStatusesFail(error));
+ });
+ };
+};
+
+export function expandEmojiReactionedStatusesRequest() {
+ return {
+ type: EMOJI_REACTIONED_STATUSES_EXPAND_REQUEST,
+ };
+};
+
+export function expandEmojiReactionedStatusesSuccess(statuses, next) {
+ return {
+ type: EMOJI_REACTIONED_STATUSES_EXPAND_SUCCESS,
+ statuses,
+ next,
+ };
+};
+
+export function expandEmojiReactionedStatusesFail(error) {
+ return {
+ type: EMOJI_REACTIONED_STATUSES_EXPAND_FAIL,
+ error,
+ };
+};
diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js
index 483f714d6..9963e150a 100644
--- a/app/javascript/mastodon/actions/interactions.js
+++ b/app/javascript/mastodon/actions/interactions.js
@@ -25,6 +25,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
+export const EMOJI_REACTIONS_FETCH_REQUEST = 'EMOJI_REACTIONS_FETCH_REQUEST';
+export const EMOJI_REACTIONS_FETCH_SUCCESS = 'EMOJI_REACTIONS_FETCH_SUCCESS';
+export const EMOJI_REACTIONS_FETCH_FAIL = 'EMOJI_REACTIONS_FETCH_FAIL';
+
export const MENTIONS_FETCH_REQUEST = 'MENTIONS_FETCH_REQUEST';
export const MENTIONS_FETCH_SUCCESS = 'MENTIONS_FETCH_SUCCESS';
export const MENTIONS_FETCH_FAIL = 'MENTIONS_FETCH_FAIL';
@@ -45,6 +49,16 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
+export const EMOJI_REACTION_REQUEST = 'EMOJI_REACTION_REQUEST';
+export const EMOJI_REACTION_SUCCESS = 'EMOJI_REACTION_SUCCESS';
+export const EMOJI_REACTION_FAIL = 'EMOJI_REACTION_FAIL';
+
+export const UN_EMOJI_REACTION_REQUEST = 'UN_EMOJI_REACTION_REQUEST';
+export const UN_EMOJI_REACTION_SUCCESS = 'UN_EMOJI_REACTION_SUCCESS';
+export const UN_EMOJI_REACTION_FAIL = 'UN_EMOJI_REACTION_FAIL';
+
+export const EMOJI_REACTION_UPDATE = 'EMOJI_REACTION_UPDATE';
+
export function reblog(status, visibility) {
return function (dispatch, getState) {
dispatch(reblogRequest(status));
@@ -341,6 +355,41 @@ export function fetchFavouritesFail(id, error) {
};
};
+export function fetchEmojiReactions(id) {
+ return (dispatch, getState) => {
+ dispatch(fetchEmojiReactionsRequest(id));
+
+ api(getState).get(`/api/v1/statuses/${id}/emoji_reactioned_by`).then(response => {
+ dispatch(importFetchedAccounts(response.data));
+ dispatch(fetchEmojiReactionsSuccess(id, response.data));
+ }).catch(error => {
+ dispatch(fetchEmojiReactionsFail(id, error));
+ });
+ };
+};
+
+export function fetchEmojiReactionsRequest(id) {
+ return {
+ type: EMOJI_REACTIONS_FETCH_REQUEST,
+ id,
+ };
+};
+
+export function fetchEmojiReactionsSuccess(id, accounts) {
+ return {
+ type: EMOJI_REACTIONS_FETCH_SUCCESS,
+ id,
+ accounts,
+ };
+};
+
+export function fetchEmojiReactionsFail(id, error) {
+ return {
+ type: EMOJI_REACTIONS_FETCH_FAIL,
+ error,
+ };
+};
+
export function fetchMentions(id) {
return (dispatch, getState) => {
dispatch(fetchMentionsRequest(id));
@@ -451,3 +500,118 @@ export function unpinFail(status, error) {
skipLoading: true,
};
};
+
+export function addEmojiReaction(status, name, domain, url, static_url) {
+ return function (dispatch, getState) {
+ dispatch(emojiReactionRequest(status, name, domain, url, static_url));
+
+ api(getState).put(`/api/v1/statuses/${status.get('id')}/emoji_reactions/${name}${domain ? `@${domain}` : ''}`).then(function (response) {
+ dispatch(importFetchedStatus(response.data));
+ dispatch(emojiReactionSuccess(status, name, domain, url, static_url));
+ }).catch(function (error) {
+ dispatch(emojiReactionFail(status, name, domain, url, static_url, error));
+ });
+ };
+};
+
+export function emojiReactionRequest(status, name, domain, url, static_url) {
+ return {
+ type: EMOJI_REACTION_REQUEST,
+ status: status,
+ name: name,
+ domain: domain,
+ url: url,
+ static_url: static_url,
+ skipLoading: true,
+ };
+};
+
+export function emojiReactionSuccess(status, name, domain, url, static_url) {
+ return {
+ type: EMOJI_REACTION_SUCCESS,
+ status: status,
+ name: name,
+ domain: domain,
+ url: url,
+ static_url: static_url,
+ skipLoading: true,
+ };
+};
+
+export function emojiReactionFail(status, name, domain, url, static_url, error) {
+ return {
+ type: EMOJI_REACTION_FAIL,
+ status: status,
+ name: name,
+ domain: domain,
+ url: url,
+ static_url: static_url,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+const findMyEmojiReaction = (status) => {
+ return status.get('emoji_reactions').find(emoji_reaction => emoji_reaction.get('me') === true) ?? {};
+};
+
+export function removeEmojiReaction(status) {
+ return function (dispatch, getState) {
+ const {name, domain, url, static_url} = findMyEmojiReaction(status).toObject();
+
+ if (name) {
+ dispatch(unEmojiReactionRequest(status, name, domain, url, static_url));
+
+ api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_unreaction`).then(function (response) {
+ dispatch(importFetchedStatus(response.data));
+ dispatch(unEmojiReactionSuccess(status, name, domain, url, static_url));
+ }).catch(function (error) {
+ dispatch(unEmojiReactionFail(status, name, domain, url, static_url, error));
+ });
+ }
+ };
+};
+
+export function unEmojiReactionRequest(status, name, domain, url, static_url) {
+ return {
+ type: UN_EMOJI_REACTION_REQUEST,
+ status: status,
+ name: name,
+ domain: domain,
+ url: url,
+ static_url: static_url,
+ skipLoading: true,
+ };
+};
+
+export function unEmojiReactionSuccess(status, name, domain, url, static_url) {
+ return {
+ type: UN_EMOJI_REACTION_SUCCESS,
+ status: status,
+ name: name,
+ domain: domain,
+ url: url,
+ static_url: static_url,
+ skipLoading: true,
+ };
+};
+
+export function unEmojiReactionFail(status, name, domain, url, static_url, error) {
+ return {
+ type: UN_EMOJI_REACTION_FAIL,
+ status: status,
+ name: name,
+ domain: domain,
+ url: url,
+ static_url: static_url,
+ error: error,
+ skipLoading: true,
+ };
+};
+
+export const updateEmojiReaction = emoji_reaction => {
+ return {
+ type: EMOJI_REACTION_UPDATE,
+ emojiReaction: emoji_reaction,
+ };
+};
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 22f1e0cbb..a331525d8 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -13,7 +13,7 @@ import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html';
import { getFiltersRegex } from '../selectors';
-import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
+import { usePendingItems as preferPendingItems, enableReaction } from 'mastodon/initial_state';
import compareId from 'mastodon/compare_id';
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
import { requestNotificationPermission } from '../utils/notifications';
@@ -120,7 +120,9 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
const excludeTypesFromFilter = filter => {
- const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']);
+ const allTypes = enableReaction ?
+ ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', 'emoji_reaction']) :
+ ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']);
return allTypes.filterNot(item => item === filter).toJS();
};
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index 2c74ce7d0..66a465a4e 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -11,6 +11,7 @@ import {
import { getHomeVisibilities } from 'mastodon/selectors';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
+import { updateEmojiReaction } from './interactions';
import {
fetchAnnouncements,
updateAnnouncements,
@@ -88,6 +89,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
case 'filters_changed':
dispatch(fetchFilters());
break;
+ case 'emoji_reaction':
+ dispatch(updateEmojiReaction(JSON.parse(data.payload)));
+ break;
case 'announcement':
dispatch(updateAnnouncements(JSON.parse(data.payload)));
break;
diff --git a/app/javascript/mastodon/components/emoji.js b/app/javascript/mastodon/components/emoji.js
new file mode 100644
index 000000000..141a47195
--- /dev/null
+++ b/app/javascript/mastodon/components/emoji.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { autoPlayGif } from 'mastodon/initial_state';
+import { assetHost } from 'mastodon/utils/config';
+import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
+
+export default class Emoji extends React.PureComponent {
+
+ static propTypes = {
+ emoji: PropTypes.string.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ hovered: PropTypes.bool.isRequired,
+ url: PropTypes.string,
+ static_url: PropTypes.string,
+ };
+
+ render () {
+ const { emoji, emojiMap, hovered, url, static_url } = this.props;
+
+ if (unicodeMapping[emoji]) {
+ const { filename, shortCode } = unicodeMapping[emoji];
+ const title = shortCode ? `:${shortCode}:` : '';
+
+ return (
+
+ );
+ } else if (emojiMap.get(emoji)) {
+ const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+ const shortCode = `:${emoji}:`;
+
+ return (
+
+ );
+ } else if (url || static_url) {
+ const filename = (autoPlayGif || hovered) && url ? url : static_url;
+ const shortCode = `:${emoji}:`;
+
+ return (
+
+ );
+ } else {
+ return null;
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/emoji_reactions_bar.js b/app/javascript/mastodon/components/emoji_reactions_bar.js
new file mode 100644
index 000000000..7761f56ff
--- /dev/null
+++ b/app/javascript/mastodon/components/emoji_reactions_bar.js
@@ -0,0 +1,111 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+import Emoji from './emoji';
+import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
+import TransitionMotion from 'react-motion/lib/TransitionMotion';
+import AnimatedNumber from 'mastodon/components/animated_number';
+import { reduceMotion } from 'mastodon/initial_state';
+import spring from 'react-motion/lib/spring';
+
+class EmojiReaction extends ImmutablePureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ emojiReaction: ImmutablePropTypes.map.isRequired,
+ addEmojiReaction: PropTypes.func.isRequired,
+ removeEmojiReaction: PropTypes.func.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ style: PropTypes.object,
+ };
+
+ state = {
+ hovered: false,
+ };
+
+ handleClick = () => {
+ const { emojiReaction, status, addEmojiReaction, removeEmojiReaction } = this.props;
+
+ if (emojiReaction.get('me')) {
+ removeEmojiReaction(status);
+ } else {
+ addEmojiReaction(status, emojiReaction.get('name'), emojiReaction.get('domain', null), emojiReaction.get('url', null), emojiReaction.get('static_url', null));
+ }
+ }
+
+ handleMouseEnter = () => this.setState({ hovered: true })
+
+ handleMouseLeave = () => this.setState({ hovered: false })
+
+ render () {
+ const { emojiReaction, status } = this.props;
+
+ let shortCode = emojiReaction.get('name');
+
+ if (unicodeMapping[shortCode]) {
+ shortCode = unicodeMapping[shortCode].shortCode;
+ }
+
+ return (
+
+ );
+ }
+
+}
+
+export default class EmojiReactionsBar extends ImmutablePureComponent {
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ addEmojiReaction: PropTypes.func.isRequired,
+ removeEmojiReaction: PropTypes.func.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ };
+
+ willEnter () {
+ return { scale: reduceMotion ? 1 : 0 };
+ }
+
+ willLeave () {
+ return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
+ }
+
+ render () {
+ const { status } = this.props;
+ const emoji_reactions = status.get("emoji_reactions")
+ const visibleReactions = emoji_reactions.filter(x => x.get('count') > 0);
+
+ const styles = visibleReactions.map(emoji_reaction => ({
+ key: `${emoji_reaction.get('name')}@${emoji_reaction.get('domain', '')}`,
+ data: emoji_reaction,
+ style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
+ })).toArray();
+
+ return (
+
+ {items => (
+
+ {items.map(({ key, data, style }) => (
+
+ ))}
+ {visibleReactions.size < 8}
+
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/reaction_picker_dropdown.js b/app/javascript/mastodon/components/reaction_picker_dropdown.js
new file mode 100644
index 000000000..3682bbb6f
--- /dev/null
+++ b/app/javascript/mastodon/components/reaction_picker_dropdown.js
@@ -0,0 +1,395 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { EmojiPicker as EmojiPickerAsync } from '../features/ui/util/async-components';
+import Overlay from 'react-overlays/lib/Overlay';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import { buildCustomEmojis, categoriesFromEmojis } from '../features/emoji/emoji';
+import { assetHost } from 'mastodon/utils/config';
+import IconButton from './icon_button';
+
+const messages = defineMessages({
+ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+ emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+ emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
+ custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
+ recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
+ search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
+ people: { id: 'emoji_button.people', defaultMessage: 'People' },
+ nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
+ food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
+ activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
+ travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
+ objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
+ symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
+ flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
+});
+
+let EmojiPicker, Emoji; // load asynchronously
+
+const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+class ModifierPickerMenu extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ onSelect: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ handleClick = e => {
+ this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.active) {
+ this.attachListeners();
+ } else {
+ this.removeListeners();
+ }
+ }
+
+ componentWillUnmount () {
+ this.removeListeners();
+ }
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ attachListeners () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ removeListeners () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ render () {
+ const { active } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+ static propTypes = {
+ active: PropTypes.bool,
+ modifier: PropTypes.number,
+ onChange: PropTypes.func,
+ onClose: PropTypes.func,
+ onOpen: PropTypes.func,
+ };
+
+ handleClick = () => {
+ if (this.props.active) {
+ this.props.onClose();
+ } else {
+ this.props.onOpen();
+ }
+ }
+
+ handleSelect = modifier => {
+ this.props.onChange(modifier);
+ this.props.onClose();
+ }
+
+ render () {
+ const { active, modifier } = this.props;
+
+ return (
+
+
+
+
+ );
+ }
+
+}
+
+@injectIntl
+class EmojiPickerMenu extends React.PureComponent {
+
+ static propTypes = {
+ custom_emojis: ImmutablePropTypes.list,
+ frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+ loading: PropTypes.bool,
+ onClose: PropTypes.func.isRequired,
+ onPick: PropTypes.func.isRequired,
+ style: PropTypes.object,
+ placement: PropTypes.string,
+ arrowOffsetLeft: PropTypes.string,
+ arrowOffsetTop: PropTypes.string,
+ intl: PropTypes.object.isRequired,
+ skinTone: PropTypes.number.isRequired,
+ onSkinTone: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ style: {},
+ loading: true,
+ frequentlyUsedEmojis: [],
+ };
+
+ state = {
+ modifierOpen: false,
+ placement: null,
+ };
+
+ handleDocumentClick = e => {
+ if (this.node && !this.node.contains(e.target)) {
+ this.props.onClose();
+ }
+ }
+
+ componentDidMount () {
+ document.addEventListener('click', this.handleDocumentClick, false);
+ document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.handleDocumentClick, false);
+ document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+ }
+
+ setRef = c => {
+ this.node = c;
+ }
+
+ getI18n = () => {
+ const { intl } = this.props;
+
+ return {
+ search: intl.formatMessage(messages.emoji_search),
+ notfound: intl.formatMessage(messages.emoji_not_found),
+ categories: {
+ search: intl.formatMessage(messages.search_results),
+ recent: intl.formatMessage(messages.recent),
+ people: intl.formatMessage(messages.people),
+ nature: intl.formatMessage(messages.nature),
+ foods: intl.formatMessage(messages.food),
+ activity: intl.formatMessage(messages.activity),
+ places: intl.formatMessage(messages.travel),
+ objects: intl.formatMessage(messages.objects),
+ symbols: intl.formatMessage(messages.symbols),
+ flags: intl.formatMessage(messages.flags),
+ custom: intl.formatMessage(messages.custom),
+ },
+ };
+ }
+
+ handleClick = (emoji, event) => {
+ if (!emoji.native) {
+ emoji.native = emoji.colons;
+ }
+ if (!(event.ctrlKey || event.metaKey)) {
+ this.props.onClose();
+ }
+ this.props.onPick(emoji);
+ }
+
+ handleModifierOpen = () => {
+ this.setState({ modifierOpen: true });
+ }
+
+ handleModifierClose = () => {
+ this.setState({ modifierOpen: false });
+ }
+
+ handleModifierChange = modifier => {
+ this.props.onSkinTone(modifier);
+ }
+
+ render () {
+ const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
+
+ if (loading) {
+ return ;
+ }
+
+ const title = intl.formatMessage(messages.emoji);
+
+ const { modifierOpen } = this.state;
+
+ const categoriesSort = [
+ 'recent',
+ 'people',
+ 'nature',
+ 'foods',
+ 'activity',
+ 'places',
+ 'objects',
+ 'symbols',
+ 'flags',
+ ];
+
+ categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class ReactionPickerDropdown extends React.PureComponent {
+
+ static propTypes = {
+ custom_emojis: ImmutablePropTypes.list,
+ frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+ intl: PropTypes.object.isRequired,
+ onPickEmoji: PropTypes.func.isRequired,
+ onRemoveEmoji: PropTypes.func.isRequired,
+ onSkinTone: PropTypes.func.isRequired,
+ skinTone: PropTypes.number.isRequired,
+ button: PropTypes.node,
+ dropdownPlacement: PropTypes.string,
+ iconButtonClass: PropTypes.string,
+ disabled: PropTypes.bool,
+ active: PropTypes.bool,
+ pressed: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ iconButtonClass: 'status__action-bar-button',
+ disabled: false,
+ active: false,
+ pressed: false,
+ };
+
+ state = {
+ active: false,
+ loading: false,
+ };
+
+ setRef = (c) => {
+ this.dropdown = c;
+ }
+
+ onShowDropdown = ({ target }) => {
+ if (!this.props.disabled) {
+ this.setState({ active: true });
+
+ if (!EmojiPicker) {
+ this.setState({ loading: true });
+
+ EmojiPickerAsync().then(EmojiMart => {
+ EmojiPicker = EmojiMart.Picker;
+ Emoji = EmojiMart.Emoji;
+
+ this.setState({ loading: false });
+ }).catch(() => {
+ this.setState({ loading: false, active: false });
+ });
+ }
+
+ const { top } = target.getBoundingClientRect();
+ }
+ }
+
+ onHideDropdown = () => {
+ if (!this.props.disabled) {
+ this.setState({ active: false });
+ }
+ }
+
+ onToggle = (e) => {
+ if (!this.state.loading && (!e.key || e.key === 'Enter') && !this.props.disabled) {
+ if (this.props.active) {
+ this.props.onRemoveEmoji();
+ } else if (this.state.active) {
+ this.onHideDropdown();
+ } else {
+ this.onShowDropdown(e);
+ }
+ }
+ }
+
+ handleKeyDown = e => {
+ if (e.key === 'Escape' && !this.props.disabled) {
+ this.onHideDropdown();
+ }
+ }
+
+ setTargetRef = c => {
+ this.target = c;
+ }
+
+ findTarget = () => {
+ return this.target;
+ }
+
+ render () {
+ const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, iconButtonClass, dropdownPlacement, disabled, active, pressed } = this.props;
+ const title = intl.formatMessage(messages.emoji);
+ const { active: show, loading } = this.state;
+
+ return (
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index b3ee98d20..2fd430c29 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -17,10 +17,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames';
-import { Link } from 'react-router-dom';
import Icon from 'mastodon/components/icon';
-import { displayMedia, me } from '../initial_state';
+import EmojiReactionsBar from 'mastodon/components/emoji_reactions_bar';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
+import { displayMedia, enableReaction } from 'mastodon/initial_state';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
@@ -144,6 +144,9 @@ class Status extends ImmutablePureComponent {
available: PropTypes.bool,
}),
contextType: PropTypes.string,
+ emojiMap: ImmutablePropTypes.map,
+ addEmojiReaction: PropTypes.func.isRequired,
+ removeEmojiReaction: PropTypes.func.isRequired,
};
// Avoid checking props that are functions (and whose equality will always
@@ -709,6 +712,12 @@ class Status extends ImmutablePureComponent {
{quote}
{media}
+ {enableReaction && }
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 5302c246c..f00b46568 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -6,9 +6,11 @@ import IconButton from './icon_button';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me, isStaff, show_bookmark_button, show_quote_button } from '../initial_state';
+import { me, isStaff, show_bookmark_button, show_quote_button, enableReaction } from '../initial_state';
import classNames from 'classnames';
+import ReactionPickerDropdown from '../containers/reaction_picker_dropdown_container';
+
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
@@ -85,6 +87,8 @@ class StatusActionBar extends ImmutablePureComponent {
withDismiss: PropTypes.bool,
scrollKey: PropTypes.string,
intl: PropTypes.object.isRequired,
+ addEmojiReaction: PropTypes.func,
+ removeEmojiReaction: PropTypes.func,
};
static defaultProps = {
@@ -246,6 +250,16 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
+ handleEmojiPick = data => {
+ const { addEmojiReaction, status } = this.props;
+ addEmojiReaction(status, data.native.replace(/:/g, ''), null, null, null);
+ }
+
+ handleEmojiRemove = () => {
+ const { removeEmojiReaction, status } = this.props;
+ removeEmojiReaction(status);
+ }
+
render () {
const { status, relationship, intl, withDismiss, scrollKey, expired } = this.props;
@@ -364,6 +378,7 @@ class StatusActionBar extends ImmutablePureComponent {
{show_quote_button && }
+ {enableReaction && }
{shareButton}
{show_bookmark_button && }
diff --git a/app/javascript/mastodon/containers/reaction_picker_dropdown_container.js b/app/javascript/mastodon/containers/reaction_picker_dropdown_container.js
new file mode 100644
index 000000000..f8581fbf0
--- /dev/null
+++ b/app/javascript/mastodon/containers/reaction_picker_dropdown_container.js
@@ -0,0 +1,84 @@
+import { connect } from 'react-redux';
+import ReactionPickerDropdown from '../components/reaction_picker_dropdown';
+import { changeSetting } from '../actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from '../actions/emojis';
+
+const perLine = 8;
+const lines = 2;
+
+const DEFAULTS = [
+ '+1',
+ 'grinning',
+ 'kissing_heart',
+ 'heart_eyes',
+ 'laughing',
+ 'stuck_out_tongue_winking_eye',
+ 'sweat_smile',
+ 'joy',
+ 'yum',
+ 'disappointed',
+ 'thinking_face',
+ 'weary',
+ 'sob',
+ 'sunglasses',
+ 'heart',
+ 'ok_hand',
+];
+
+const getFrequentlyUsedEmojis = createSelector([
+ state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
+], emojiCounters => {
+ let emojis = emojiCounters
+ .keySeq()
+ .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
+ .reverse()
+ .slice(0, perLine * lines)
+ .toArray();
+
+ if (emojis.length < DEFAULTS.length) {
+ let uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
+ emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
+ }
+
+ return emojis;
+});
+
+const getCustomEmojis = createSelector([
+ state => state.get('custom_emojis'),
+], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
+ const aShort = a.get('shortcode').toLowerCase();
+ const bShort = b.get('shortcode').toLowerCase();
+
+ if (aShort < bShort) {
+ return -1;
+ } else if (aShort > bShort ) {
+ return 1;
+ } else {
+ return 0;
+ }
+}));
+
+const mapStateToProps = state => ({
+ custom_emojis: getCustomEmojis(state),
+ skinTone: state.getIn(['settings', 'skinTone']),
+ frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
+ dropdownPlacement: 'bottom',
+});
+
+const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+ onSkinTone: skinTone => {
+ dispatch(changeSetting(['skinTone'], skinTone));
+ },
+
+ onPickEmoji: emoji => {
+ dispatch(useEmoji(emoji));
+
+ if (onPickEmoji) {
+ onPickEmoji(emoji);
+ }
+ },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ReactionPickerDropdown);
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 9a4eb4fa9..0ad824d77 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -17,6 +17,8 @@ import {
unbookmark,
pin,
unpin,
+ addEmojiReaction,
+ removeEmojiReaction,
} from '../actions/interactions';
import {
muteStatus,
@@ -51,6 +53,9 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, deleteModal, unfollowModal, unsubscribeModal } from '../initial_state';
import { showAlertForError } from '../actions/alerts';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
@@ -68,10 +73,12 @@ const messages = defineMessages({
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
+ const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
const mapStateToProps = (state, props) => ({
status: getStatus(state, props),
pictureInPicture: getPictureInPicture(state, props),
+ emojiMap: customEmojiMap(state),
});
return mapStateToProps;
@@ -293,6 +300,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
},
+ addEmojiReaction (status, name, domain, url, static_url) {
+ dispatch(addEmojiReaction(status, name, domain, url, static_url));
+ },
+
+ removeEmojiReaction (status) {
+ dispatch(removeEmojiReaction(status));
+ },
+
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/features/emoji_reactioned_statuses/index.js b/app/javascript/mastodon/features/emoji_reactioned_statuses/index.js
new file mode 100644
index 000000000..98baf7e15
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji_reactioned_statuses/index.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchEmojiReactionedStatuses, expandEmojiReactionedStatuses } from '../../actions/emoji_reactions';
+import Column from '../ui/components/column';
+import ColumnHeader from '../../components/column_header';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import StatusList from '../../components/status_list';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { debounce } from 'lodash';
+
+const messages = defineMessages({
+ heading: { id: 'column.emoji_reactions', defaultMessage: 'EmojiReactions' },
+});
+
+const mapStateToProps = state => ({
+ statusIds: state.getIn(['status_lists', 'emoji_reactions', 'items']),
+ isLoading: state.getIn(['status_lists', 'emoji_reactions', 'isLoading'], true),
+ hasMore: !!state.getIn(['status_lists', 'emoji_reactions', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class EmojiReactions extends ImmutablePureComponent {
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ intl: PropTypes.object.isRequired,
+ columnId: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ this.props.dispatch(fetchEmojiReactionedStatuses());
+ }
+
+ handlePin = () => {
+ const { columnId, dispatch } = this.props;
+
+ if (columnId) {
+ dispatch(removeColumn(columnId));
+ } else {
+ dispatch(addColumn('EMOJI_REACTIONS', {}));
+ }
+ }
+
+ handleMove = (dir) => {
+ const { columnId, dispatch } = this.props;
+ dispatch(moveColumn(columnId, dir));
+ }
+
+ handleHeaderClick = () => {
+ this.column.scrollTop();
+ }
+
+ setRef = c => {
+ this.column = c;
+ }
+
+ handleLoadMore = debounce(() => {
+ this.props.dispatch(expandEmojiReactionedStatuses());
+ }, 300, { leading: true })
+
+ render () {
+ const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
+ const pinned = !!columnId;
+
+ const emptyMessage = ;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/emoji_reactions/index.js b/app/javascript/mastodon/features/emoji_reactions/index.js
new file mode 100644
index 000000000..3e778836f
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji_reactions/index.js
@@ -0,0 +1,87 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchEmojiReactions } from '../../actions/interactions';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import ScrollableList from '../../components/scrollable_list';
+import Icon from 'mastodon/components/icon';
+import ColumnHeader from '../../components/column_header';
+
+const messages = defineMessages({
+ refresh: { id: 'refresh', defaultMessage: 'Refresh' },
+});
+
+const mapStateToProps = (state, props) => ({
+ accountIds: state.getIn(['user_lists', 'emoji_reactioned_by', props.params.statusId]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class EmojiReactions extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ accountIds: ImmutablePropTypes.list,
+ multiColumn: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ componentWillMount () {
+ if (!this.props.accountIds) {
+ this.props.dispatch(fetchEmojiReactions(this.props.params.statusId));
+ }
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+ this.props.dispatch(fetchEmojiReactions(nextProps.params.statusId));
+ }
+ }
+
+ handleRefresh = () => {
+ this.props.dispatch(fetchEmojiReactions(this.props.params.statusId));
+ }
+
+ render () {
+ const { intl, accountIds, multiColumn } = this.props;
+
+ if (!accountIds) {
+ return (
+
+
+
+ );
+ }
+
+ const emptyMessage = ;
+
+ return (
+
+
+ )}
+ />
+
+
+ {accountIds.map(id =>
+ ,
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
index 9606a144c..73631946a 100644
--- a/app/javascript/mastodon/features/favourited_statuses/index.js
+++ b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -70,7 +70,7 @@ class Favourites extends ImmutablePureComponent {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
- const emptyMessage = ;
+ const emptyMessage = ;
return (
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
index ac94ae18a..f060068a4 100644
--- a/app/javascript/mastodon/features/favourites/index.js
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -59,7 +59,7 @@ class Favourites extends ImmutablePureComponent {
);
}
- const emptyMessage = ;
+ const emptyMessage = ;
return (
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 4a345ffc8..5667933d8 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -31,6 +31,7 @@ const messages = defineMessages({
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+ emoji_reactions: { id: 'navigation_bar.emoji_reactions', defaultMessage: 'Emoji reactions' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@@ -214,6 +215,7 @@ class GettingStarted extends ImmutablePureComponent {
,
,
,
+ ,
,
,
);
diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.js b/app/javascript/mastodon/features/keyboard_shortcuts/index.js
index 03b599dc0..deb0fd92e 100644
--- a/app/javascript/mastodon/features/keyboard_shortcuts/index.js
+++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.js
@@ -128,6 +128,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
g+f |
|
+
+ g+e |
+ |
+
g+p |
|
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 0c24c3294..0f306ee9f 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -152,6 +152,17 @@ export default class ColumnSettings extends React.PureComponent {
+
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
);
}
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.js b/app/javascript/mastodon/features/notifications/components/filter_bar.js
index 368eb0b7e..6be1d76dd 100644
--- a/app/javascript/mastodon/features/notifications/components/filter_bar.js
+++ b/app/javascript/mastodon/features/notifications/components/filter_bar.js
@@ -10,6 +10,7 @@ const tooltips = defineMessages({
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
+ reactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Reactions' },
});
export default @injectIntl
@@ -95,6 +96,13 @@ class FilterBar extends React.PureComponent {
>
+