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 ( + {emoji} + ); + } else if (emojiMap.get(emoji)) { + const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']); + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } else if (url || static_url) { + const filename = (autoPlayGif || hovered) && url ? url : static_url; + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } 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 { > +