Add emoji reaction

This commit is contained in:
noellabo 2021-05-19 14:58:27 +09:00
parent 28675f7d34
commit ae60e0a7d7
90 changed files with 2275 additions and 69 deletions

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
};
};

View file

@ -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,
};
};

View file

@ -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();
};

View file

@ -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;

View file

@ -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 (
<img
draggable='false'
className='emojione'
alt={emoji}
title={title}
src={`${assetHost}/emoji/${filename}.svg`}
/>
);
} else if (emojiMap.get(emoji)) {
const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
const shortCode = `:${emoji}:`;
return (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
} else if (url || static_url) {
const filename = (autoPlayGif || hovered) && url ? url : static_url;
const shortCode = `:${emoji}:`;
return (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
} else {
return null;
}
}
}

View file

@ -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 (
<button className={classNames('reactions-bar__item', { active: emojiReaction.get('me') })} disabled={status.get('emoji_reactioned') && !emojiReaction.get('me')} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={emojiReaction.get('name')} emojiMap={this.props.emojiMap} url={emojiReaction.get('url')} static_url={emojiReaction.get('static_url')} /></span>
<span className='reactions-bar__item__count'><AnimatedNumber value={emojiReaction.get('count')} /></span>
</button>
);
}
}
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 (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<EmojiReaction
key={key}
emojiReaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
status={this.props.status}
addEmojiReaction={this.props.addEmojiReaction}
removeEmojiReaction={this.props.removeEmojiReaction}
emojiMap={this.props.emojiMap}
/>
))}
{visibleReactions.size < 8}
</div>
)}
</TransitionMotion>
);
}
}

View file

@ -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 (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
</div>
);
}
}
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 (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div>
);
}
}
@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 <div style={{ width: 299 }} />;
}
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 (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker
perLine={6}
emojiSize={32}
sheetSize={32}
custom={buildCustomEmojis(custom_emojis)}
color=''
emoji=''
set='twitter'
title={title}
i18n={this.getI18n()}
onClick={this.handleClick}
include={categoriesSort}
skin={skinTone}
showPreview={false}
backgroundImageFn={backgroundImageFn}
autoFocus={false}
emojiTooltip
/>
<ModifierPicker
active={modifierOpen}
modifier={skinTone}
onOpen={this.handleModifierOpen}
onClose={this.handleModifierClose}
onChange={this.handleModifierChange}
/>
</div>
);
}
}
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 (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
<IconButton disabled={disabled} active={active} pressed={pressed} className={iconButtonClass} ref={this.setTargetRef} title={title} icon='smile-o' onClick={this.onToggle} />
<Overlay show={show} placement={dropdownPlacement} target={this.findTarget}>
<EmojiPickerMenu
custom_emojis={this.props.custom_emojis}
loading={loading}
onClose={this.onHideDropdown}
onPick={onPickEmoji}
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/>
</Overlay>
</div>
);
}
}

View file

@ -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 && <EmojiReactionsBar
status={status}
addEmojiReaction={this.props.addEmojiReaction}
removeEmojiReaction={this.props.removeEmojiReaction}
emojiMap={this.props.emojiMap}
/>}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} expired={expired} {...other} />
</div>
</div>

View file

@ -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 {
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate || expired} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate disabled={!me && expired} active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{show_quote_button && <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />}
{enableReaction && <ReactionPickerDropdown disabled={expired} active={status.get('emoji_reactioned')} pressed={status.get('emoji_reactioned')} className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} onRemoveEmoji={this.handleEmojiRemove} />}
{shareButton}
{show_bookmark_button && <IconButton className='status__action-bar-button bookmark-icon' disabled={!me && expired} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />}

View file

@ -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);

View file

@ -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));

View file

@ -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 = <FormattedMessage id='empty_column.emoji_reactioned_statuses' defaultMessage="You don't have any reaction posts yet. When you reaction one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='star'
title={intl.formatMessage(messages.heading)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
showBackButton
/>
<StatusList
trackScroll={!pinned}
statusIds={statusIds}
scrollKey={`emoji_reactioned_statuses-${columnId}`}
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
</Column>
);
}
}

View file

@ -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 (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.emoji_reactions' defaultMessage='No one has reactioned this post yet. When someone does, they will show up here.' />;
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
showBackButton
multiColumn={multiColumn}
extraButton={(
<button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
)}
/>
<ScrollableList
scrollKey='emoji_reactions'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} withNote={false} />,
)}
</ScrollableList>
</Column>
);
}
}

View file

@ -70,7 +70,7 @@ class Favourites extends ImmutablePureComponent {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />;
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite posts yet. When you favourite one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>

View file

@ -59,7 +59,7 @@ class Favourites extends ImmutablePureComponent {
);
}
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favourited this toot yet. When someone does, they will show up here.' />;
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favourited this post yet. When someone does, they will show up here.' />;
return (
<Column bindToDocument={!multiColumn}>

View file

@ -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 {
<ColumnLink key='direct' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />,
<ColumnLink key='bookmark' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
<ColumnLink key='favourites' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='emoji_reactions' icon='smile-o' text={intl.formatMessage(messages.emoji_reactions)} to='/emoji_reactions' />,
<ColumnLink key='lists' icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />,
<ColumnLink key='circles' icon='user-circle' text={intl.formatMessage(messages.circles)} to='/circles' />,
);

View file

@ -128,6 +128,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
<td><kbd>g</kbd>+<kbd>f</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open favourites list' /></td>
</tr>
<tr>
<td><kbd>g</kbd>+<kbd>e</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.emoji_reaction' defaultMessage='to open emoji reactions list' /></td>
</tr>
<tr>
<td><kbd>g</kbd>+<kbd>p</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.pinned' defaultMessage='to open pinned toots list' /></td>

View file

@ -152,6 +152,17 @@ export default class ColumnSettings extends React.PureComponent {
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
</div>
</div>
<div role='group' aria-labelledby='notifications-reaction'>
<span id='notifications-reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.emoji_reaction' defaultMessage='Reactions:' /></span>
<div className='column-settings__row'>
<SettingToggle disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'emoji_reaction']} onChange={onChange} label={alertStr} />
{showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'emoji_reaction']} onChange={this.onPushChange} label={pushStr} />}
<SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'emoji_reaction']} onChange={onChange} label={showStr} />
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'emoji_reaction']} onChange={onChange} label={soundStr} />
</div>
</div>
</div>
);
}

View file

@ -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 {
>
<Icon id='home' fixedWidth />
</button>
<button
className={selectedFilter === 'emoji_reaction' ? 'active' : ''}
onClick={this.onClick('emoji_reaction')}
title={intl.formatMessage(tooltips.reactions)}
>
<Icon id='smile-o' fixedWidth />
</button>
<button
className={selectedFilter === 'follow' ? 'active' : ''}
onClick={this.onClick('follow')}

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { Fragment } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import { HotKeys } from 'react-hotkeys';
@ -8,17 +8,19 @@ import { me } from 'mastodon/initial_state';
import StatusContainer from 'mastodon/containers/status_container';
import AccountContainer from 'mastodon/containers/account_container';
import FollowRequestContainer from '../containers/follow_request_container';
import Emoji from 'mastodon/components/emoji';
import Icon from 'mastodon/components/icon';
import Permalink from 'mastodon/components/permalink';
import classNames from 'classnames';
const messages = defineMessages({
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your post' },
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
emoji_reaction: { id: 'notification.emoji_reaction', defaultMessage: '{name} reactioned your post' },
});
const notificationForScreenReader = (intl, message, timestamp) => {
@ -52,6 +54,7 @@ class Notification extends ImmutablePureComponent {
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
unread: PropTypes.bool,
emojiMap: ImmutablePropTypes.map,
};
handleMoveUp = () => {
@ -189,7 +192,7 @@ class Notification extends ImmutablePureComponent {
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
<FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your post' values={{ name: link }} />
</span>
</div>
@ -221,7 +224,7 @@ class Notification extends ImmutablePureComponent {
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
<FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your post' values={{ name: link }} />
</span>
</div>
@ -311,6 +314,42 @@ class Notification extends ImmutablePureComponent {
);
}
renderReaction (notification, link) {
const { intl, unread, emojiMap } = this.props;
if (!notification.get('emoji_reaction')) {
return <Fragment></Fragment>
}
return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-reaction focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.emoji_reaction, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Emoji hovered={false} emoji={notification.getIn(['emoji_reaction', 'name'])} emojiMap={emojiMap} url={notification.getIn(['emoji_reaction', 'url'])} static_url={notification.getIn(['emoji_reaction', 'static_url'])} />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.emoji_reaction' defaultMessage='{name} reactioned your post' values={{ name: link }} />
</span>
</div>
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={!!this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div>
</HotKeys>
);
}
render () {
const { notification } = this.props;
const account = notification.get('account');
@ -332,9 +371,11 @@ class Notification extends ImmutablePureComponent {
return this.renderStatus(notification, link);
case 'poll':
return this.renderPoll(notification, account);
case 'emoji_reaction':
return this.renderReaction(notification, link);
}
return null;
}
};
}

View file

@ -14,16 +14,20 @@ import {
revealStatus,
} from '../../../actions/statuses';
import { boostModal } from '../../../initial_state';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
const makeMapStateToProps = () => {
const getNotification = makeGetNotification();
const getStatus = makeGetStatus();
const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
const mapStateToProps = (state, props) => {
const notification = getNotification(state, props.notification, props.accountId);
return {
notification: notification,
status: notification.get('status') ? getStatus(state, { id: notification.get('status') }) : null,
emojiMap: customEmojiMap(state),
};
};

View file

@ -5,8 +5,9 @@ import IconButton from '../../../components/icon_button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import { me, isStaff, show_quote_button } from '../../../initial_state';
import { me, isStaff, show_quote_button, enableReaction } from '../../../initial_state';
import classNames from 'classnames';
import ReactionPickerDropdown from 'mastodon/containers/reaction_picker_dropdown_container';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -78,6 +79,8 @@ class ActionBar extends React.PureComponent {
onPin: PropTypes.func,
onEmbed: PropTypes.func,
intl: PropTypes.object.isRequired,
addEmojiReaction: PropTypes.func.isRequired,
removeEmojiReaction: PropTypes.func.isRequired,
};
handleReplyClick = () => {
@ -205,6 +208,16 @@ class ActionBar extends React.PureComponent {
}
}
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 } = this.props;
@ -312,6 +325,7 @@ class ActionBar extends React.PureComponent {
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate || expired} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{show_quote_button && <div className='detailed-status__button'><IconButton disabled={!publicStatus || expired} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>}
{enableReaction && <div className='detailed-status__button'><ReactionPickerDropdown disabled={expired} active={status.get('emoji_reactioned')} pressed={status.get('emoji_reactioned')} iconButtonClass='detailed-status__action-bar-button' onPickEmoji={this.handleEmojiPick} onRemoveEmoji={this.handleEmojiRemove} /></div>}
{shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>

View file

@ -16,7 +16,9 @@ import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number';
import EmojiReactionsBar from 'mastodon/components/emoji_reactions_bar';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import { enableReaction } from 'mastodon/initial_state';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@ -88,6 +90,9 @@ class DetailedStatus extends ImmutablePureComponent {
onQuoteToggleHidden: PropTypes.func.isRequired,
showQuoteMedia: PropTypes.bool,
onToggleQuoteMediaVisibility: PropTypes.func,
emojiMap: ImmutablePropTypes.map,
addEmojiReaction: PropTypes.func.isRequired,
removeEmojiReaction: PropTypes.func.isRequired,
};
state = {
@ -183,6 +188,11 @@ class DetailedStatus extends ImmutablePureComponent {
let reblogLink = '';
let reblogIcon = 'retweet';
let favouriteLink = '';
let emojiReactionLink = '';
const reblogsCount = status.get('reblogs_count');
const favouritesCount = status.get('favourites_count');
const emojiReactionsCount = status.get('emoji_reactions').reduce( (accumulator, reaction) => accumulator + reaction.get('count'), 0 );
if (this.props.measureHeight) {
outerStyle.height = `${this.state.height}px`;
@ -356,7 +366,7 @@ class DetailedStatus extends ImmutablePureComponent {
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
<Icon id={reblogIcon} />
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
<AnimatedNumber value={reblogsCount} />
</span>
</Link>
</Fragment>
@ -368,7 +378,7 @@ class DetailedStatus extends ImmutablePureComponent {
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id={reblogIcon} />
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
<AnimatedNumber value={reblogsCount} />
</span>
</a>
</Fragment>
@ -380,7 +390,7 @@ class DetailedStatus extends ImmutablePureComponent {
<Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
<Icon id='star' />
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} />
<AnimatedNumber value={favouritesCount} />
</span>
</Link>
);
@ -389,7 +399,27 @@ class DetailedStatus extends ImmutablePureComponent {
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id='star' />
<span className='detailed-status__favorites'>
<AnimatedNumber value={status.get('favourites_count')} />
<AnimatedNumber value={favouritesCount} />
</span>
</a>
);
}
if (this.context.router) {
emojiReactionLink = (
<Link to={`/statuses/${status.get('id')}/emoji_reactions`} className='detailed-status__link'>
<Icon id='smile-o' />
<span className='detailed-status__emoji_reactions'>
<AnimatedNumber value={emojiReactionsCount} />
</span>
</Link>
);
} else {
emojiReactionLink = (
<a href={`/interact/${status.get('id')}?type=emoji_reactions`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id='smile-o' />
<span className='detailed-status__emoji_reactions'>
<AnimatedNumber value={emojiReactionsCount} />
</span>
</a>
);
@ -412,6 +442,13 @@ class DetailedStatus extends ImmutablePureComponent {
{quote}
{media}
{enableReaction && <EmojiReactionsBar
status={status}
addEmojiReaction={this.props.addEmojiReaction}
removeEmojiReaction={this.props.removeEmojiReaction}
emojiMap={this.props.emojiMap}
/>}
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
@ -423,7 +460,7 @@ class DetailedStatus extends ImmutablePureComponent {
</time>
</span>
}
{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionLink}
</div>
</div>
</div>

View file

@ -13,6 +13,8 @@ import {
unfavourite,
pin,
unpin,
addEmojiReaction,
removeEmojiReaction,
} from '../../../actions/interactions';
import {
muteStatus,
@ -32,6 +34,9 @@ import { defineMessages, injectIntl } from 'react-intl';
import { boostModal, deleteModal } 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?' },
@ -44,11 +49,13 @@ 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),
domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, props),
emojiMap: customEmojiMap(state),
});
return mapStateToProps;
@ -182,6 +189,15 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(hideQuote(status.get('id')));
}
},
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)(DetailedStatus));

View file

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Map as ImmutableMap } from 'immutable';
import { createSelector } from 'reselect';
import { fetchStatus } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator';
@ -19,6 +20,8 @@ import {
unreblog,
pin,
unpin,
addEmojiReaction,
removeEmojiReaction,
} from '../../actions/interactions';
import {
replyCompose,
@ -79,6 +82,7 @@ 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 getAncestorsIds = createSelector([
(_, { id }) => id,
@ -152,6 +156,7 @@ const makeMapStateToProps = () => {
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']),
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
emojiMap: customEmojiMap(state),
};
};
@ -180,6 +185,7 @@ class Status extends ImmutablePureComponent {
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
emojiMap: ImmutablePropTypes.map,
};
state = {
@ -451,6 +457,14 @@ class Status extends ImmutablePureComponent {
this.handleToggleMediaVisibility();
}
handleAddEmojiReaction = (status, name, domain, url, static_url) => {
this.props.dispatch(addEmojiReaction(status, name, domain, url, static_url));
}
handleRemoveEmojiReaction = (status) => {
this.props.dispatch(removeEmojiReaction(status));
}
handleMoveUp = id => {
const { status, ancestorsIds, descendantsIds } = this.props;
@ -542,7 +556,7 @@ class Status extends ImmutablePureComponent {
render () {
let ancestors, descendants;
const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
const { status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture, emojiMap } = this.props;
const { fullscreen } = this.state;
if (status === null) {
@ -606,6 +620,9 @@ class Status extends ImmutablePureComponent {
onQuoteToggleHidden={this.handleQuoteToggleHidden}
showQuoteMedia={this.state.showQuoteMedia}
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
emojiMap={emojiMap}
addEmojiReaction={this.handleAddEmojiReaction}
removeEmojiReaction={this.handleRemoveEmojiReaction}
/>
<ActionBar
@ -630,6 +647,8 @@ class Status extends ImmutablePureComponent {
onReport={this.handleReport}
onPin={this.handlePin}
onEmbed={this.handleEmbed}
addEmojiReaction={this.handleAddEmojiReaction}
removeEmojiReaction={this.handleRemoveEmojiReaction}
/>
</div>
</HotKeys>

View file

@ -22,6 +22,7 @@ const NavigationPanel = () => (
<NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/emoji_reactions'><Icon className='column-link__icon' id='smile-o' fixedWidth /><FormattedMessage id='navigation_bar.emoji_reactions' defaultMessage='Emoji reactions' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/circles'><Icon className='column-link__icon' id='user-circle' fixedWidth /><FormattedMessage id='navigation_bar.circles' defaultMessage='Circles' /></NavLink>
<NavLink className='column-link column-link--transparent' to='/group_directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.group_directory' defaultMessage='Group directory' /></NavLink>

View file

@ -39,6 +39,7 @@ import {
Subscribing,
Reblogs,
Favourites,
EmojiReactions,
Mentions,
DirectTimeline,
LimitedTimeline,
@ -48,6 +49,7 @@ import {
GenericNotFound,
FavouritedStatuses,
BookmarkedStatuses,
EmojiReactionedStatuses,
ListTimeline,
Blocks,
DomainBlocks,
@ -106,6 +108,7 @@ const keyMap = {
goToDirect: 'g d',
goToStart: 'g s',
goToFavourites: 'g f',
goToEmojiReactions: 'g e',
goToPinned: 'g p',
goToProfile: 'g u',
goToBlocked: 'g b',
@ -173,6 +176,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/emoji_reactions' component={EmojiReactionedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/start' component={FollowRecommendations} content={children} />
@ -186,6 +190,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/statuses/:statusId/emoji_reactions' component={EmojiReactions} content={children} />
<WrappedRoute path='/statuses/:statusId/mentions' component={Mentions} content={children} />
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
@ -494,6 +499,10 @@ class UI extends React.PureComponent {
this.context.router.history.push('/favourites');
}
handleHotkeyGoToEmojiReactions = () => {
this.context.router.history.push('/emoji_reactions');
}
handleHotkeyGoToPinned = () => {
this.context.router.history.push('/pinned');
}
@ -532,6 +541,7 @@ class UI extends React.PureComponent {
goToDirect: this.handleHotkeyGoToDirect,
goToStart: this.handleHotkeyGoToStart,
goToFavourites: this.handleHotkeyGoToFavourites,
goToEmojiReactions: this.handleHotkeyGoToEmojiReactions,
goToPinned: this.handleHotkeyGoToPinned,
goToProfile: this.handleHotkeyGoToProfile,
goToBlocked: this.handleHotkeyGoToBlocked,

View file

@ -90,6 +90,10 @@ export function Favourites () {
return import(/* webpackChunkName: "features/favourites" */'../../favourites');
}
export function EmojiReactions () {
return import(/* webpackChunkName: "features/emoji_reactions" */'../../emoji_reactions');
}
export function Mentions () {
return import(/* webpackChunkName: "features/mentions" */'../../mentions');
}
@ -110,6 +114,10 @@ export function BookmarkedStatuses () {
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
}
export function EmojiReactionedStatuses () {
return import(/* webpackChunkName: "features/emoji_reactioned_statuses" */'../../emoji_reactioned_statuses');
}
export function Blocks () {
return import(/* webpackChunkName: "features/blocks" */'../../blocks');
}

View file

@ -39,5 +39,6 @@ export const show_target = getMeta('show_target');
export const place_tab_bar_at_bottom = getMeta('place_tab_bar_at_bottom');
export const show_tab_bar_label = getMeta('show_tab_bar_label');
export const enable_limited_timeline = getMeta('enable_limited_timeline');
export const enableReaction = getMeta('enable_reaction');
export default initialState;

View file

@ -90,6 +90,7 @@
"column.directory": "Browse profiles",
"column.group_directory": "Browse groups",
"column.domain_blocks": "Blocked domains",
"column.emoji_reactions": "EmojiReactions",
"column.favourites": "Favourites",
"column.follow_requests": "Follow requests",
"column.group": "Group timeline",
@ -194,6 +195,8 @@
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
"empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
"empty_column.domain_blocks": "There are no blocked domains yet.",
"empty_column.emoji_reactioned_statuses": "You don't have any reaction posts yet. When you reaction one, it will show up here.",
"empty_column.emoji_reactions": "No one has reactioned this post yet. When someone does, they will show up here.",
"empty_column.favourited_statuses": "You don't have any favourite posts yet. When you favourite one, it will show up here.",
"empty_column.favourites": "No one has favourited this post yet. When someone does, they will show up here.",
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
@ -268,6 +271,7 @@
"keyboard_shortcuts.description": "Description",
"keyboard_shortcuts.direct": "Open direct messages column",
"keyboard_shortcuts.down": "Move down in the list",
"keyboard_shortcuts.emoji_reaction": "Open emoji reactions list",
"keyboard_shortcuts.enter": "Open post",
"keyboard_shortcuts.favourite": "Favourite post",
"keyboard_shortcuts.favourites": "Open favourites list",
@ -337,6 +341,7 @@
"navigation_bar.discover": "Discover",
"navigation_bar.domain_blocks": "Blocked domains",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.emoji_reactions": "Emoji reactions",
"navigation_bar.favourites": "Favourites",
"navigation_bar.filters": "Muted words",
"navigation_bar.follow_requests": "Follow requests",
@ -373,6 +378,7 @@
"notification.mention": "{name} mentioned you",
"notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you have voted in has ended",
"notification.emoji_reaction": "{name} reactioned your post",
"notification.reblog": "{name} boosted your post",
"notification.status": "{name} just posted",
"notifications.clear": "Clear notifications",
@ -387,6 +393,7 @@
"notifications.column_settings.mention": "Mentions:",
"notifications.column_settings.poll": "Poll results:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.emoji_reaction": "Reactions:",
"notifications.column_settings.reblog": "Boosts:",
"notifications.column_settings.show": "Show in column",
"notifications.column_settings.sound": "Play sound",
@ -398,6 +405,7 @@
"notifications.filter.follows": "Follows",
"notifications.filter.mentions": "Mentions",
"notifications.filter.polls": "Poll results",
"notifications.filter.emoji_reactions": "Reactions",
"notifications.filter.statuses": "Updates from people you follow",
"notifications.grant_permission": "Grant permission.",
"notifications.group": "{count} notifications",

View file

@ -89,6 +89,7 @@
"column.direct": "ダイレクトメッセージ",
"column.directory": "ディレクトリ",
"column.domain_blocks": "ブロックしたドメイン",
"column.emoji_reactions": "絵文字リアクション",
"column.favourites": "お気に入り",
"column.follow_requests": "フォローリクエスト",
"column.group": "グループタイムライン",
@ -194,6 +195,8 @@
"empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
"empty_column.direct": "ダイレクトメッセージはまだありません。ダイレクトメッセージをやりとりすると、ここに表示されます。",
"empty_column.domain_blocks": "ブロックしているドメインはありません。",
"empty_column.emoji_reactioned_statuses": "まだ何も絵文字リアクションしていません。絵文字リアクションするとここに表示されます。",
"empty_column.emoji_reactions": "まだ誰も絵文字リアクションしていません。絵文字リアクションされるとここに表示されます。",
"empty_column.favourited_statuses": "まだ何もお気に入り登録していません。お気に入り登録するとここに表示されます。",
"empty_column.favourites": "まだ誰もお気に入り登録していません。お気に入り登録されるとここに表示されます。",
"empty_column.follow_recommendations": "おすすめを生成できませんでした。検索を使って知り合いを探したり、トレンドハッシュタグを見てみましょう。",
@ -269,6 +272,7 @@
"keyboard_shortcuts.description": "説明",
"keyboard_shortcuts.direct": "ダイレクトメッセージのカラムを開く",
"keyboard_shortcuts.down": "カラム内一つ下に移動",
"keyboard_shortcuts.emoji_reaction": "絵文字リアクションのリストを開く",
"keyboard_shortcuts.enter": "投稿の詳細を表示",
"keyboard_shortcuts.favourite": "お気に入り",
"keyboard_shortcuts.favourites": "お気に入り登録のリストを開く",
@ -338,6 +342,7 @@
"navigation_bar.discover": "見つける",
"navigation_bar.domain_blocks": "ブロックしたドメイン",
"navigation_bar.edit_profile": "プロフィールを編集",
"navigation_bar.emoji_reactions": "絵文字リアクション",
"navigation_bar.favourites": "お気に入り",
"navigation_bar.filters": "フィルター設定",
"navigation_bar.follow_requests": "フォローリクエスト",
@ -374,6 +379,7 @@
"notification.mention": "{name}さんがあなたに返信しました",
"notification.own_poll": "アンケートが終了しました",
"notification.poll": "アンケートが終了しました",
"notification.emoji_reaction": "{name}さんがあなたの投稿にリアクションしました",
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
"notification.status": "{name}さんが投稿しました",
"notifications.clear": "通知を消去",
@ -388,6 +394,7 @@
"notifications.column_settings.mention": "返信:",
"notifications.column_settings.poll": "アンケート結果:",
"notifications.column_settings.push": "プッシュ通知",
"notifications.column_settings.emoji_reaction": "リアクション:",
"notifications.column_settings.reblog": "ブースト:",
"notifications.column_settings.show": "カラムに表示",
"notifications.column_settings.sound": "通知音を再生",
@ -399,6 +406,7 @@
"notifications.filter.follows": "フォロー",
"notifications.filter.mentions": "返信",
"notifications.filter.polls": "アンケート結果",
"notifications.filter.emoji_reactions": "リアクション",
"notifications.filter.statuses": "フォローしている人の新着情報",
"notifications.grant_permission": "権限の付与",
"notifications.group": "{count} 件の通知",

View file

@ -52,6 +52,8 @@ const notificationToMap = notification => ImmutableMap({
account: notification.account.id,
created_at: notification.created_at,
status: notification.status ? notification.status.id : null,
reaction: ImmutableMap(notification.reaction),
reblogVisibility: notification.reblog_visibility,
});
const normalizeNotification = (state, notification, usePendingItems) => {

View file

@ -53,6 +53,7 @@ const initialState = ImmutableMap({
mention: false,
poll: false,
status: false,
emoji_reaction: false,
}),
quickFilter: ImmutableMap({
@ -72,6 +73,7 @@ const initialState = ImmutableMap({
mention: true,
poll: true,
status: true,
emoji_reaction: true,
}),
sounds: ImmutableMap({
@ -82,6 +84,7 @@ const initialState = ImmutableMap({
mention: true,
poll: true,
status: true,
emoji_reaction: true,
}),
}),

View file

@ -14,6 +14,14 @@ import {
BOOKMARKED_STATUSES_EXPAND_SUCCESS,
BOOKMARKED_STATUSES_EXPAND_FAIL,
} from '../actions/bookmarks';
import {
EMOJI_REACTIONED_STATUSES_FETCH_REQUEST,
EMOJI_REACTIONED_STATUSES_FETCH_SUCCESS,
EMOJI_REACTIONED_STATUSES_FETCH_FAIL,
EMOJI_REACTIONED_STATUSES_EXPAND_REQUEST,
EMOJI_REACTIONED_STATUSES_EXPAND_SUCCESS,
EMOJI_REACTIONED_STATUSES_EXPAND_FAIL,
} from '../actions/emoji_reactions';
import {
PINNED_STATUSES_FETCH_SUCCESS,
} from '../actions/pin_statuses';
@ -23,6 +31,8 @@ import {
UNFAVOURITE_SUCCESS,
BOOKMARK_SUCCESS,
UNBOOKMARK_SUCCESS,
EMOJI_REACTION_SUCCESS,
UN_EMOJI_REACTION_SUCCESS,
PIN_SUCCESS,
UNPIN_SUCCESS,
} from '../actions/interactions';
@ -38,6 +48,11 @@ const initialState = ImmutableMap({
loaded: false,
items: ImmutableList(),
}),
emoji_reactions: ImmutableMap({
next: null,
loaded: false,
items: ImmutableList(),
}),
pins: ImmutableMap({
next: null,
loaded: false,
@ -96,6 +111,16 @@ export default function statusLists(state = initialState, action) {
return normalizeList(state, 'bookmarks', action.statuses, action.next);
case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
return appendToList(state, 'bookmarks', action.statuses, action.next);
case EMOJI_REACTIONED_STATUSES_FETCH_REQUEST:
case EMOJI_REACTIONED_STATUSES_EXPAND_REQUEST:
return state.setIn(['emoji_reactions', 'isLoading'], true);
case EMOJI_REACTIONED_STATUSES_FETCH_FAIL:
case EMOJI_REACTIONED_STATUSES_EXPAND_FAIL:
return state.setIn(['emoji_reactions', 'isLoading'], false);
case EMOJI_REACTIONED_STATUSES_FETCH_SUCCESS:
return normalizeList(state, 'emoji_reactions', action.statuses, action.next);
case EMOJI_REACTIONED_STATUSES_EXPAND_SUCCESS:
return appendToList(state, 'emoji_reactions', action.statuses, action.next);
case FAVOURITE_SUCCESS:
return prependOneToList(state, 'favourites', action.status);
case UNFAVOURITE_SUCCESS:
@ -104,6 +129,10 @@ export default function statusLists(state = initialState, action) {
return prependOneToList(state, 'bookmarks', action.status);
case UNBOOKMARK_SUCCESS:
return removeOneFromList(state, 'bookmarks', action.status);
case EMOJI_REACTION_SUCCESS:
return prependOneToList(state, 'emoji_reactions', action.status);
case UN_EMOJI_REACTION_SUCCESS:
return removeOneFromList(state, 'emoji_reactions', action.status);
case PINNED_STATUSES_FETCH_SUCCESS:
return normalizeList(state, 'pins', action.statuses, action.next);
case PIN_SUCCESS:

View file

@ -6,6 +6,11 @@ import {
UNFAVOURITE_SUCCESS,
BOOKMARK_REQUEST,
BOOKMARK_FAIL,
EMOJI_REACTION_REQUEST,
EMOJI_REACTION_FAIL,
UN_EMOJI_REACTION_REQUEST,
UN_EMOJI_REACTION_FAIL,
EMOJI_REACTION_UPDATE,
} from '../actions/interactions';
import {
STATUS_MUTE_SUCCESS,
@ -37,6 +42,24 @@ const deleteStatus = (state, id, references, quotes) => {
return state.delete(id);
};
const updateEmojiReaction = (state, id, name, domain, url, static_url, updater) => state.update(id, status => {
return status.update('emoji_reactions', emojiReactions => {
const idx = emojiReactions.findIndex(emojiReaction => !domain && !emojiReaction.get('domain') && emojiReaction.get('name') === name || emojiReaction.get('name') === name && emojiReaction.get('domain', null) === domain);
if (idx > -1) {
return emojiReactions.update(idx, emojiReactions => updater(emojiReactions));
}
return emojiReactions.push(updater(fromJS({ name, domain, url, static_url, count: 0 })));
});
});
const updateEmojiReactionCount = (state, emojiReaction) => updateEmojiReaction(state, emojiReaction.status_id, emojiReaction.name, emojiReaction.domain, emojiReaction.url, emojiReaction.static_url, x => x.set('count', emojiReaction.count));
const addEmojiReaction = (state, id, name, domain, url, static_url) => updateEmojiReaction(state, id, name, domain, url, static_url, x => x.set('me', true).update('count', y => y + 1));
const removeEmojiReaction = (state, id, name, domain, url, static_url) => updateEmojiReaction(state, id, name, domain, url, static_url, x => x.set('me', false).update('count', y => y - 1));
const initialState = ImmutableMap();
export default function statuses(state = initialState, action) {
@ -55,6 +78,22 @@ export default function statuses(state = initialState, action) {
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true);
case BOOKMARK_FAIL:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
case EMOJI_REACTION_UPDATE:
return state.get(action.emojiReaction.status_id) === undefined ? state : updateEmojiReactionCount(state, action.emojiReaction);
case EMOJI_REACTION_REQUEST:
case UN_EMOJI_REACTION_FAIL:
if (state.get(action.status.get('id')) !== undefined) {
state = state.setIn([action.status.get('id'), 'emoji_reactioned'], true);
state = addEmojiReaction(state, action.status.get('id'), action.name, action.domain, action.url, action.static_url);
}
return state;
case UN_EMOJI_REACTION_REQUEST:
case EMOJI_REACTION_FAIL:
if (state.get(action.status.get('id')) !== undefined) {
state = state.setIn([action.status.get('id'), 'emoji_reactioned'], false);
state = removeEmojiReaction(state, action.status.get('id'), action.name, action.domain, action.url, action.static_url);
}
return state;
case REBLOG_REQUEST:
return state.setIn([action.status.get('id'), 'reblogged'], true);
case REBLOG_FAIL:

View file

@ -32,6 +32,7 @@ import {
import {
REBLOGS_FETCH_SUCCESS,
FAVOURITES_FETCH_SUCCESS,
EMOJI_REACTIONS_FETCH_SUCCESS,
MENTIONS_FETCH_SUCCESS,
} from '../actions/interactions';
import {
@ -80,6 +81,7 @@ const initialState = ImmutableMap({
subscribing: initialListState,
reblogged_by: initialListState,
favourited_by: initialListState,
emoji_reactioned_by: initialListState,
mentioned_by: initialListState,
follow_requests: initialListState,
blocks: initialListState,
@ -142,6 +144,8 @@ export default function userLists(state = initialState, action) {
return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
case FAVOURITES_FETCH_SUCCESS:
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
case EMOJI_REACTIONS_FETCH_SUCCESS:
return state.setIn(['emoji_reactioned_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
case MENTIONS_FETCH_SUCCESS:
return state.setIn(['mentioned_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
case NOTIFICATIONS_UPDATE:

View file

@ -369,14 +369,15 @@ html {
}
.reactions-bar__item {
&:hover,
&:focus,
&:active {
&:hover:enabled,
&:focus:enabled,
&:active:enabled {
background-color: $ui-base-color;
}
}
.reactions-bar__item.active {
.reactions-bar__item.active:enabled,
.reactions-bar__item.active:disabled {
background-color: mix($white, $ui-highlight-color, 80%);
border-color: mix(lighten($ui-base-color, 8%), $ui-highlight-color, 80%);
}

View file

@ -1284,19 +1284,26 @@
align-items: center;
display: flex;
margin-top: 8px;
.emoji-picker-dropdown {
flex: 1 0 auto;
display: flex;
}
}
.status__action-bar-button {
margin-right: 18px;
flex: 1 0 auto;
&.icon-button--with-counter {
margin-right: 14px;
.icon-button__counter {
width: auto;
}
}
.status__action-bar-dropdown {
flex: 0 0 auto;
height: 23.15px;
width: 23.15px;
margin-left: 4px
}
.detailed-status__action-bar-dropdown {
@ -1367,6 +1374,7 @@
}
.detailed-status__favorites,
.detailed-status__emoji_reactions,
.detailed-status__reblogs {
display: inline-block;
font-weight: 500;
@ -2754,7 +2762,7 @@ a.account__display-name {
.column-actions {
display: flex;
align-items: start;
align-items: flex-start;
justify-content: center;
padding: 40px;
padding-top: 40px;
@ -7695,9 +7703,9 @@ noscript {
color: $darker-text-color;
}
&:hover,
&:focus,
&:active {
&:hover:enabled,
&:focus:enabled,
&:active:enabled {
background: lighten($ui-base-color, 16%);
transition: all 200ms ease-out;
transition-property: background-color, color;
@ -7707,8 +7715,9 @@ noscript {
}
}
&.active {
transition: all 100ms ease-in;
&:enabled.active,
&:disabled.active {
transition: all 100ms ease-in;
transition-property: background-color, color;
background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%);

View file

@ -264,6 +264,7 @@ body.rtl {
}
.detailed-status__favorites,
.detailed-status__emoji_reactions,
.detailed-status__reblogs {
margin-left: 0;
margin-right: 6px;

View file

@ -56,6 +56,8 @@ class ActivityPub::Activity
ActivityPub::Activity::Remove
when 'Move'
ActivityPub::Activity::Move
when 'EmojiReact'
ActivityPub::Activity::EmojiReact
end
end
end

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
class ActivityPub::Activity::EmojiReact < ActivityPub::Activity
def perform
original_status = status_from_uri(object_uri)
shortcode = @json['content']
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.reacted?(original_status, shortcode)
reaction = original_status.emoji_reactions.create!(account: @account, name: shortcode, uri: @json['id'])
NotifyService.new.call(original_status.account, :emoji_reaction, reaction) if original_status.account.local?
end
end

View file

@ -2,11 +2,62 @@
class ActivityPub::Activity::Like < ActivityPub::Activity
def perform
original_status = status_from_uri(object_uri)
@original_status = status_from_uri(object_uri)
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
return if @original_status.nil? || delete_arrived_first?(@json['id'])
favourite = original_status.favourites.create!(account: @account)
NotifyService.new.call(original_status.account, :favourite, favourite)
lock_or_fail("like:#{object_uri}") do
if shortcode.nil?
process_favourite
else
process_reaction
end
end
end
private
def process_favourite
return if @account.favourited?(@original_status)
favourite = @original_status.favourites.create!(account: @account)
NotifyService.new.call(@original_status.account, :favourite, favourite) if @original_status.account.local?
end
def process_reaction
if emoji_tag.present?
return if emoji_tag['id'].blank? || emoji_tag['name'].blank? || emoji_tag['icon'].blank? || emoji_tag['icon']['url'].blank?
image_url = emoji_tag['icon']['url']
uri = emoji_tag['id']
domain = URI.split(uri)[2]
emoji = CustomEmoji.find_or_create_by!(shortcode: shortcode, domain: domain) do |emoji|
emoji.uri = uri
emoji.image_remote_url = image_url
end
end
return if @account.reacted?(@original_status, shortcode, emoji)
EmojiReaction.find_by(account: @account, status: @original_status)&.destroy!
reaction = @original_status.emoji_reactions.create!(account: @account, name: shortcode, custom_emoji: emoji, uri: @json['id'])
NotifyService.new.call(@original_status.account, :emoji_reaction, reaction) if @original_status.account.local?
rescue Seahorse::Client::NetworkingError
nil
end
def shortcode
return @shortcode if defined?(@shortcode)
@shortcode = @json['_misskey_reaction']&.delete(':')
end
def emoji_tag
return @emoji_tag if defined?(@emoji_tag)
@emoji_tag = @json['tag'].is_a?(Array) ? @json['tag']&.first : @json['tag']
end
end

View file

@ -1,3 +1,4 @@
# frozen_string_literal: true
class ActivityPub::Activity::Undo < ActivityPub::Activity
@ -13,6 +14,8 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
undo_like
when 'Block'
undo_block
when 'EmojiReact'
undo_react
when nil
handle_reference
end
@ -25,7 +28,7 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
# global index, we have to guess what object it is.
return if object_uri.nil?
try_undo_announce || try_undo_accept || try_undo_follow || try_undo_like || try_undo_block || delete_later!(object_uri)
try_undo_announce || try_undo_accept || try_undo_follow || try_undo_like || try_undo_react || try_undo_block || delete_later!(object_uri)
end
def try_undo_announce
@ -59,6 +62,10 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
false
end
def try_undo_react
@account.emoji_reactions.find_by(uri: object_uri)&.destroy
end
def try_undo_block
block = @account.block_relationships.find_by(uri: object_uri)
if block.present?
@ -105,14 +112,33 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
return if status.nil? || !status.account.local?
if @account.favourited?(status)
favourite = status.favourites.where(account: @account).first
favourite&.destroy
shortcode = @object['_misskey_reaction']&.delete(':')
if shortcode.present?
emoji_tag = @object['tag'].is_a?(Array) ? @object['tag']&.first : @object['tag']
if emoji_tag.present? && emoji_tag['id'].present?
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
end
if @account.reacted?(status, shortcode, emoji)
status.emoji_reactions.where(account: @account, name: shortcode, custom_emoji: emoji).first&.destroy
else
delete_later!(object_uri)
end
else
delete_later!(object_uri)
if @account.favourited?(status)
status.favourites.where(account: @account).first&.destroy
else
delete_later!(object_uri)
end
end
end
def undo_react
@account.emoji_reactions.find_by(uri: object_uri)&.destroy
end
def undo_block
target_account = account_from_uri(target_uri)

View file

@ -19,6 +19,8 @@ class InlineRenderer
serializer = REST::AnnouncementSerializer
when :reaction
serializer = REST::ReactionSerializer
when :emoji_reaction
serializer = REST::EmojiReactionSerializer
when :encrypted_message
serializer = REST::EncryptedMessageSerializer
else

View file

@ -6,6 +6,7 @@ class PotentialFriendshipTracker
WEIGHTS = {
reply: 1,
emoji_reaction: 1,
favourite: 10,
reblog: 20,
}.freeze

View file

@ -51,6 +51,7 @@ class UserSettingsDecorator
user.settings['place_tab_bar_at_bottom'] = place_tab_bar_at_bottom_preference if change?('setting_place_tab_bar_at_bottom')
user.settings['show_tab_bar_label'] = show_tab_bar_label_preference if change?('setting_show_tab_bar_label')
user.settings['enable_limited_timeline'] = enable_limited_timeline_preference if change?('setting_enable_limited_timeline')
user.settings['enable_reaction'] = enable_reaction_preference if change?('setting_enable_reaction')
end
def merged_notification_emails
@ -197,6 +198,10 @@ class UserSettingsDecorator
boolean_cast_setting 'setting_enable_limited_timeline'
end
def enable_reaction_preference
boolean_cast_setting 'setting_enable_reaction'
end
def boolean_cast_setting(key)
ActiveModel::Type::Boolean.new.cast(settings[key])
end

View file

@ -66,6 +66,20 @@ class NotificationMailer < ApplicationMailer
end
end
def emoji_reaction(recipient, notification)
@me = recipient
@account = notification.from_account
@status = notification.target_status
@emoji_reaction = notification.emoji_reaction
return unless @me.user.functional? && @status.present?
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
mail to: @me.user.email, subject: I18n.t('notification_mailer.emoji_reaction.subject', name: @account.acct)
end
end
def digest(recipient, **opts)
return unless recipient.user.functional?

View file

@ -15,6 +15,7 @@ module AccountAssociations
has_many :statuses, inverse_of: :account, dependent: :destroy
has_many :favourites, inverse_of: :account, dependent: :destroy
has_many :bookmarks, inverse_of: :account, dependent: :destroy
has_many :emoji_reactions, inverse_of: :account, dependent: :destroy
has_many :mentions, inverse_of: :account, dependent: :destroy
has_many :notifications, inverse_of: :account, dependent: :destroy
has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account

View file

@ -275,6 +275,14 @@ module AccountInteractions
status.proper.favourites.where(account: self).exists?
end
def reacted?(status, name, custom_emoji = nil)
status.proper.emoji_reactions.where(account: self, name: name, custom_emoji: custom_emoji).exists?
end
def emoji_reactioned?(status)
status.proper.emoji_reactions.where(account: self).exists?
end
def bookmarked?(status)
status.proper.bookmarks.where(account: self).exists?
end

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: emoji_reactions
#
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# status_id :bigint(8) not null
# name :string default(""), not null
# custom_emoji_id :bigint(8)
# created_at :datetime not null
# updated_at :datetime not null
# uri :string
#
class EmojiReaction < ApplicationRecord
include Paginable
after_commit :queue_publish
belongs_to :account
belongs_to :status, inverse_of: :emoji_reactions
belongs_to :custom_emoji, optional: true
has_one :notification, as: :activity, dependent: :destroy
validates :name, presence: true
validates_with EmojiReactionValidator
before_validation do
self.status = status.reblog if status&.reblog?
end
private
def queue_publish
PublishEmojiReactionWorker.perform_async(status_id, name, custom_emoji_id) unless status.destroyed?
end
end

View file

@ -25,6 +25,7 @@ class Notification < ApplicationRecord
'FollowRequest' => :follow_request,
'Favourite' => :favourite,
'Poll' => :poll,
'EmojiReaction' => :emoji_reaction,
}.freeze
TYPES = %i(
@ -35,6 +36,7 @@ class Notification < ApplicationRecord
follow_request
favourite
poll
emoji_reaction
).freeze
TARGET_STATUS_INCLUDES_BY_TYPE = {
@ -43,6 +45,7 @@ class Notification < ApplicationRecord
mention: [mention: :status],
favourite: [favourite: :status],
poll: [poll: :status],
emoji_reaction: [emoji_reaction: :status],
}.freeze
belongs_to :account, optional: true
@ -55,6 +58,7 @@ class Notification < ApplicationRecord
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
belongs_to :favourite, foreign_key: 'activity_id', optional: true
belongs_to :poll, foreign_key: 'activity_id', optional: true
belongs_to :emoji_reaction, foreign_key: 'activity_id', optional: true
validates :type, inclusion: { in: TYPES }
validates :activity_id, uniqueness: { scope: [:account_id, :type] }, if: -> { type.to_sym == :status }
@ -87,9 +91,15 @@ class Notification < ApplicationRecord
mention&.status
when :poll
poll&.status
when :emoji_reaction
emoji_reaction&.status
end
end
def reblog_visibility
type == :reblog ? status.visibility : :public
end
class << self
def preload_cache_collection_target_statuses(notifications, &_block)
notifications.group_by(&:type).each do |type, grouped_notifications|
@ -121,6 +131,8 @@ class Notification < ApplicationRecord
notification.mention.status = cached_status
when :poll
notification.poll.status = cached_status
when :emoji_reaction
notification.emoji_reaction.status = cached_status
end
end
@ -137,7 +149,7 @@ class Notification < ApplicationRecord
return unless new_record?
case activity_type
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll'
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'EmojiReaction'
self.from_account_id = activity&.account_id
when 'Mention'
self.from_account_id = activity&.status&.account_id

View file

@ -69,6 +69,7 @@ class Status < ApplicationRecord
has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :bookmarks, inverse_of: :status, dependent: :destroy
has_many :emoji_reactions, inverse_of: :status, dependent: :destroy
has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
has_many :mentions, dependent: :destroy, inverse_of: :status
@ -124,6 +125,12 @@ class Status < ApplicationRecord
WHERE f.status_id = statuses.id
AND f.account_id = :account_id
)
AND NOT EXISTS (
SELECT *
FROM emoji_reactions r
WHERE r.status_id = statuses.id
AND r.account_id = :account_id
)
AND statuses.expires_at IS NOT NULL
AND statuses.expires_at < :current_utc
)
@ -190,11 +197,13 @@ class Status < ApplicationRecord
ids += favourites.where(account: Account.local).pluck(:account_id)
ids += reblogs.where(account: Account.local).pluck(:account_id)
ids += bookmarks.where(account: Account.local).pluck(:account_id)
ids += emoji_reactions.where(account: Account.local).pluck(:account_id)
else
ids += preloaded.mentions[id] || []
ids += preloaded.favourites[id] || []
ids += preloaded.reblogs[id] || []
ids += preloaded.bookmarks[id] || []
ids += preloaded.emoji_reactions[id] || []
end
ids.uniq
@ -311,6 +320,19 @@ class Status < ApplicationRecord
status_stat&.favourites_count || 0
end
def grouped_reactions(account = nil)
records = begin
scope = emoji_reactions.group(:status_id, :name, :custom_emoji_id).order(Arel.sql('MIN(created_at) ASC'))
if account.nil?
scope.select('name, custom_emoji_id, count(*) as count, false as me')
else
scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from emoji_reactions r where r.account_id = #{account.id} and r.status_id = emoji_reactions.status_id and r.name = emoji_reactions.name) as me")
end
end
ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji)
records
end
def increment_count!(key)
update_status_stat!(key => public_send(key) + 1)
end
@ -349,6 +371,10 @@ class Status < ApplicationRecord
Bookmark.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
end
def emoji_reactions_map(status_ids, account_id)
EmojiReaction.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
end
def reblogs_map(status_ids, account_id)
unscoped.select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).each_with_object({}) { |s, h| h[s.reblog_of_id] = true }
end

View file

@ -129,7 +129,7 @@ class User < ApplicationRecord
:show_follow_button_on_timeline, :show_subscribe_button_on_timeline, :show_target,
:show_follow_button_on_timeline, :show_subscribe_button_on_timeline, :show_followed_by, :show_target,
:follow_button_to_list_adder, :show_navigation_panel, :show_quote_button, :show_bookmark_button,
:place_tab_bar_at_bottom,:show_tab_bar_label, :enable_limited_timeline,
:place_tab_bar_at_bottom,:show_tab_bar_label, :enable_limited_timeline, :enable_reaction,
to: :settings, prefix: :setting, allow_nil: false

View file

@ -2,26 +2,28 @@
class StatusRelationshipsPresenter
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
:bookmarks_map
:bookmarks_map, :emoji_reactions_map
def initialize(statuses, current_account_id = nil, **options)
if current_account_id.nil?
@reblogs_map = {}
@favourites_map = {}
@bookmarks_map = {}
@mutes_map = {}
@pins_map = {}
@reblogs_map = {}
@favourites_map = {}
@bookmarks_map = {}
@emoji_reactions_map = {}
@mutes_map = {}
@pins_map = {}
else
statuses = statuses.compact
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
conversation_ids = statuses.filter_map(&:conversation_id).uniq
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted).include?(s.visibility) }
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
@mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})
@emoji_reactions_map = Status.emoji_reactions_map(status_ids, current_account_id).merge(options[:emoji_reactions_map] || {})
@mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
end
end
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
class ActivityPub::EmojiReactionSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :content
attribute :virtual_object, key: :object
attribute :misskey_reaction, key: :_misskey_reaction
has_one :custom_emoji ,key: :tag, serializer: ActivityPub::EmojiSerializer, unless: -> { object.custom_emoji.nil? }
def id
[ActivityPub::TagManager.instance.uri_for(object.account), '#emoji_reactions/', object.id].join
end
def type
'Like'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def virtual_object
ActivityPub::TagManager.instance.uri_for(object.status)
end
def content
object.custom_emoji.nil? ? object.name : ":#{object.name}:"
end
def misskey_reaction
object.custom_emoji.nil? ? object.name : ":#{object.name}:"
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class ActivityPub::UndoEmojiReactionSerializer < ActivityPub::Serializer
attributes :id, :type, :actor
has_one :object, serializer: ActivityPub::EmojiReactionSerializer
def id
[ActivityPub::TagManager.instance.uri_for(object.account), '#emoji_reactions/', object.id, '/undo'].join
end
def type
'Undo'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
end

View file

@ -53,6 +53,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:place_tab_bar_at_bottom] = object.current_account.user.setting_place_tab_bar_at_bottom
store[:show_tab_bar_label] = object.current_account.user.setting_show_tab_bar_label
store[:enable_limited_timeline] = object.current_account.user.setting_enable_limited_timeline
store[:enable_reaction] = object.current_account.user.setting_enable_reaction
else
store[:auto_play_gif] = Setting.auto_play_gif
store[:display_media] = Setting.display_media

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
class REST::EmojiReactionSerializer < REST::ReactionSerializer
end

View file

@ -118,6 +118,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:timeline_group_directory,
:visibility_mutual,
:visibility_limited,
:emoji_reaction,
]
end

View file

@ -5,12 +5,30 @@ class REST::NotificationSerializer < ActiveModel::Serializer
belongs_to :from_account, key: :account, serializer: REST::AccountSerializer
belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer
belongs_to :emoji_reaction, if: :emoji_reaction?
attribute :reblog_visibility, if: :reblog?
def id
object.id.to_s
end
def status_type?
[:favourite, :reblog, :status, :mention, :poll].include?(object.type)
[:favourite, :reblog, :status, :mention, :poll, :emoji_reaction].include?(object.type)
end
def reblog?
object.type == :reblog
end
def emoji_reaction?
object.type == :emoji_reaction
end
class EmojiReactionSerializer < REST::EmojiReactionSerializer
attributes :me
def me
false
end
end
end

View file

@ -8,6 +8,7 @@ class REST::ReactionSerializer < ActiveModel::Serializer
attribute :me, if: :current_user?
attribute :url, if: :custom_emoji?
attribute :static_url, if: :custom_emoji?
attribute :domain, if: :custom_emoji?
def count
object.respond_to?(:count) ? object.count : 0
@ -28,4 +29,8 @@ class REST::ReactionSerializer < ActiveModel::Serializer
def static_url
full_asset_url(object.custom_emoji.image.url(:static))
end
def domain
object.custom_emoji.domain
end
end

View file

@ -10,6 +10,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attribute :reblogged, if: :current_user?
attribute :muted, if: :current_user?
attribute :bookmarked, if: :current_user?
attribute :emoji_reactioned, if: :current_user?
attribute :pinned, if: :pinnable?
attribute :circle_id, if: :limited_owned_parent_status?
@ -29,6 +30,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :ordered_mentions, key: :mentions
has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer
has_many :emoji_reactions, serializer: REST::EmojiReactionSerializer
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
@ -124,6 +126,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
def emoji_reactions
object.grouped_reactions(current_user&.account)
end
def reblogged
if instance_options && instance_options[:relationships]
instance_options[:relationships].reblogs_map[object.id] || false
@ -148,6 +154,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
def emoji_reactioned
if instance_options && instance_options[:relationships]
instance_options[:relationships].emoji_reactions_map[object.id] || false
else
current_user.account.emoji_reactioned?(object)
end
end
def pinned
if instance_options && instance_options[:relationships]
instance_options[:relationships].pins_map[object.id] || false

View file

@ -150,6 +150,7 @@ class DeleteAccountService < BaseService
purge_generated_notifications!
purge_favourites!
purge_bookmarks!
purge_reactions!
purge_feeds!
purge_other_associations!
@ -202,6 +203,13 @@ class DeleteAccountService < BaseService
end
end
def purge_reactions!
@account.emoji_reactions.in_batches do |reactions|
Chewy.strategy.current.update(StatusesIndex::Status, reactions.pluck(:status_id)) if Chewy.enabled?
reactions.delete_all
end
end
def purge_other_associations!
associations_for_destruction.each do |association_name|
purge_association(association_name)

View file

@ -0,0 +1,45 @@
# frozen_string_literal: true
class EmojiReactionService < BaseService
include Authorization
include Payloadable
def call(account, status, emoji)
emoji_reaction = EmojiReaction.find_by(account_id: account.id, status_id: status.id)
return emoji_reaction unless emoji_reaction.nil?
shortcode, domain = emoji.split("@")
custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain)
emoji_reaction = EmojiReaction.create!(account: account, status: status, name: shortcode, custom_emoji: custom_emoji)
create_notification(emoji_reaction)
bump_potential_friendship(account, status)
emoji_reaction
end
private
def create_notification(emoji_reaction)
status = emoji_reaction.status
if status.account.local?
NotifyService.new.call(status.account, :emoji_reaction, emoji_reaction)
elsif status.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(emoji_reaction), emoji_reaction.account_id, status.account.inbox_url)
end
end
def bump_potential_friendship(account, status)
ActivityTracker.increment('activity:interactions')
return if account.following?(status.account_id)
PotentialFriendshipTracker.record(account.id, status.account_id, :emoji_reaction)
end
def build_json(emoji_reaction)
Oj.dump(serialize_payload(emoji_reaction, ActivityPub::EmojiReactionSerializer))
end
end

View file

@ -46,6 +46,10 @@ class NotifyService < BaseService
false
end
def blocked_emoji_reaction?
false
end
def following_sender?
return @following_sender if defined?(@following_sender)
@following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
class UnEmojiReactionService < BaseService
include Payloadable
def call(account, status)
emoji_reaction = EmojiReaction.find_by!(account: account, status: status)
emoji_reaction.destroy!
create_notification(emoji_reaction)
emoji_reaction
end
private
def create_notification(emoji_reaction)
status = emoji_reaction.status
if !status.account.local? && status.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(emoji_reaction), emoji_reaction.account_id, status.account.inbox_url)
end
end
def build_json(emoji_reaction)
Oj.dump(serialize_payload(emoji_reaction, ActivityPub::UndoEmojiReactionSerializer))
end
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
class EmojiReactionValidator < ActiveModel::Validator
SUPPORTED_EMOJIS = Oj.load(File.read(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json'))).keys.freeze
LIMIT = 8
def validate(reaction)
return if reaction.name.blank?
reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) if reaction.custom_emoji_id.blank? && !unicode_emoji?(reaction.name)
reaction.errors.add(:base, I18n.t('reactions.errors.limit_reached')) if new_reaction?(reaction) && limit_reached?(reaction)
end
private
def unicode_emoji?(name)
SUPPORTED_EMOJIS.include?(name)
end
def new_reaction?(reaction)
!reaction.status.emoji_reactions.where(name: reaction.name).exists?
end
def limit_reached?(reaction)
reaction.status.emoji_reactions.where.not(name: reaction.name).count('distinct name') >= LIMIT
end
end

View file

@ -0,0 +1,45 @@
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.hero
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center.padded
%table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td
= image_tag full_pack_url('media/images/mailer/icon_grade.png'), alt:''
%h1= t 'notification_mailer.emoji_reaction.title'
%p.lead= t('notification_mailer.emoji_reaction.body', name: @account.acct)
= render 'status', status: @status
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.content-start.border-top
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.button-cell
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.button-primary
= link_to web_url("statuses/#{@status.id}") do
%span= t 'application_mailer.view_status'

View file

@ -0,0 +1,5 @@
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
<%= raw t('notification_mailer.emoji_reaction.body', name: @account.acct) %>
<%= render 'status', status: @status %>

View file

@ -17,6 +17,7 @@
= ff.input :follow_request, as: :boolean, wrapper: :with_label
= ff.input :reblog, as: :boolean, wrapper: :with_label
= ff.input :favourite, as: :boolean, wrapper: :with_label
= ff.input :emoji_reaction, as: :boolean, wrapper: :with_label
= ff.input :mention, as: :boolean, wrapper: :with_label
- if current_user.staff?

View file

@ -63,6 +63,9 @@
.fields-group
= f.input :setting_enable_limited_timeline, as: :boolean, wrapper: :with_label
.fields-group
= f.input :setting_enable_reaction, as: :boolean, wrapper: :with_label
-# .fields-group
-# = f.input :setting_show_target, as: :boolean, wrapper: :with_label

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class PublishEmojiReactionWorker
include Sidekiq::Worker
include Redisable
include RoutingHelper
def perform(status_id, name, custom_emoji_id)
status = Status.find(status_id)
custom_emoji = CustomEmoji.find(custom_emoji_id) if custom_emoji_id.present?
emoji_reaction, = status.emoji_reactions.where(name: name, custom_emoji_id: custom_emoji_id).group(:status_id, :name, :custom_emoji_id).select('name, custom_emoji_id, count(*) as count, false as me')
emoji_reaction ||= status.emoji_reactions.new(name: name, custom_emoji_id: custom_emoji_id)
payload = InlineRenderer.render(emoji_reaction, nil, :emoji_reaction).tap { |h|
h[:status_id] = status_id.to_s
if custom_emoji.present?
h[:url] = full_asset_url(custom_emoji.image.url)
h[:static_url] = full_asset_url(custom_emoji.image.url(:static))
h[:domain] = custom_emoji.domain
end
}
payload = Oj.dump(event: :'emoji_reaction', payload: payload)
FeedManager.instance.with_active_accounts do |account|
redis.publish("timeline:#{account.id}", payload) if redis.exists?("subscribed:timeline:#{account.id}")
end
rescue ActiveRecord::RecordNotFound
true
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class UnEmojiReactionWorker
include Sidekiq::Worker
def perform(account_id, status_id)
UnEmojiReactionService.new.call(Account.find(account_id), Status.find(status_id))
rescue ActiveRecord::RecordNotFound
true
end
end

View file

@ -1155,6 +1155,10 @@ en:
one: "1 new notification since your last visit \U0001F418"
other: "%{count} new notifications since your last visit \U0001F418"
title: In your absence...
emoji_reaction:
body: 'Your post was emoji reactioned by %{name}:'
subject: "%{name} emoji reactioned your post"
title: New emoji reaction
favourite:
body: 'Your post was favourited by %{name}:'
subject: "%{name} favourited your post"

View file

@ -1101,6 +1101,10 @@ ja:
subject:
other: "新しい%{count}件の通知 \U0001F418"
title: 不在の間に…
emoji_reaction:
body: "%{name} さんに絵文字リアクションされた、あなたの投稿があります:"
subject: "%{name} さんに絵文字リアクションされました"
title: 新たな絵文字リアクション
favourite:
body: "%{name} さんにお気に入り登録された、あなたの投稿があります:"
subject: "%{name} さんにお気に入りに登録されました"

View file

@ -52,6 +52,7 @@ en:
setting_display_media_hide_all: Always hide media
setting_display_media_show_all: Always show media
setting_enable_limited_timeline: Enable a limited home to display private and circle and direct message
setting_enable_reaction: Enable the reaction display on the timeline and display the reaction button
setting_follow_button_to_list_adder: Change the behavior of the Follow / Subscribe button, open a dialog where you can select a list to follow / subscribe, or opt out of receiving at home
setting_hide_network: Who you follow and who follows you will be hidden on your profile
setting_noindex: Affects your public profile and post pages
@ -188,6 +189,7 @@ en:
setting_display_media_hide_all: Hide all
setting_display_media_show_all: Show all
setting_enable_limited_timeline: Enable limited timeline
setting_enable_reaction: Enable reaction
setting_expand_spoilers: Always expand posts marked with content warnings
setting_follow_button_to_list_adder: Open list add dialog with follow button
setting_hide_network: Hide your social graph
@ -275,11 +277,13 @@ en:
timeline: Timeline
notification_emails:
digest: Send digest e-mails
emoji_reaction: Someone emoji reactioned you
favourite: Someone favourited your post
follow: Someone followed you
follow_request: Someone requested to follow you
mention: Someone mentioned you
pending_account: New account needs review
reaction: Someone reactioned your post
reblog: Someone boosted your post
report: New report is submitted
trending_tag: An unreviewed hashtag is trending

View file

@ -52,6 +52,7 @@ ja:
setting_display_media_hide_all: メディアを常に隠す
setting_display_media_show_all: メディアを常に表示する
setting_enable_limited_timeline: フォロワー限定・サークル・ダイレクトメッセージを表示する限定ホームを有効にします
setting_enable_reaction: タイムラインでリアクションの表示を有効にし、リアクションボタンを表示する
setting_follow_button_to_list_adder: フォロー・購読ボタンの動作を変更し、フォロー・購読するリストを選択したり、ホームで受け取らないよう設定するダイアログを開きます
setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします
setting_noindex: 公開プロフィールおよび各投稿ページに影響します
@ -188,6 +189,7 @@ ja:
setting_display_media_hide_all: 非表示
setting_display_media_show_all: 表示
setting_enable_limited_timeline: 限定ホームを有効にする
setting_enable_reaction: リアクションを有効にする
setting_expand_spoilers: 閲覧注意としてマークされた投稿を常に展開する
setting_follow_button_to_list_adder: フォローボタンでリスト追加ダイアログを開く
setting_hide_network: 繋がりを隠す
@ -279,11 +281,13 @@ ja:
timeline: タイムライン
notification_emails:
digest: タイムラインからピックアップしてメールで通知する
emoji_reaction: 絵文字リアクションされた時
favourite: お気に入り登録された時
follow: フォローされた時
follow_request: フォローリクエストを受けた時
mention: 返信が来た時
pending_account: 新しいアカウントの承認が必要な時
reaction: リアクションされた時
reblog: 投稿がブーストされた時
report: 通報を受けた時
trending_tag: 未審査のハッシュタグが人気の時

View file

@ -333,6 +333,7 @@ Rails.application.routes.draw do
scope module: :statuses do
resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
resources :favourited_by, controller: :favourited_by_accounts, only: :index
resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index
resources :mentioned_by, controller: :mentioned_by_accounts, only: :index
resource :reblog, only: :create
post :unreblog, to: 'reblogs#destroy'
@ -348,6 +349,9 @@ Rails.application.routes.draw do
resource :pin, only: :create
post :unpin, to: 'pins#destroy'
resources :emoji_reactions, only: :update, constraints: { id: /[^\/]+/ }
post :emoji_unreaction, to: 'emoji_reactions#destroy'
end
member do
@ -402,16 +406,17 @@ Rails.application.routes.draw do
end
end
resources :media, only: [:create, :update, :show]
resources :blocks, only: [:index]
resources :mutes, only: [:index]
resources :favourites, only: [:index]
resources :bookmarks, only: [:index]
resources :reports, only: [:create]
resources :trends, only: [:index]
resources :filters, only: [:index, :create, :show, :update, :destroy]
resources :endorsements, only: [:index]
resources :markers, only: [:index, :create]
resources :media, only: [:create, :update, :show]
resources :blocks, only: [:index]
resources :mutes, only: [:index]
resources :favourites, only: [:index]
resources :bookmarks, only: [:index]
resources :emoji_reactions, only: [:index]
resources :reports, only: [:create]
resources :trends, only: [:index]
resources :filters, only: [:index, :create, :show, :update, :destroy]
resources :endorsements, only: [:index]
resources :markers, only: [:index, :create]
namespace :apps do
get :verify_credentials, to: 'credentials#show'

View file

@ -50,10 +50,12 @@ defaults: &defaults
place_tab_bar_at_bottom: false
show_tab_bar_label: false
enable_limited_timeline: false
enable_reaction: true
notification_emails:
follow: false
reblog: false
favourite: false
emoji_reaction: false
mention: false
follow_request: true
digest: true

View file

@ -0,0 +1,16 @@
class CreateEmojiReactions < ActiveRecord::Migration[5.2]
def change
create_table :emoji_reactions do |t|
t.belongs_to :account, foreign_key: { on_delete: :cascade, index: false }
t.belongs_to :status, foreign_key: { on_delete: :cascade }
t.string :name, null: false, default: ''
t.belongs_to :custom_emoji, foreign_key: { on_delete: :cascade }
t.timestamps
end
add_index :emoji_reactions, [:account_id, :status_id, :name], unique: true, name: :index_emoji_reactions_on_account_id_and_status_id
end
end

View file

@ -0,0 +1,5 @@
class AddUriToEmojiReaction < ActiveRecord::Migration[6.1]
def change
add_column :emoji_reactions, :uri, :string
end
end

View file

@ -0,0 +1,8 @@
class ChangeStatusAndAccountOnEmojiReactionToNonnullable < ActiveRecord::Migration[6.1]
def change
safety_assured do
change_column_null :emoji_reactions, :account_id, false
change_column_null :emoji_reactions, :status_id, false
end
end
end

View file

@ -454,6 +454,20 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true
end
create_table "emoji_reactions", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "status_id", null: false
t.string "name", default: "", null: false
t.bigint "custom_emoji_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "uri"
t.index ["account_id", "status_id", "name"], name: "index_emoji_reactions_on_account_id_and_status_id", unique: true
t.index ["account_id"], name: "index_emoji_reactions_on_account_id"
t.index ["custom_emoji_id"], name: "index_emoji_reactions_on_custom_emoji_id"
t.index ["status_id"], name: "index_emoji_reactions_on_status_id"
end
create_table "encrypted_messages", id: :bigint, default: -> { "timestamp_id('encrypted_messages'::text)" }, force: :cascade do |t|
t.bigint "device_id"
t.bigint "from_account_id"
@ -1165,6 +1179,9 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
add_foreign_key "domain_subscribes", "lists", on_delete: :cascade
add_foreign_key "domains", "accounts", column: "contact_account_id"
add_foreign_key "email_domain_blocks", "email_domain_blocks", column: "parent_id", on_delete: :cascade
add_foreign_key "emoji_reactions", "accounts", on_delete: :cascade
add_foreign_key "emoji_reactions", "custom_emojis", on_delete: :cascade
add_foreign_key "emoji_reactions", "statuses", on_delete: :cascade
add_foreign_key "encrypted_messages", "accounts", column: "from_account_id", on_delete: :cascade
add_foreign_key "encrypted_messages", "devices", on_delete: :cascade
add_foreign_key "favourite_domains", "accounts", on_delete: :cascade