Add emoji reaction
This commit is contained in:
parent
28675f7d34
commit
ae60e0a7d7
90 changed files with 2275 additions and 69 deletions
|
@ -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'
|
||||
|
|
89
app/controllers/api/v1/emoji_reactions_controller.rb
Normal file
89
app/controllers/api/v1/emoji_reactions_controller.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
94
app/javascript/mastodon/actions/emoji_reactions.js
Normal file
94
app/javascript/mastodon/actions/emoji_reactions.js
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
65
app/javascript/mastodon/components/emoji.js
Normal file
65
app/javascript/mastodon/components/emoji.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
111
app/javascript/mastodon/components/emoji_reactions_bar.js
Normal file
111
app/javascript/mastodon/components/emoji_reactions_bar.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
395
app/javascript/mastodon/components/reaction_picker_dropdown.js
Normal file
395
app/javascript/mastodon/components/reaction_picker_dropdown.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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} />}
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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));
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
87
app/javascript/mastodon/features/emoji_reactions/index.js
Normal file
87
app/javascript/mastodon/features/emoji_reactions/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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)}>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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' />,
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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} 件の通知",
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
@ -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%);
|
||||
|
||||
|
|
|
@ -264,6 +264,7 @@ body.rtl {
|
|||
}
|
||||
|
||||
.detailed-status__favorites,
|
||||
.detailed-status__emoji_reactions,
|
||||
.detailed-status__reblogs {
|
||||
margin-left: 0;
|
||||
margin-right: 6px;
|
||||
|
|
|
@ -56,6 +56,8 @@ class ActivityPub::Activity
|
|||
ActivityPub::Activity::Remove
|
||||
when 'Move'
|
||||
ActivityPub::Activity::Move
|
||||
when 'EmojiReact'
|
||||
ActivityPub::Activity::EmojiReact
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
14
app/lib/activitypub/activity/emoji_react.rb
Normal file
14
app/lib/activitypub/activity/emoji_react.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -6,6 +6,7 @@ class PotentialFriendshipTracker
|
|||
|
||||
WEIGHTS = {
|
||||
reply: 1,
|
||||
emoji_reaction: 1,
|
||||
favourite: 10,
|
||||
reblog: 20,
|
||||
}.freeze
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
40
app/models/emoji_reaction.rb
Normal file
40
app/models/emoji_reaction.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
33
app/serializers/activitypub/emoji_reaction_serializer.rb
Normal file
33
app/serializers/activitypub/emoji_reaction_serializer.rb
Normal 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
|
|
@ -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
|
|
@ -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
|
||||
|
|
4
app/serializers/rest/emoji_reaction_serializer.rb
Normal file
4
app/serializers/rest/emoji_reaction_serializer.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::EmojiReactionSerializer < REST::ReactionSerializer
|
||||
end
|
|
@ -118,6 +118,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
|||
:timeline_group_directory,
|
||||
:visibility_mutual,
|
||||
:visibility_limited,
|
||||
:emoji_reaction,
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
45
app/services/emoji_reaction_service.rb
Normal file
45
app/services/emoji_reaction_service.rb
Normal 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
|
|
@ -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)
|
||||
|
|
27
app/services/un_emoji_reaction_service.rb
Normal file
27
app/services/un_emoji_reaction_service.rb
Normal 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
|
28
app/validators/emoji_reaction_validator.rb
Normal file
28
app/validators/emoji_reaction_validator.rb
Normal 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
|
45
app/views/notification_mailer/emoji_reaction.html.haml
Normal file
45
app/views/notification_mailer/emoji_reaction.html.haml
Normal 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'
|
5
app/views/notification_mailer/emoji_reaction.text.erb
Normal file
5
app/views/notification_mailer/emoji_reaction.text.erb
Normal 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 %>
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
31
app/workers/publish_emoji_reaction_worker.rb
Normal file
31
app/workers/publish_emoji_reaction_worker.rb
Normal 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
|
11
app/workers/un_emoji_reaction_worker.rb
Normal file
11
app/workers/un_emoji_reaction_worker.rb
Normal 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
|
|
@ -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"
|
||||
|
|
|
@ -1101,6 +1101,10 @@ ja:
|
|||
subject:
|
||||
other: "新しい%{count}件の通知 \U0001F418"
|
||||
title: 不在の間に…
|
||||
emoji_reaction:
|
||||
body: "%{name} さんに絵文字リアクションされた、あなたの投稿があります:"
|
||||
subject: "%{name} さんに絵文字リアクションされました"
|
||||
title: 新たな絵文字リアクション
|
||||
favourite:
|
||||
body: "%{name} さんにお気に入り登録された、あなたの投稿があります:"
|
||||
subject: "%{name} さんにお気に入りに登録されました"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: 未審査のハッシュタグが人気の時
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
16
db/migrate/20200318135004_create_emoji_reactions.rb
Normal file
16
db/migrate/20200318135004_create_emoji_reactions.rb
Normal 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
|
||||
|
5
db/migrate/20210514223437_add_uri_to_emoji_reaction.rb
Normal file
5
db/migrate/20210514223437_add_uri_to_emoji_reaction.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class AddUriToEmojiReaction < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :emoji_reactions, :uri, :string
|
||||
end
|
||||
end
|
|
@ -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
|
17
db/schema.rb
17
db/schema.rb
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue