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) }
|
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
|
||||||
end
|
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
|
root date_detection: false do
|
||||||
field :id, type: 'long'
|
field :id, type: 'long'
|
||||||
field :account_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
|
def data_params
|
||||||
return {} if params[:data].blank?
|
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
|
||||||
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,
|
mention: alerts_enabled,
|
||||||
poll: alerts_enabled,
|
poll: alerts_enabled,
|
||||||
status: alerts_enabled,
|
status: alerts_enabled,
|
||||||
|
emoji_reaction: alerts_enabled,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,6 +62,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def data_params
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -67,7 +67,8 @@ class Settings::PreferencesController < Settings::BaseController
|
||||||
:setting_show_tab_bar_label,
|
:setting_show_tab_bar_label,
|
||||||
:setting_show_target,
|
:setting_show_target,
|
||||||
:setting_enable_limited_timeline,
|
: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)
|
interactions: %i(must_be_follower must_be_following must_be_following_dm)
|
||||||
)
|
)
|
||||||
end
|
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_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
|
||||||
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
|
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_REQUEST = 'MENTIONS_FETCH_REQUEST';
|
||||||
export const MENTIONS_FETCH_SUCCESS = 'MENTIONS_FETCH_SUCCESS';
|
export const MENTIONS_FETCH_SUCCESS = 'MENTIONS_FETCH_SUCCESS';
|
||||||
export const MENTIONS_FETCH_FAIL = 'MENTIONS_FETCH_FAIL';
|
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_SUCCESS = 'UNBOOKMARKED_SUCCESS';
|
||||||
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
|
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) {
|
export function reblog(status, visibility) {
|
||||||
return function (dispatch, getState) {
|
return function (dispatch, getState) {
|
||||||
dispatch(reblogRequest(status));
|
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) {
|
export function fetchMentions(id) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(fetchMentionsRequest(id));
|
dispatch(fetchMentionsRequest(id));
|
||||||
|
@ -451,3 +500,118 @@ export function unpinFail(status, error) {
|
||||||
skipLoading: true,
|
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 { List as ImmutableList } from 'immutable';
|
||||||
import { unescapeHTML } from '../utils/html';
|
import { unescapeHTML } from '../utils/html';
|
||||||
import { getFiltersRegex } from '../selectors';
|
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 compareId from 'mastodon/compare_id';
|
||||||
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
|
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
|
||||||
import { requestNotificationPermission } from '../utils/notifications';
|
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 excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
|
||||||
|
|
||||||
const excludeTypesFromFilter = filter => {
|
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();
|
return allTypes.filterNot(item => item === filter).toJS();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import { getHomeVisibilities } from 'mastodon/selectors';
|
import { getHomeVisibilities } from 'mastodon/selectors';
|
||||||
import { updateNotifications, expandNotifications } from './notifications';
|
import { updateNotifications, expandNotifications } from './notifications';
|
||||||
import { updateConversations } from './conversations';
|
import { updateConversations } from './conversations';
|
||||||
|
import { updateEmojiReaction } from './interactions';
|
||||||
import {
|
import {
|
||||||
fetchAnnouncements,
|
fetchAnnouncements,
|
||||||
updateAnnouncements,
|
updateAnnouncements,
|
||||||
|
@ -88,6 +89,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||||
case 'filters_changed':
|
case 'filters_changed':
|
||||||
dispatch(fetchFilters());
|
dispatch(fetchFilters());
|
||||||
break;
|
break;
|
||||||
|
case 'emoji_reaction':
|
||||||
|
dispatch(updateEmojiReaction(JSON.parse(data.payload)));
|
||||||
|
break;
|
||||||
case 'announcement':
|
case 'announcement':
|
||||||
dispatch(updateAnnouncements(JSON.parse(data.payload)));
|
dispatch(updateAnnouncements(JSON.parse(data.payload)));
|
||||||
break;
|
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 { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Icon from 'mastodon/components/icon';
|
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 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
|
// We use the component (and not the container) since we do not want
|
||||||
// to use the progress bar to show download progress
|
// to use the progress bar to show download progress
|
||||||
|
@ -144,6 +144,9 @@ class Status extends ImmutablePureComponent {
|
||||||
available: PropTypes.bool,
|
available: PropTypes.bool,
|
||||||
}),
|
}),
|
||||||
contextType: PropTypes.string,
|
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
|
// Avoid checking props that are functions (and whose equality will always
|
||||||
|
@ -709,6 +712,12 @@ class Status extends ImmutablePureComponent {
|
||||||
{quote}
|
{quote}
|
||||||
{media}
|
{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} />
|
<StatusActionBar scrollKey={scrollKey} status={status} account={account} expired={expired} {...other} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,9 +6,11 @@ import IconButton from './icon_button';
|
||||||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
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 classNames from 'classnames';
|
||||||
|
|
||||||
|
import ReactionPickerDropdown from '../containers/reaction_picker_dropdown_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
|
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
|
||||||
|
@ -85,6 +87,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
withDismiss: PropTypes.bool,
|
withDismiss: PropTypes.bool,
|
||||||
scrollKey: PropTypes.string,
|
scrollKey: PropTypes.string,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
|
addEmojiReaction: PropTypes.func,
|
||||||
|
removeEmojiReaction: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
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 () {
|
render () {
|
||||||
const { status, relationship, intl, withDismiss, scrollKey, expired } = this.props;
|
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={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} />
|
<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} />}
|
{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}
|
{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} />}
|
{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,
|
unbookmark,
|
||||||
pin,
|
pin,
|
||||||
unpin,
|
unpin,
|
||||||
|
addEmojiReaction,
|
||||||
|
removeEmojiReaction,
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
import {
|
import {
|
||||||
muteStatus,
|
muteStatus,
|
||||||
|
@ -51,6 +53,9 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import { boostModal, deleteModal, unfollowModal, unsubscribeModal } from '../initial_state';
|
import { boostModal, deleteModal, unfollowModal, unsubscribeModal } from '../initial_state';
|
||||||
import { showAlertForError } from '../actions/alerts';
|
import { showAlertForError } from '../actions/alerts';
|
||||||
|
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
|
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 makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
const getPictureInPicture = makeGetPictureInPicture();
|
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) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
status: getStatus(state, props),
|
status: getStatus(state, props),
|
||||||
pictureInPicture: getPictureInPicture(state, props),
|
pictureInPicture: getPictureInPicture(state, props),
|
||||||
|
emojiMap: customEmojiMap(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapStateToProps;
|
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));
|
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 { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
|
||||||
const pinned = !!columnId;
|
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 (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
|
<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 (
|
return (
|
||||||
<Column bindToDocument={!multiColumn}>
|
<Column bindToDocument={!multiColumn}>
|
||||||
|
|
|
@ -31,6 +31,7 @@ const messages = defineMessages({
|
||||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
|
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' },
|
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
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='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='bookmark' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
|
||||||
<ColumnLink key='favourites' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
|
<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='lists' icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />,
|
||||||
<ColumnLink key='circles' icon='user-circle' text={intl.formatMessage(messages.circles)} to='/circles' />,
|
<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><kbd>g</kbd>+<kbd>f</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open favourites list' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.favourites' defaultMessage='to open favourites list' /></td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td><kbd>g</kbd>+<kbd>p</kbd></td>
|
<td><kbd>g</kbd>+<kbd>p</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.pinned' defaultMessage='to open pinned toots list' /></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} />
|
<SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ const tooltips = defineMessages({
|
||||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||||
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
|
||||||
|
reactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Reactions' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @injectIntl
|
export default @injectIntl
|
||||||
|
@ -95,6 +96,13 @@ class FilterBar extends React.PureComponent {
|
||||||
>
|
>
|
||||||
<Icon id='home' fixedWidth />
|
<Icon id='home' fixedWidth />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={selectedFilter === 'emoji_reaction' ? 'active' : ''}
|
||||||
|
onClick={this.onClick('emoji_reaction')}
|
||||||
|
title={intl.formatMessage(tooltips.reactions)}
|
||||||
|
>
|
||||||
|
<Icon id='smile-o' fixedWidth />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={selectedFilter === 'follow' ? 'active' : ''}
|
className={selectedFilter === 'follow' ? 'active' : ''}
|
||||||
onClick={this.onClick('follow')}
|
onClick={this.onClick('follow')}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
@ -8,17 +8,19 @@ import { me } from 'mastodon/initial_state';
|
||||||
import StatusContainer from 'mastodon/containers/status_container';
|
import StatusContainer from 'mastodon/containers/status_container';
|
||||||
import AccountContainer from 'mastodon/containers/account_container';
|
import AccountContainer from 'mastodon/containers/account_container';
|
||||||
import FollowRequestContainer from '../containers/follow_request_container';
|
import FollowRequestContainer from '../containers/follow_request_container';
|
||||||
|
import Emoji from 'mastodon/components/emoji';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import Permalink from 'mastodon/components/permalink';
|
import Permalink from 'mastodon/components/permalink';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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' },
|
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
|
||||||
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
|
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
|
||||||
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in 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' },
|
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
|
||||||
|
emoji_reaction: { id: 'notification.emoji_reaction', defaultMessage: '{name} reactioned your post' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const notificationForScreenReader = (intl, message, timestamp) => {
|
const notificationForScreenReader = (intl, message, timestamp) => {
|
||||||
|
@ -52,6 +54,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
cacheMediaWidth: PropTypes.func,
|
cacheMediaWidth: PropTypes.func,
|
||||||
cachedMediaWidth: PropTypes.number,
|
cachedMediaWidth: PropTypes.number,
|
||||||
unread: PropTypes.bool,
|
unread: PropTypes.bool,
|
||||||
|
emojiMap: ImmutablePropTypes.map,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveUp = () => {
|
handleMoveUp = () => {
|
||||||
|
@ -189,7 +192,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span title={notification.get('created_at')}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -221,7 +224,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span title={notification.get('created_at')}>
|
<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>
|
</span>
|
||||||
</div>
|
</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 () {
|
render () {
|
||||||
const { notification } = this.props;
|
const { notification } = this.props;
|
||||||
const account = notification.get('account');
|
const account = notification.get('account');
|
||||||
|
@ -332,9 +371,11 @@ class Notification extends ImmutablePureComponent {
|
||||||
return this.renderStatus(notification, link);
|
return this.renderStatus(notification, link);
|
||||||
case 'poll':
|
case 'poll':
|
||||||
return this.renderPoll(notification, account);
|
return this.renderPoll(notification, account);
|
||||||
|
case 'emoji_reaction':
|
||||||
|
return this.renderReaction(notification, link);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,16 +14,20 @@ import {
|
||||||
revealStatus,
|
revealStatus,
|
||||||
} from '../../../actions/statuses';
|
} from '../../../actions/statuses';
|
||||||
import { boostModal } from '../../../initial_state';
|
import { boostModal } from '../../../initial_state';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
const getNotification = makeGetNotification();
|
const getNotification = makeGetNotification();
|
||||||
const getStatus = makeGetStatus();
|
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 mapStateToProps = (state, props) => {
|
||||||
const notification = getNotification(state, props.notification, props.accountId);
|
const notification = getNotification(state, props.notification, props.accountId);
|
||||||
return {
|
return {
|
||||||
notification: notification,
|
notification: notification,
|
||||||
status: notification.get('status') ? getStatus(state, { id: notification.get('status') }) : null,
|
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 ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
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 classNames from 'classnames';
|
||||||
|
import ReactionPickerDropdown from 'mastodon/containers/reaction_picker_dropdown_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
|
@ -78,6 +79,8 @@ class ActionBar extends React.PureComponent {
|
||||||
onPin: PropTypes.func,
|
onPin: PropTypes.func,
|
||||||
onEmbed: PropTypes.func,
|
onEmbed: PropTypes.func,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
|
addEmojiReaction: PropTypes.func.isRequired,
|
||||||
|
removeEmojiReaction: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleReplyClick = () => {
|
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 () {
|
render () {
|
||||||
const { status, relationship, intl } = this.props;
|
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={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>
|
<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>}
|
{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}
|
{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>
|
<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 classNames from 'classnames';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import AnimatedNumber from 'mastodon/components/animated_number';
|
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 PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||||
|
import { enableReaction } from 'mastodon/initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
|
@ -88,6 +90,9 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
onQuoteToggleHidden: PropTypes.func.isRequired,
|
onQuoteToggleHidden: PropTypes.func.isRequired,
|
||||||
showQuoteMedia: PropTypes.bool,
|
showQuoteMedia: PropTypes.bool,
|
||||||
onToggleQuoteMediaVisibility: PropTypes.func,
|
onToggleQuoteMediaVisibility: PropTypes.func,
|
||||||
|
emojiMap: ImmutablePropTypes.map,
|
||||||
|
addEmojiReaction: PropTypes.func.isRequired,
|
||||||
|
removeEmojiReaction: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -183,6 +188,11 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
let reblogLink = '';
|
let reblogLink = '';
|
||||||
let reblogIcon = 'retweet';
|
let reblogIcon = 'retweet';
|
||||||
let favouriteLink = '';
|
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) {
|
if (this.props.measureHeight) {
|
||||||
outerStyle.height = `${this.state.height}px`;
|
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'>
|
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
|
||||||
<Icon id={reblogIcon} />
|
<Icon id={reblogIcon} />
|
||||||
<span className='detailed-status__reblogs'>
|
<span className='detailed-status__reblogs'>
|
||||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
<AnimatedNumber value={reblogsCount} />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
@ -368,7 +378,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
|
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||||
<Icon id={reblogIcon} />
|
<Icon id={reblogIcon} />
|
||||||
<span className='detailed-status__reblogs'>
|
<span className='detailed-status__reblogs'>
|
||||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
<AnimatedNumber value={reblogsCount} />
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
@ -380,7 +390,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
<Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
|
<Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
|
||||||
<Icon id='star' />
|
<Icon id='star' />
|
||||||
<span className='detailed-status__favorites'>
|
<span className='detailed-status__favorites'>
|
||||||
<AnimatedNumber value={status.get('favourites_count')} />
|
<AnimatedNumber value={favouritesCount} />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
@ -389,7 +399,27 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
|
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||||
<Icon id='star' />
|
<Icon id='star' />
|
||||||
<span className='detailed-status__favorites'>
|
<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>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@ -412,6 +442,13 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
{quote}
|
{quote}
|
||||||
{media}
|
{media}
|
||||||
|
|
||||||
|
{enableReaction && <EmojiReactionsBar
|
||||||
|
status={status}
|
||||||
|
addEmojiReaction={this.props.addEmojiReaction}
|
||||||
|
removeEmojiReaction={this.props.removeEmojiReaction}
|
||||||
|
emojiMap={this.props.emojiMap}
|
||||||
|
/>}
|
||||||
|
|
||||||
<div className='detailed-status__meta'>
|
<div className='detailed-status__meta'>
|
||||||
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
|
<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' />
|
<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>
|
</time>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
|
{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionLink}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -13,6 +13,8 @@ import {
|
||||||
unfavourite,
|
unfavourite,
|
||||||
pin,
|
pin,
|
||||||
unpin,
|
unpin,
|
||||||
|
addEmojiReaction,
|
||||||
|
removeEmojiReaction,
|
||||||
} from '../../../actions/interactions';
|
} from '../../../actions/interactions';
|
||||||
import {
|
import {
|
||||||
muteStatus,
|
muteStatus,
|
||||||
|
@ -32,6 +34,9 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import { boostModal, deleteModal } from '../../../initial_state';
|
import { boostModal, deleteModal } from '../../../initial_state';
|
||||||
import { showAlertForError } from '../../../actions/alerts';
|
import { showAlertForError } from '../../../actions/alerts';
|
||||||
|
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
|
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 makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
const getPictureInPicture = makeGetPictureInPicture();
|
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) => ({
|
const mapStateToProps = (state, props) => ({
|
||||||
status: getStatus(state, props),
|
status: getStatus(state, props),
|
||||||
domain: state.getIn(['meta', 'domain']),
|
domain: state.getIn(['meta', 'domain']),
|
||||||
pictureInPicture: getPictureInPicture(state, props),
|
pictureInPicture: getPictureInPicture(state, props),
|
||||||
|
emojiMap: customEmojiMap(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapStateToProps;
|
return mapStateToProps;
|
||||||
|
@ -182,6 +189,15 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
dispatch(hideQuote(status.get('id')));
|
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));
|
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { connect } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { fetchStatus } from '../../actions/statuses';
|
import { fetchStatus } from '../../actions/statuses';
|
||||||
import MissingIndicator from '../../components/missing_indicator';
|
import MissingIndicator from '../../components/missing_indicator';
|
||||||
|
@ -19,6 +20,8 @@ import {
|
||||||
unreblog,
|
unreblog,
|
||||||
pin,
|
pin,
|
||||||
unpin,
|
unpin,
|
||||||
|
addEmojiReaction,
|
||||||
|
removeEmojiReaction,
|
||||||
} from '../../actions/interactions';
|
} from '../../actions/interactions';
|
||||||
import {
|
import {
|
||||||
replyCompose,
|
replyCompose,
|
||||||
|
@ -79,6 +82,7 @@ const messages = defineMessages({
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
const getPictureInPicture = makeGetPictureInPicture();
|
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([
|
const getAncestorsIds = createSelector([
|
||||||
(_, { id }) => id,
|
(_, { id }) => id,
|
||||||
|
@ -152,6 +156,7 @@ const makeMapStateToProps = () => {
|
||||||
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
|
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
|
||||||
domain: state.getIn(['meta', 'domain']),
|
domain: state.getIn(['meta', 'domain']),
|
||||||
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
|
pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
|
||||||
|
emojiMap: customEmojiMap(state),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -180,6 +185,7 @@ class Status extends ImmutablePureComponent {
|
||||||
inUse: PropTypes.bool,
|
inUse: PropTypes.bool,
|
||||||
available: PropTypes.bool,
|
available: PropTypes.bool,
|
||||||
}),
|
}),
|
||||||
|
emojiMap: ImmutablePropTypes.map,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -451,6 +457,14 @@ class Status extends ImmutablePureComponent {
|
||||||
this.handleToggleMediaVisibility();
|
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 => {
|
handleMoveUp = id => {
|
||||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||||
|
|
||||||
|
@ -542,7 +556,7 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let ancestors, descendants;
|
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;
|
const { fullscreen } = this.state;
|
||||||
|
|
||||||
if (status === null) {
|
if (status === null) {
|
||||||
|
@ -606,6 +620,9 @@ class Status extends ImmutablePureComponent {
|
||||||
onQuoteToggleHidden={this.handleQuoteToggleHidden}
|
onQuoteToggleHidden={this.handleQuoteToggleHidden}
|
||||||
showQuoteMedia={this.state.showQuoteMedia}
|
showQuoteMedia={this.state.showQuoteMedia}
|
||||||
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
|
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
|
||||||
|
emojiMap={emojiMap}
|
||||||
|
addEmojiReaction={this.handleAddEmojiReaction}
|
||||||
|
removeEmojiReaction={this.handleRemoveEmojiReaction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ActionBar
|
<ActionBar
|
||||||
|
@ -630,6 +647,8 @@ class Status extends ImmutablePureComponent {
|
||||||
onReport={this.handleReport}
|
onReport={this.handleReport}
|
||||||
onPin={this.handlePin}
|
onPin={this.handlePin}
|
||||||
onEmbed={this.handleEmbed}
|
onEmbed={this.handleEmbed}
|
||||||
|
addEmojiReaction={this.handleAddEmojiReaction}
|
||||||
|
removeEmojiReaction={this.handleRemoveEmojiReaction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</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='/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='/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='/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='/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='/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>
|
<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,
|
Subscribing,
|
||||||
Reblogs,
|
Reblogs,
|
||||||
Favourites,
|
Favourites,
|
||||||
|
EmojiReactions,
|
||||||
Mentions,
|
Mentions,
|
||||||
DirectTimeline,
|
DirectTimeline,
|
||||||
LimitedTimeline,
|
LimitedTimeline,
|
||||||
|
@ -48,6 +49,7 @@ import {
|
||||||
GenericNotFound,
|
GenericNotFound,
|
||||||
FavouritedStatuses,
|
FavouritedStatuses,
|
||||||
BookmarkedStatuses,
|
BookmarkedStatuses,
|
||||||
|
EmojiReactionedStatuses,
|
||||||
ListTimeline,
|
ListTimeline,
|
||||||
Blocks,
|
Blocks,
|
||||||
DomainBlocks,
|
DomainBlocks,
|
||||||
|
@ -106,6 +108,7 @@ const keyMap = {
|
||||||
goToDirect: 'g d',
|
goToDirect: 'g d',
|
||||||
goToStart: 'g s',
|
goToStart: 'g s',
|
||||||
goToFavourites: 'g f',
|
goToFavourites: 'g f',
|
||||||
|
goToEmojiReactions: 'g e',
|
||||||
goToPinned: 'g p',
|
goToPinned: 'g p',
|
||||||
goToProfile: 'g u',
|
goToProfile: 'g u',
|
||||||
goToBlocked: 'g b',
|
goToBlocked: 'g b',
|
||||||
|
@ -173,6 +176,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||||
<WrappedRoute path='/notifications' component={Notifications} content={children} />
|
<WrappedRoute path='/notifications' component={Notifications} content={children} />
|
||||||
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
|
||||||
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} 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='/pinned' component={PinnedStatuses} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/start' component={FollowRecommendations} 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' exact component={Status} content={children} />
|
||||||
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
|
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
|
||||||
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} 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='/statuses/:statusId/mentions' component={Mentions} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} 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');
|
this.context.router.history.push('/favourites');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToEmojiReactions = () => {
|
||||||
|
this.context.router.history.push('/emoji_reactions');
|
||||||
|
}
|
||||||
|
|
||||||
handleHotkeyGoToPinned = () => {
|
handleHotkeyGoToPinned = () => {
|
||||||
this.context.router.history.push('/pinned');
|
this.context.router.history.push('/pinned');
|
||||||
}
|
}
|
||||||
|
@ -532,6 +541,7 @@ class UI extends React.PureComponent {
|
||||||
goToDirect: this.handleHotkeyGoToDirect,
|
goToDirect: this.handleHotkeyGoToDirect,
|
||||||
goToStart: this.handleHotkeyGoToStart,
|
goToStart: this.handleHotkeyGoToStart,
|
||||||
goToFavourites: this.handleHotkeyGoToFavourites,
|
goToFavourites: this.handleHotkeyGoToFavourites,
|
||||||
|
goToEmojiReactions: this.handleHotkeyGoToEmojiReactions,
|
||||||
goToPinned: this.handleHotkeyGoToPinned,
|
goToPinned: this.handleHotkeyGoToPinned,
|
||||||
goToProfile: this.handleHotkeyGoToProfile,
|
goToProfile: this.handleHotkeyGoToProfile,
|
||||||
goToBlocked: this.handleHotkeyGoToBlocked,
|
goToBlocked: this.handleHotkeyGoToBlocked,
|
||||||
|
|
|
@ -90,6 +90,10 @@ export function Favourites () {
|
||||||
return import(/* webpackChunkName: "features/favourites" */'../../favourites');
|
return import(/* webpackChunkName: "features/favourites" */'../../favourites');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function EmojiReactions () {
|
||||||
|
return import(/* webpackChunkName: "features/emoji_reactions" */'../../emoji_reactions');
|
||||||
|
}
|
||||||
|
|
||||||
export function Mentions () {
|
export function Mentions () {
|
||||||
return import(/* webpackChunkName: "features/mentions" */'../../mentions');
|
return import(/* webpackChunkName: "features/mentions" */'../../mentions');
|
||||||
}
|
}
|
||||||
|
@ -110,6 +114,10 @@ export function BookmarkedStatuses () {
|
||||||
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
|
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function EmojiReactionedStatuses () {
|
||||||
|
return import(/* webpackChunkName: "features/emoji_reactioned_statuses" */'../../emoji_reactioned_statuses');
|
||||||
|
}
|
||||||
|
|
||||||
export function Blocks () {
|
export function Blocks () {
|
||||||
return import(/* webpackChunkName: "features/blocks" */'../../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 place_tab_bar_at_bottom = getMeta('place_tab_bar_at_bottom');
|
||||||
export const show_tab_bar_label = getMeta('show_tab_bar_label');
|
export const show_tab_bar_label = getMeta('show_tab_bar_label');
|
||||||
export const enable_limited_timeline = getMeta('enable_limited_timeline');
|
export const enable_limited_timeline = getMeta('enable_limited_timeline');
|
||||||
|
export const enableReaction = getMeta('enable_reaction');
|
||||||
|
|
||||||
export default initialState;
|
export default initialState;
|
||||||
|
|
|
@ -90,6 +90,7 @@
|
||||||
"column.directory": "Browse profiles",
|
"column.directory": "Browse profiles",
|
||||||
"column.group_directory": "Browse groups",
|
"column.group_directory": "Browse groups",
|
||||||
"column.domain_blocks": "Blocked domains",
|
"column.domain_blocks": "Blocked domains",
|
||||||
|
"column.emoji_reactions": "EmojiReactions",
|
||||||
"column.favourites": "Favourites",
|
"column.favourites": "Favourites",
|
||||||
"column.follow_requests": "Follow requests",
|
"column.follow_requests": "Follow requests",
|
||||||
"column.group": "Group timeline",
|
"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.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.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.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.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.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.",
|
"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.description": "Description",
|
||||||
"keyboard_shortcuts.direct": "Open direct messages column",
|
"keyboard_shortcuts.direct": "Open direct messages column",
|
||||||
"keyboard_shortcuts.down": "Move down in the list",
|
"keyboard_shortcuts.down": "Move down in the list",
|
||||||
|
"keyboard_shortcuts.emoji_reaction": "Open emoji reactions list",
|
||||||
"keyboard_shortcuts.enter": "Open post",
|
"keyboard_shortcuts.enter": "Open post",
|
||||||
"keyboard_shortcuts.favourite": "Favourite post",
|
"keyboard_shortcuts.favourite": "Favourite post",
|
||||||
"keyboard_shortcuts.favourites": "Open favourites list",
|
"keyboard_shortcuts.favourites": "Open favourites list",
|
||||||
|
@ -337,6 +341,7 @@
|
||||||
"navigation_bar.discover": "Discover",
|
"navigation_bar.discover": "Discover",
|
||||||
"navigation_bar.domain_blocks": "Blocked domains",
|
"navigation_bar.domain_blocks": "Blocked domains",
|
||||||
"navigation_bar.edit_profile": "Edit profile",
|
"navigation_bar.edit_profile": "Edit profile",
|
||||||
|
"navigation_bar.emoji_reactions": "Emoji reactions",
|
||||||
"navigation_bar.favourites": "Favourites",
|
"navigation_bar.favourites": "Favourites",
|
||||||
"navigation_bar.filters": "Muted words",
|
"navigation_bar.filters": "Muted words",
|
||||||
"navigation_bar.follow_requests": "Follow requests",
|
"navigation_bar.follow_requests": "Follow requests",
|
||||||
|
@ -373,6 +378,7 @@
|
||||||
"notification.mention": "{name} mentioned you",
|
"notification.mention": "{name} mentioned you",
|
||||||
"notification.own_poll": "Your poll has ended",
|
"notification.own_poll": "Your poll has ended",
|
||||||
"notification.poll": "A poll you have voted in 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.reblog": "{name} boosted your post",
|
||||||
"notification.status": "{name} just posted",
|
"notification.status": "{name} just posted",
|
||||||
"notifications.clear": "Clear notifications",
|
"notifications.clear": "Clear notifications",
|
||||||
|
@ -387,6 +393,7 @@
|
||||||
"notifications.column_settings.mention": "Mentions:",
|
"notifications.column_settings.mention": "Mentions:",
|
||||||
"notifications.column_settings.poll": "Poll results:",
|
"notifications.column_settings.poll": "Poll results:",
|
||||||
"notifications.column_settings.push": "Push notifications",
|
"notifications.column_settings.push": "Push notifications",
|
||||||
|
"notifications.column_settings.emoji_reaction": "Reactions:",
|
||||||
"notifications.column_settings.reblog": "Boosts:",
|
"notifications.column_settings.reblog": "Boosts:",
|
||||||
"notifications.column_settings.show": "Show in column",
|
"notifications.column_settings.show": "Show in column",
|
||||||
"notifications.column_settings.sound": "Play sound",
|
"notifications.column_settings.sound": "Play sound",
|
||||||
|
@ -398,6 +405,7 @@
|
||||||
"notifications.filter.follows": "Follows",
|
"notifications.filter.follows": "Follows",
|
||||||
"notifications.filter.mentions": "Mentions",
|
"notifications.filter.mentions": "Mentions",
|
||||||
"notifications.filter.polls": "Poll results",
|
"notifications.filter.polls": "Poll results",
|
||||||
|
"notifications.filter.emoji_reactions": "Reactions",
|
||||||
"notifications.filter.statuses": "Updates from people you follow",
|
"notifications.filter.statuses": "Updates from people you follow",
|
||||||
"notifications.grant_permission": "Grant permission.",
|
"notifications.grant_permission": "Grant permission.",
|
||||||
"notifications.group": "{count} notifications",
|
"notifications.group": "{count} notifications",
|
||||||
|
|
|
@ -89,6 +89,7 @@
|
||||||
"column.direct": "ダイレクトメッセージ",
|
"column.direct": "ダイレクトメッセージ",
|
||||||
"column.directory": "ディレクトリ",
|
"column.directory": "ディレクトリ",
|
||||||
"column.domain_blocks": "ブロックしたドメイン",
|
"column.domain_blocks": "ブロックしたドメイン",
|
||||||
|
"column.emoji_reactions": "絵文字リアクション",
|
||||||
"column.favourites": "お気に入り",
|
"column.favourites": "お気に入り",
|
||||||
"column.follow_requests": "フォローリクエスト",
|
"column.follow_requests": "フォローリクエスト",
|
||||||
"column.group": "グループタイムライン",
|
"column.group": "グループタイムライン",
|
||||||
|
@ -194,6 +195,8 @@
|
||||||
"empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
|
"empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
|
||||||
"empty_column.direct": "ダイレクトメッセージはまだありません。ダイレクトメッセージをやりとりすると、ここに表示されます。",
|
"empty_column.direct": "ダイレクトメッセージはまだありません。ダイレクトメッセージをやりとりすると、ここに表示されます。",
|
||||||
"empty_column.domain_blocks": "ブロックしているドメインはありません。",
|
"empty_column.domain_blocks": "ブロックしているドメインはありません。",
|
||||||
|
"empty_column.emoji_reactioned_statuses": "まだ何も絵文字リアクションしていません。絵文字リアクションするとここに表示されます。",
|
||||||
|
"empty_column.emoji_reactions": "まだ誰も絵文字リアクションしていません。絵文字リアクションされるとここに表示されます。",
|
||||||
"empty_column.favourited_statuses": "まだ何もお気に入り登録していません。お気に入り登録するとここに表示されます。",
|
"empty_column.favourited_statuses": "まだ何もお気に入り登録していません。お気に入り登録するとここに表示されます。",
|
||||||
"empty_column.favourites": "まだ誰もお気に入り登録していません。お気に入り登録されるとここに表示されます。",
|
"empty_column.favourites": "まだ誰もお気に入り登録していません。お気に入り登録されるとここに表示されます。",
|
||||||
"empty_column.follow_recommendations": "おすすめを生成できませんでした。検索を使って知り合いを探したり、トレンドハッシュタグを見てみましょう。",
|
"empty_column.follow_recommendations": "おすすめを生成できませんでした。検索を使って知り合いを探したり、トレンドハッシュタグを見てみましょう。",
|
||||||
|
@ -269,6 +272,7 @@
|
||||||
"keyboard_shortcuts.description": "説明",
|
"keyboard_shortcuts.description": "説明",
|
||||||
"keyboard_shortcuts.direct": "ダイレクトメッセージのカラムを開く",
|
"keyboard_shortcuts.direct": "ダイレクトメッセージのカラムを開く",
|
||||||
"keyboard_shortcuts.down": "カラム内一つ下に移動",
|
"keyboard_shortcuts.down": "カラム内一つ下に移動",
|
||||||
|
"keyboard_shortcuts.emoji_reaction": "絵文字リアクションのリストを開く",
|
||||||
"keyboard_shortcuts.enter": "投稿の詳細を表示",
|
"keyboard_shortcuts.enter": "投稿の詳細を表示",
|
||||||
"keyboard_shortcuts.favourite": "お気に入り",
|
"keyboard_shortcuts.favourite": "お気に入り",
|
||||||
"keyboard_shortcuts.favourites": "お気に入り登録のリストを開く",
|
"keyboard_shortcuts.favourites": "お気に入り登録のリストを開く",
|
||||||
|
@ -338,6 +342,7 @@
|
||||||
"navigation_bar.discover": "見つける",
|
"navigation_bar.discover": "見つける",
|
||||||
"navigation_bar.domain_blocks": "ブロックしたドメイン",
|
"navigation_bar.domain_blocks": "ブロックしたドメイン",
|
||||||
"navigation_bar.edit_profile": "プロフィールを編集",
|
"navigation_bar.edit_profile": "プロフィールを編集",
|
||||||
|
"navigation_bar.emoji_reactions": "絵文字リアクション",
|
||||||
"navigation_bar.favourites": "お気に入り",
|
"navigation_bar.favourites": "お気に入り",
|
||||||
"navigation_bar.filters": "フィルター設定",
|
"navigation_bar.filters": "フィルター設定",
|
||||||
"navigation_bar.follow_requests": "フォローリクエスト",
|
"navigation_bar.follow_requests": "フォローリクエスト",
|
||||||
|
@ -374,6 +379,7 @@
|
||||||
"notification.mention": "{name}さんがあなたに返信しました",
|
"notification.mention": "{name}さんがあなたに返信しました",
|
||||||
"notification.own_poll": "アンケートが終了しました",
|
"notification.own_poll": "アンケートが終了しました",
|
||||||
"notification.poll": "アンケートが終了しました",
|
"notification.poll": "アンケートが終了しました",
|
||||||
|
"notification.emoji_reaction": "{name}さんがあなたの投稿にリアクションしました",
|
||||||
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
|
"notification.reblog": "{name}さんがあなたの投稿をブーストしました",
|
||||||
"notification.status": "{name}さんが投稿しました",
|
"notification.status": "{name}さんが投稿しました",
|
||||||
"notifications.clear": "通知を消去",
|
"notifications.clear": "通知を消去",
|
||||||
|
@ -388,6 +394,7 @@
|
||||||
"notifications.column_settings.mention": "返信:",
|
"notifications.column_settings.mention": "返信:",
|
||||||
"notifications.column_settings.poll": "アンケート結果:",
|
"notifications.column_settings.poll": "アンケート結果:",
|
||||||
"notifications.column_settings.push": "プッシュ通知",
|
"notifications.column_settings.push": "プッシュ通知",
|
||||||
|
"notifications.column_settings.emoji_reaction": "リアクション:",
|
||||||
"notifications.column_settings.reblog": "ブースト:",
|
"notifications.column_settings.reblog": "ブースト:",
|
||||||
"notifications.column_settings.show": "カラムに表示",
|
"notifications.column_settings.show": "カラムに表示",
|
||||||
"notifications.column_settings.sound": "通知音を再生",
|
"notifications.column_settings.sound": "通知音を再生",
|
||||||
|
@ -399,6 +406,7 @@
|
||||||
"notifications.filter.follows": "フォロー",
|
"notifications.filter.follows": "フォロー",
|
||||||
"notifications.filter.mentions": "返信",
|
"notifications.filter.mentions": "返信",
|
||||||
"notifications.filter.polls": "アンケート結果",
|
"notifications.filter.polls": "アンケート結果",
|
||||||
|
"notifications.filter.emoji_reactions": "リアクション",
|
||||||
"notifications.filter.statuses": "フォローしている人の新着情報",
|
"notifications.filter.statuses": "フォローしている人の新着情報",
|
||||||
"notifications.grant_permission": "権限の付与",
|
"notifications.grant_permission": "権限の付与",
|
||||||
"notifications.group": "{count} 件の通知",
|
"notifications.group": "{count} 件の通知",
|
||||||
|
|
|
@ -52,6 +52,8 @@ const notificationToMap = notification => ImmutableMap({
|
||||||
account: notification.account.id,
|
account: notification.account.id,
|
||||||
created_at: notification.created_at,
|
created_at: notification.created_at,
|
||||||
status: notification.status ? notification.status.id : null,
|
status: notification.status ? notification.status.id : null,
|
||||||
|
reaction: ImmutableMap(notification.reaction),
|
||||||
|
reblogVisibility: notification.reblog_visibility,
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeNotification = (state, notification, usePendingItems) => {
|
const normalizeNotification = (state, notification, usePendingItems) => {
|
||||||
|
|
|
@ -53,6 +53,7 @@ const initialState = ImmutableMap({
|
||||||
mention: false,
|
mention: false,
|
||||||
poll: false,
|
poll: false,
|
||||||
status: false,
|
status: false,
|
||||||
|
emoji_reaction: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
quickFilter: ImmutableMap({
|
quickFilter: ImmutableMap({
|
||||||
|
@ -72,6 +73,7 @@ const initialState = ImmutableMap({
|
||||||
mention: true,
|
mention: true,
|
||||||
poll: true,
|
poll: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
emoji_reaction: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
sounds: ImmutableMap({
|
sounds: ImmutableMap({
|
||||||
|
@ -82,6 +84,7 @@ const initialState = ImmutableMap({
|
||||||
mention: true,
|
mention: true,
|
||||||
poll: true,
|
poll: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
emoji_reaction: true,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,14 @@ import {
|
||||||
BOOKMARKED_STATUSES_EXPAND_SUCCESS,
|
BOOKMARKED_STATUSES_EXPAND_SUCCESS,
|
||||||
BOOKMARKED_STATUSES_EXPAND_FAIL,
|
BOOKMARKED_STATUSES_EXPAND_FAIL,
|
||||||
} from '../actions/bookmarks';
|
} 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 {
|
import {
|
||||||
PINNED_STATUSES_FETCH_SUCCESS,
|
PINNED_STATUSES_FETCH_SUCCESS,
|
||||||
} from '../actions/pin_statuses';
|
} from '../actions/pin_statuses';
|
||||||
|
@ -23,6 +31,8 @@ import {
|
||||||
UNFAVOURITE_SUCCESS,
|
UNFAVOURITE_SUCCESS,
|
||||||
BOOKMARK_SUCCESS,
|
BOOKMARK_SUCCESS,
|
||||||
UNBOOKMARK_SUCCESS,
|
UNBOOKMARK_SUCCESS,
|
||||||
|
EMOJI_REACTION_SUCCESS,
|
||||||
|
UN_EMOJI_REACTION_SUCCESS,
|
||||||
PIN_SUCCESS,
|
PIN_SUCCESS,
|
||||||
UNPIN_SUCCESS,
|
UNPIN_SUCCESS,
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
|
@ -38,6 +48,11 @@ const initialState = ImmutableMap({
|
||||||
loaded: false,
|
loaded: false,
|
||||||
items: ImmutableList(),
|
items: ImmutableList(),
|
||||||
}),
|
}),
|
||||||
|
emoji_reactions: ImmutableMap({
|
||||||
|
next: null,
|
||||||
|
loaded: false,
|
||||||
|
items: ImmutableList(),
|
||||||
|
}),
|
||||||
pins: ImmutableMap({
|
pins: ImmutableMap({
|
||||||
next: null,
|
next: null,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
@ -96,6 +111,16 @@ export default function statusLists(state = initialState, action) {
|
||||||
return normalizeList(state, 'bookmarks', action.statuses, action.next);
|
return normalizeList(state, 'bookmarks', action.statuses, action.next);
|
||||||
case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
|
case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
|
||||||
return appendToList(state, 'bookmarks', action.statuses, action.next);
|
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:
|
case FAVOURITE_SUCCESS:
|
||||||
return prependOneToList(state, 'favourites', action.status);
|
return prependOneToList(state, 'favourites', action.status);
|
||||||
case UNFAVOURITE_SUCCESS:
|
case UNFAVOURITE_SUCCESS:
|
||||||
|
@ -104,6 +129,10 @@ export default function statusLists(state = initialState, action) {
|
||||||
return prependOneToList(state, 'bookmarks', action.status);
|
return prependOneToList(state, 'bookmarks', action.status);
|
||||||
case UNBOOKMARK_SUCCESS:
|
case UNBOOKMARK_SUCCESS:
|
||||||
return removeOneFromList(state, 'bookmarks', action.status);
|
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:
|
case PINNED_STATUSES_FETCH_SUCCESS:
|
||||||
return normalizeList(state, 'pins', action.statuses, action.next);
|
return normalizeList(state, 'pins', action.statuses, action.next);
|
||||||
case PIN_SUCCESS:
|
case PIN_SUCCESS:
|
||||||
|
|
|
@ -6,6 +6,11 @@ import {
|
||||||
UNFAVOURITE_SUCCESS,
|
UNFAVOURITE_SUCCESS,
|
||||||
BOOKMARK_REQUEST,
|
BOOKMARK_REQUEST,
|
||||||
BOOKMARK_FAIL,
|
BOOKMARK_FAIL,
|
||||||
|
EMOJI_REACTION_REQUEST,
|
||||||
|
EMOJI_REACTION_FAIL,
|
||||||
|
UN_EMOJI_REACTION_REQUEST,
|
||||||
|
UN_EMOJI_REACTION_FAIL,
|
||||||
|
EMOJI_REACTION_UPDATE,
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
import {
|
import {
|
||||||
STATUS_MUTE_SUCCESS,
|
STATUS_MUTE_SUCCESS,
|
||||||
|
@ -37,6 +42,24 @@ const deleteStatus = (state, id, references, quotes) => {
|
||||||
return state.delete(id);
|
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();
|
const initialState = ImmutableMap();
|
||||||
|
|
||||||
export default function statuses(state = initialState, action) {
|
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);
|
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], true);
|
||||||
case BOOKMARK_FAIL:
|
case BOOKMARK_FAIL:
|
||||||
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'bookmarked'], false);
|
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:
|
case REBLOG_REQUEST:
|
||||||
return state.setIn([action.status.get('id'), 'reblogged'], true);
|
return state.setIn([action.status.get('id'), 'reblogged'], true);
|
||||||
case REBLOG_FAIL:
|
case REBLOG_FAIL:
|
||||||
|
|
|
@ -32,6 +32,7 @@ import {
|
||||||
import {
|
import {
|
||||||
REBLOGS_FETCH_SUCCESS,
|
REBLOGS_FETCH_SUCCESS,
|
||||||
FAVOURITES_FETCH_SUCCESS,
|
FAVOURITES_FETCH_SUCCESS,
|
||||||
|
EMOJI_REACTIONS_FETCH_SUCCESS,
|
||||||
MENTIONS_FETCH_SUCCESS,
|
MENTIONS_FETCH_SUCCESS,
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
import {
|
import {
|
||||||
|
@ -80,6 +81,7 @@ const initialState = ImmutableMap({
|
||||||
subscribing: initialListState,
|
subscribing: initialListState,
|
||||||
reblogged_by: initialListState,
|
reblogged_by: initialListState,
|
||||||
favourited_by: initialListState,
|
favourited_by: initialListState,
|
||||||
|
emoji_reactioned_by: initialListState,
|
||||||
mentioned_by: initialListState,
|
mentioned_by: initialListState,
|
||||||
follow_requests: initialListState,
|
follow_requests: initialListState,
|
||||||
blocks: 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)));
|
return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
|
||||||
case FAVOURITES_FETCH_SUCCESS:
|
case FAVOURITES_FETCH_SUCCESS:
|
||||||
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
|
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:
|
case MENTIONS_FETCH_SUCCESS:
|
||||||
return state.setIn(['mentioned_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
|
return state.setIn(['mentioned_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
|
||||||
case NOTIFICATIONS_UPDATE:
|
case NOTIFICATIONS_UPDATE:
|
||||||
|
|
|
@ -369,14 +369,15 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-bar__item {
|
.reactions-bar__item {
|
||||||
&:hover,
|
&:hover:enabled,
|
||||||
&:focus,
|
&:focus:enabled,
|
||||||
&:active {
|
&:active:enabled {
|
||||||
background-color: $ui-base-color;
|
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%);
|
background-color: mix($white, $ui-highlight-color, 80%);
|
||||||
border-color: mix(lighten($ui-base-color, 8%), $ui-highlight-color, 80%);
|
border-color: mix(lighten($ui-base-color, 8%), $ui-highlight-color, 80%);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1284,19 +1284,26 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|
||||||
|
.emoji-picker-dropdown {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__action-bar-button {
|
.status__action-bar-button {
|
||||||
margin-right: 18px;
|
flex: 1 0 auto;
|
||||||
|
|
||||||
&.icon-button--with-counter {
|
.icon-button__counter {
|
||||||
margin-right: 14px;
|
width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status__action-bar-dropdown {
|
.status__action-bar-dropdown {
|
||||||
|
flex: 0 0 auto;
|
||||||
height: 23.15px;
|
height: 23.15px;
|
||||||
width: 23.15px;
|
width: 23.15px;
|
||||||
|
margin-left: 4px
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailed-status__action-bar-dropdown {
|
.detailed-status__action-bar-dropdown {
|
||||||
|
@ -1367,6 +1374,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailed-status__favorites,
|
.detailed-status__favorites,
|
||||||
|
.detailed-status__emoji_reactions,
|
||||||
.detailed-status__reblogs {
|
.detailed-status__reblogs {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
@ -2754,7 +2762,7 @@ a.account__display-name {
|
||||||
|
|
||||||
.column-actions {
|
.column-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
padding-top: 40px;
|
padding-top: 40px;
|
||||||
|
@ -7695,9 +7703,9 @@ noscript {
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover,
|
&:hover:enabled,
|
||||||
&:focus,
|
&:focus:enabled,
|
||||||
&:active {
|
&:active:enabled {
|
||||||
background: lighten($ui-base-color, 16%);
|
background: lighten($ui-base-color, 16%);
|
||||||
transition: all 200ms ease-out;
|
transition: all 200ms ease-out;
|
||||||
transition-property: background-color, color;
|
transition-property: background-color, color;
|
||||||
|
@ -7707,8 +7715,9 @@ noscript {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&:enabled.active,
|
||||||
transition: all 100ms ease-in;
|
&:disabled.active {
|
||||||
|
transition: all 100ms ease-in;
|
||||||
transition-property: background-color, color;
|
transition-property: background-color, color;
|
||||||
background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%);
|
background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%);
|
||||||
|
|
||||||
|
|
|
@ -264,6 +264,7 @@ body.rtl {
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailed-status__favorites,
|
.detailed-status__favorites,
|
||||||
|
.detailed-status__emoji_reactions,
|
||||||
.detailed-status__reblogs {
|
.detailed-status__reblogs {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
|
|
|
@ -56,6 +56,8 @@ class ActivityPub::Activity
|
||||||
ActivityPub::Activity::Remove
|
ActivityPub::Activity::Remove
|
||||||
when 'Move'
|
when 'Move'
|
||||||
ActivityPub::Activity::Move
|
ActivityPub::Activity::Move
|
||||||
|
when 'EmojiReact'
|
||||||
|
ActivityPub::Activity::EmojiReact
|
||||||
end
|
end
|
||||||
end
|
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
|
class ActivityPub::Activity::Like < ActivityPub::Activity
|
||||||
def perform
|
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)
|
lock_or_fail("like:#{object_uri}") do
|
||||||
NotifyService.new.call(original_status.account, :favourite, favourite)
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::Activity::Undo < ActivityPub::Activity
|
class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||||
|
@ -13,6 +14,8 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||||
undo_like
|
undo_like
|
||||||
when 'Block'
|
when 'Block'
|
||||||
undo_block
|
undo_block
|
||||||
|
when 'EmojiReact'
|
||||||
|
undo_react
|
||||||
when nil
|
when nil
|
||||||
handle_reference
|
handle_reference
|
||||||
end
|
end
|
||||||
|
@ -25,7 +28,7 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||||
# global index, we have to guess what object it is.
|
# global index, we have to guess what object it is.
|
||||||
return if object_uri.nil?
|
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
|
end
|
||||||
|
|
||||||
def try_undo_announce
|
def try_undo_announce
|
||||||
|
@ -59,6 +62,10 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def try_undo_react
|
||||||
|
@account.emoji_reactions.find_by(uri: object_uri)&.destroy
|
||||||
|
end
|
||||||
|
|
||||||
def try_undo_block
|
def try_undo_block
|
||||||
block = @account.block_relationships.find_by(uri: object_uri)
|
block = @account.block_relationships.find_by(uri: object_uri)
|
||||||
if block.present?
|
if block.present?
|
||||||
|
@ -105,14 +112,33 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||||
|
|
||||||
return if status.nil? || !status.account.local?
|
return if status.nil? || !status.account.local?
|
||||||
|
|
||||||
if @account.favourited?(status)
|
shortcode = @object['_misskey_reaction']&.delete(':')
|
||||||
favourite = status.favourites.where(account: @account).first
|
|
||||||
favourite&.destroy
|
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
|
else
|
||||||
delete_later!(object_uri)
|
if @account.favourited?(status)
|
||||||
|
status.favourites.where(account: @account).first&.destroy
|
||||||
|
else
|
||||||
|
delete_later!(object_uri)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def undo_react
|
||||||
|
@account.emoji_reactions.find_by(uri: object_uri)&.destroy
|
||||||
|
end
|
||||||
|
|
||||||
def undo_block
|
def undo_block
|
||||||
target_account = account_from_uri(target_uri)
|
target_account = account_from_uri(target_uri)
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,8 @@ class InlineRenderer
|
||||||
serializer = REST::AnnouncementSerializer
|
serializer = REST::AnnouncementSerializer
|
||||||
when :reaction
|
when :reaction
|
||||||
serializer = REST::ReactionSerializer
|
serializer = REST::ReactionSerializer
|
||||||
|
when :emoji_reaction
|
||||||
|
serializer = REST::EmojiReactionSerializer
|
||||||
when :encrypted_message
|
when :encrypted_message
|
||||||
serializer = REST::EncryptedMessageSerializer
|
serializer = REST::EncryptedMessageSerializer
|
||||||
else
|
else
|
||||||
|
|
|
@ -6,6 +6,7 @@ class PotentialFriendshipTracker
|
||||||
|
|
||||||
WEIGHTS = {
|
WEIGHTS = {
|
||||||
reply: 1,
|
reply: 1,
|
||||||
|
emoji_reaction: 1,
|
||||||
favourite: 10,
|
favourite: 10,
|
||||||
reblog: 20,
|
reblog: 20,
|
||||||
}.freeze
|
}.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['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['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_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
|
end
|
||||||
|
|
||||||
def merged_notification_emails
|
def merged_notification_emails
|
||||||
|
@ -197,6 +198,10 @@ class UserSettingsDecorator
|
||||||
boolean_cast_setting 'setting_enable_limited_timeline'
|
boolean_cast_setting 'setting_enable_limited_timeline'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def enable_reaction_preference
|
||||||
|
boolean_cast_setting 'setting_enable_reaction'
|
||||||
|
end
|
||||||
|
|
||||||
def boolean_cast_setting(key)
|
def boolean_cast_setting(key)
|
||||||
ActiveModel::Type::Boolean.new.cast(settings[key])
|
ActiveModel::Type::Boolean.new.cast(settings[key])
|
||||||
end
|
end
|
||||||
|
|
|
@ -66,6 +66,20 @@ class NotificationMailer < ApplicationMailer
|
||||||
end
|
end
|
||||||
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)
|
def digest(recipient, **opts)
|
||||||
return unless recipient.user.functional?
|
return unless recipient.user.functional?
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ module AccountAssociations
|
||||||
has_many :statuses, inverse_of: :account, dependent: :destroy
|
has_many :statuses, inverse_of: :account, dependent: :destroy
|
||||||
has_many :favourites, inverse_of: :account, dependent: :destroy
|
has_many :favourites, inverse_of: :account, dependent: :destroy
|
||||||
has_many :bookmarks, 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 :mentions, inverse_of: :account, dependent: :destroy
|
||||||
has_many :notifications, inverse_of: :account, dependent: :destroy
|
has_many :notifications, inverse_of: :account, dependent: :destroy
|
||||||
has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account
|
has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account
|
||||||
|
|
|
@ -275,6 +275,14 @@ module AccountInteractions
|
||||||
status.proper.favourites.where(account: self).exists?
|
status.proper.favourites.where(account: self).exists?
|
||||||
end
|
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)
|
def bookmarked?(status)
|
||||||
status.proper.bookmarks.where(account: self).exists?
|
status.proper.bookmarks.where(account: self).exists?
|
||||||
end
|
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,
|
'FollowRequest' => :follow_request,
|
||||||
'Favourite' => :favourite,
|
'Favourite' => :favourite,
|
||||||
'Poll' => :poll,
|
'Poll' => :poll,
|
||||||
|
'EmojiReaction' => :emoji_reaction,
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
TYPES = %i(
|
TYPES = %i(
|
||||||
|
@ -35,6 +36,7 @@ class Notification < ApplicationRecord
|
||||||
follow_request
|
follow_request
|
||||||
favourite
|
favourite
|
||||||
poll
|
poll
|
||||||
|
emoji_reaction
|
||||||
).freeze
|
).freeze
|
||||||
|
|
||||||
TARGET_STATUS_INCLUDES_BY_TYPE = {
|
TARGET_STATUS_INCLUDES_BY_TYPE = {
|
||||||
|
@ -43,6 +45,7 @@ class Notification < ApplicationRecord
|
||||||
mention: [mention: :status],
|
mention: [mention: :status],
|
||||||
favourite: [favourite: :status],
|
favourite: [favourite: :status],
|
||||||
poll: [poll: :status],
|
poll: [poll: :status],
|
||||||
|
emoji_reaction: [emoji_reaction: :status],
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
belongs_to :account, optional: true
|
belongs_to :account, optional: true
|
||||||
|
@ -55,6 +58,7 @@ class Notification < ApplicationRecord
|
||||||
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
|
belongs_to :follow_request, foreign_key: 'activity_id', optional: true
|
||||||
belongs_to :favourite, 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 :poll, foreign_key: 'activity_id', optional: true
|
||||||
|
belongs_to :emoji_reaction, foreign_key: 'activity_id', optional: true
|
||||||
|
|
||||||
validates :type, inclusion: { in: TYPES }
|
validates :type, inclusion: { in: TYPES }
|
||||||
validates :activity_id, uniqueness: { scope: [:account_id, :type] }, if: -> { type.to_sym == :status }
|
validates :activity_id, uniqueness: { scope: [:account_id, :type] }, if: -> { type.to_sym == :status }
|
||||||
|
@ -87,9 +91,15 @@ class Notification < ApplicationRecord
|
||||||
mention&.status
|
mention&.status
|
||||||
when :poll
|
when :poll
|
||||||
poll&.status
|
poll&.status
|
||||||
|
when :emoji_reaction
|
||||||
|
emoji_reaction&.status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reblog_visibility
|
||||||
|
type == :reblog ? status.visibility : :public
|
||||||
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def preload_cache_collection_target_statuses(notifications, &_block)
|
def preload_cache_collection_target_statuses(notifications, &_block)
|
||||||
notifications.group_by(&:type).each do |type, grouped_notifications|
|
notifications.group_by(&:type).each do |type, grouped_notifications|
|
||||||
|
@ -121,6 +131,8 @@ class Notification < ApplicationRecord
|
||||||
notification.mention.status = cached_status
|
notification.mention.status = cached_status
|
||||||
when :poll
|
when :poll
|
||||||
notification.poll.status = cached_status
|
notification.poll.status = cached_status
|
||||||
|
when :emoji_reaction
|
||||||
|
notification.emoji_reaction.status = cached_status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -137,7 +149,7 @@ class Notification < ApplicationRecord
|
||||||
return unless new_record?
|
return unless new_record?
|
||||||
|
|
||||||
case activity_type
|
case activity_type
|
||||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll'
|
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'EmojiReaction'
|
||||||
self.from_account_id = activity&.account_id
|
self.from_account_id = activity&.account_id
|
||||||
when 'Mention'
|
when 'Mention'
|
||||||
self.from_account_id = activity&.status&.account_id
|
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 :favourites, inverse_of: :status, dependent: :destroy
|
||||||
has_many :bookmarks, 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 :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 :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
|
||||||
has_many :mentions, dependent: :destroy, inverse_of: :status
|
has_many :mentions, dependent: :destroy, inverse_of: :status
|
||||||
|
@ -124,6 +125,12 @@ class Status < ApplicationRecord
|
||||||
WHERE f.status_id = statuses.id
|
WHERE f.status_id = statuses.id
|
||||||
AND f.account_id = :account_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 IS NOT NULL
|
||||||
AND statuses.expires_at < :current_utc
|
AND statuses.expires_at < :current_utc
|
||||||
)
|
)
|
||||||
|
@ -190,11 +197,13 @@ class Status < ApplicationRecord
|
||||||
ids += favourites.where(account: Account.local).pluck(:account_id)
|
ids += favourites.where(account: Account.local).pluck(:account_id)
|
||||||
ids += reblogs.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 += bookmarks.where(account: Account.local).pluck(:account_id)
|
||||||
|
ids += emoji_reactions.where(account: Account.local).pluck(:account_id)
|
||||||
else
|
else
|
||||||
ids += preloaded.mentions[id] || []
|
ids += preloaded.mentions[id] || []
|
||||||
ids += preloaded.favourites[id] || []
|
ids += preloaded.favourites[id] || []
|
||||||
ids += preloaded.reblogs[id] || []
|
ids += preloaded.reblogs[id] || []
|
||||||
ids += preloaded.bookmarks[id] || []
|
ids += preloaded.bookmarks[id] || []
|
||||||
|
ids += preloaded.emoji_reactions[id] || []
|
||||||
end
|
end
|
||||||
|
|
||||||
ids.uniq
|
ids.uniq
|
||||||
|
@ -311,6 +320,19 @@ class Status < ApplicationRecord
|
||||||
status_stat&.favourites_count || 0
|
status_stat&.favourites_count || 0
|
||||||
end
|
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)
|
def increment_count!(key)
|
||||||
update_status_stat!(key => public_send(key) + 1)
|
update_status_stat!(key => public_send(key) + 1)
|
||||||
end
|
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
|
Bookmark.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
|
||||||
end
|
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)
|
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 }
|
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
|
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_target,
|
||||||
:show_follow_button_on_timeline, :show_subscribe_button_on_timeline, :show_followed_by, :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,
|
: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
|
to: :settings, prefix: :setting, allow_nil: false
|
||||||
|
|
||||||
|
|
|
@ -2,26 +2,28 @@
|
||||||
|
|
||||||
class StatusRelationshipsPresenter
|
class StatusRelationshipsPresenter
|
||||||
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
|
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)
|
def initialize(statuses, current_account_id = nil, **options)
|
||||||
if current_account_id.nil?
|
if current_account_id.nil?
|
||||||
@reblogs_map = {}
|
@reblogs_map = {}
|
||||||
@favourites_map = {}
|
@favourites_map = {}
|
||||||
@bookmarks_map = {}
|
@bookmarks_map = {}
|
||||||
@mutes_map = {}
|
@emoji_reactions_map = {}
|
||||||
@pins_map = {}
|
@mutes_map = {}
|
||||||
|
@pins_map = {}
|
||||||
else
|
else
|
||||||
statuses = statuses.compact
|
statuses = statuses.compact
|
||||||
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
|
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
|
||||||
conversation_ids = statuses.filter_map(&:conversation_id).uniq
|
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) }
|
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] || {})
|
@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] || {})
|
@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] || {})
|
@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] || {})
|
@emoji_reactions_map = Status.emoji_reactions_map(status_ids, current_account_id).merge(options[:emoji_reactions_map] || {})
|
||||||
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_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
|
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[: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[: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_limited_timeline] = object.current_account.user.setting_enable_limited_timeline
|
||||||
|
store[:enable_reaction] = object.current_account.user.setting_enable_reaction
|
||||||
else
|
else
|
||||||
store[:auto_play_gif] = Setting.auto_play_gif
|
store[:auto_play_gif] = Setting.auto_play_gif
|
||||||
store[:display_media] = Setting.display_media
|
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,
|
:timeline_group_directory,
|
||||||
:visibility_mutual,
|
:visibility_mutual,
|
||||||
:visibility_limited,
|
:visibility_limited,
|
||||||
|
:emoji_reaction,
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,12 +5,30 @@ class REST::NotificationSerializer < ActiveModel::Serializer
|
||||||
|
|
||||||
belongs_to :from_account, key: :account, serializer: REST::AccountSerializer
|
belongs_to :from_account, key: :account, serializer: REST::AccountSerializer
|
||||||
belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer
|
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
|
def id
|
||||||
object.id.to_s
|
object.id.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_type?
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,6 +8,7 @@ class REST::ReactionSerializer < ActiveModel::Serializer
|
||||||
attribute :me, if: :current_user?
|
attribute :me, if: :current_user?
|
||||||
attribute :url, if: :custom_emoji?
|
attribute :url, if: :custom_emoji?
|
||||||
attribute :static_url, if: :custom_emoji?
|
attribute :static_url, if: :custom_emoji?
|
||||||
|
attribute :domain, if: :custom_emoji?
|
||||||
|
|
||||||
def count
|
def count
|
||||||
object.respond_to?(:count) ? object.count : 0
|
object.respond_to?(:count) ? object.count : 0
|
||||||
|
@ -28,4 +29,8 @@ class REST::ReactionSerializer < ActiveModel::Serializer
|
||||||
def static_url
|
def static_url
|
||||||
full_asset_url(object.custom_emoji.image.url(:static))
|
full_asset_url(object.custom_emoji.image.url(:static))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def domain
|
||||||
|
object.custom_emoji.domain
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -10,6 +10,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||||
attribute :reblogged, if: :current_user?
|
attribute :reblogged, if: :current_user?
|
||||||
attribute :muted, if: :current_user?
|
attribute :muted, if: :current_user?
|
||||||
attribute :bookmarked, if: :current_user?
|
attribute :bookmarked, if: :current_user?
|
||||||
|
attribute :emoji_reactioned, if: :current_user?
|
||||||
attribute :pinned, if: :pinnable?
|
attribute :pinned, if: :pinnable?
|
||||||
attribute :circle_id, if: :limited_owned_parent_status?
|
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 :ordered_mentions, key: :mentions
|
||||||
has_many :tags
|
has_many :tags
|
||||||
has_many :emojis, serializer: REST::CustomEmojiSerializer
|
has_many :emojis, serializer: REST::CustomEmojiSerializer
|
||||||
|
has_many :emoji_reactions, serializer: REST::EmojiReactionSerializer
|
||||||
|
|
||||||
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
|
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
|
||||||
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
|
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
|
||||||
|
@ -124,6 +126,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def emoji_reactions
|
||||||
|
object.grouped_reactions(current_user&.account)
|
||||||
|
end
|
||||||
|
|
||||||
def reblogged
|
def reblogged
|
||||||
if instance_options && instance_options[:relationships]
|
if instance_options && instance_options[:relationships]
|
||||||
instance_options[:relationships].reblogs_map[object.id] || false
|
instance_options[:relationships].reblogs_map[object.id] || false
|
||||||
|
@ -148,6 +154,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
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
|
def pinned
|
||||||
if instance_options && instance_options[:relationships]
|
if instance_options && instance_options[:relationships]
|
||||||
instance_options[:relationships].pins_map[object.id] || false
|
instance_options[:relationships].pins_map[object.id] || false
|
||||||
|
|
|
@ -150,6 +150,7 @@ class DeleteAccountService < BaseService
|
||||||
purge_generated_notifications!
|
purge_generated_notifications!
|
||||||
purge_favourites!
|
purge_favourites!
|
||||||
purge_bookmarks!
|
purge_bookmarks!
|
||||||
|
purge_reactions!
|
||||||
purge_feeds!
|
purge_feeds!
|
||||||
purge_other_associations!
|
purge_other_associations!
|
||||||
|
|
||||||
|
@ -202,6 +203,13 @@ class DeleteAccountService < BaseService
|
||||||
end
|
end
|
||||||
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!
|
def purge_other_associations!
|
||||||
associations_for_destruction.each do |association_name|
|
associations_for_destruction.each do |association_name|
|
||||||
purge_association(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
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def blocked_emoji_reaction?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def following_sender?
|
def following_sender?
|
||||||
return @following_sender if defined?(@following_sender)
|
return @following_sender if defined?(@following_sender)
|
||||||
@following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)
|
@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 :follow_request, as: :boolean, wrapper: :with_label
|
||||||
= ff.input :reblog, as: :boolean, wrapper: :with_label
|
= ff.input :reblog, as: :boolean, wrapper: :with_label
|
||||||
= ff.input :favourite, 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
|
= ff.input :mention, as: :boolean, wrapper: :with_label
|
||||||
|
|
||||||
- if current_user.staff?
|
- if current_user.staff?
|
||||||
|
|
|
@ -63,6 +63,9 @@
|
||||||
.fields-group
|
.fields-group
|
||||||
= f.input :setting_enable_limited_timeline, as: :boolean, wrapper: :with_label
|
= 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
|
-# .fields-group
|
||||||
-# = f.input :setting_show_target, as: :boolean, wrapper: :with_label
|
-# = 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"
|
one: "1 new notification since your last visit \U0001F418"
|
||||||
other: "%{count} new notifications since your last visit \U0001F418"
|
other: "%{count} new notifications since your last visit \U0001F418"
|
||||||
title: In your absence...
|
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:
|
favourite:
|
||||||
body: 'Your post was favourited by %{name}:'
|
body: 'Your post was favourited by %{name}:'
|
||||||
subject: "%{name} favourited your post"
|
subject: "%{name} favourited your post"
|
||||||
|
|
|
@ -1101,6 +1101,10 @@ ja:
|
||||||
subject:
|
subject:
|
||||||
other: "新しい%{count}件の通知 \U0001F418"
|
other: "新しい%{count}件の通知 \U0001F418"
|
||||||
title: 不在の間に…
|
title: 不在の間に…
|
||||||
|
emoji_reaction:
|
||||||
|
body: "%{name} さんに絵文字リアクションされた、あなたの投稿があります:"
|
||||||
|
subject: "%{name} さんに絵文字リアクションされました"
|
||||||
|
title: 新たな絵文字リアクション
|
||||||
favourite:
|
favourite:
|
||||||
body: "%{name} さんにお気に入り登録された、あなたの投稿があります:"
|
body: "%{name} さんにお気に入り登録された、あなたの投稿があります:"
|
||||||
subject: "%{name} さんにお気に入りに登録されました"
|
subject: "%{name} さんにお気に入りに登録されました"
|
||||||
|
|
|
@ -52,6 +52,7 @@ en:
|
||||||
setting_display_media_hide_all: Always hide media
|
setting_display_media_hide_all: Always hide media
|
||||||
setting_display_media_show_all: Always show 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_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_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_hide_network: Who you follow and who follows you will be hidden on your profile
|
||||||
setting_noindex: Affects your public profile and post pages
|
setting_noindex: Affects your public profile and post pages
|
||||||
|
@ -188,6 +189,7 @@ en:
|
||||||
setting_display_media_hide_all: Hide all
|
setting_display_media_hide_all: Hide all
|
||||||
setting_display_media_show_all: Show all
|
setting_display_media_show_all: Show all
|
||||||
setting_enable_limited_timeline: Enable limited timeline
|
setting_enable_limited_timeline: Enable limited timeline
|
||||||
|
setting_enable_reaction: Enable reaction
|
||||||
setting_expand_spoilers: Always expand posts marked with content warnings
|
setting_expand_spoilers: Always expand posts marked with content warnings
|
||||||
setting_follow_button_to_list_adder: Open list add dialog with follow button
|
setting_follow_button_to_list_adder: Open list add dialog with follow button
|
||||||
setting_hide_network: Hide your social graph
|
setting_hide_network: Hide your social graph
|
||||||
|
@ -275,11 +277,13 @@ en:
|
||||||
timeline: Timeline
|
timeline: Timeline
|
||||||
notification_emails:
|
notification_emails:
|
||||||
digest: Send digest e-mails
|
digest: Send digest e-mails
|
||||||
|
emoji_reaction: Someone emoji reactioned you
|
||||||
favourite: Someone favourited your post
|
favourite: Someone favourited your post
|
||||||
follow: Someone followed you
|
follow: Someone followed you
|
||||||
follow_request: Someone requested to follow you
|
follow_request: Someone requested to follow you
|
||||||
mention: Someone mentioned you
|
mention: Someone mentioned you
|
||||||
pending_account: New account needs review
|
pending_account: New account needs review
|
||||||
|
reaction: Someone reactioned your post
|
||||||
reblog: Someone boosted your post
|
reblog: Someone boosted your post
|
||||||
report: New report is submitted
|
report: New report is submitted
|
||||||
trending_tag: An unreviewed hashtag is trending
|
trending_tag: An unreviewed hashtag is trending
|
||||||
|
|
|
@ -52,6 +52,7 @@ ja:
|
||||||
setting_display_media_hide_all: メディアを常に隠す
|
setting_display_media_hide_all: メディアを常に隠す
|
||||||
setting_display_media_show_all: メディアを常に表示する
|
setting_display_media_show_all: メディアを常に表示する
|
||||||
setting_enable_limited_timeline: フォロワー限定・サークル・ダイレクトメッセージを表示する限定ホームを有効にします
|
setting_enable_limited_timeline: フォロワー限定・サークル・ダイレクトメッセージを表示する限定ホームを有効にします
|
||||||
|
setting_enable_reaction: タイムラインでリアクションの表示を有効にし、リアクションボタンを表示する
|
||||||
setting_follow_button_to_list_adder: フォロー・購読ボタンの動作を変更し、フォロー・購読するリストを選択したり、ホームで受け取らないよう設定するダイアログを開きます
|
setting_follow_button_to_list_adder: フォロー・購読ボタンの動作を変更し、フォロー・購読するリストを選択したり、ホームで受け取らないよう設定するダイアログを開きます
|
||||||
setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします
|
setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします
|
||||||
setting_noindex: 公開プロフィールおよび各投稿ページに影響します
|
setting_noindex: 公開プロフィールおよび各投稿ページに影響します
|
||||||
|
@ -188,6 +189,7 @@ ja:
|
||||||
setting_display_media_hide_all: 非表示
|
setting_display_media_hide_all: 非表示
|
||||||
setting_display_media_show_all: 表示
|
setting_display_media_show_all: 表示
|
||||||
setting_enable_limited_timeline: 限定ホームを有効にする
|
setting_enable_limited_timeline: 限定ホームを有効にする
|
||||||
|
setting_enable_reaction: リアクションを有効にする
|
||||||
setting_expand_spoilers: 閲覧注意としてマークされた投稿を常に展開する
|
setting_expand_spoilers: 閲覧注意としてマークされた投稿を常に展開する
|
||||||
setting_follow_button_to_list_adder: フォローボタンでリスト追加ダイアログを開く
|
setting_follow_button_to_list_adder: フォローボタンでリスト追加ダイアログを開く
|
||||||
setting_hide_network: 繋がりを隠す
|
setting_hide_network: 繋がりを隠す
|
||||||
|
@ -279,11 +281,13 @@ ja:
|
||||||
timeline: タイムライン
|
timeline: タイムライン
|
||||||
notification_emails:
|
notification_emails:
|
||||||
digest: タイムラインからピックアップしてメールで通知する
|
digest: タイムラインからピックアップしてメールで通知する
|
||||||
|
emoji_reaction: 絵文字リアクションされた時
|
||||||
favourite: お気に入り登録された時
|
favourite: お気に入り登録された時
|
||||||
follow: フォローされた時
|
follow: フォローされた時
|
||||||
follow_request: フォローリクエストを受けた時
|
follow_request: フォローリクエストを受けた時
|
||||||
mention: 返信が来た時
|
mention: 返信が来た時
|
||||||
pending_account: 新しいアカウントの承認が必要な時
|
pending_account: 新しいアカウントの承認が必要な時
|
||||||
|
reaction: リアクションされた時
|
||||||
reblog: 投稿がブーストされた時
|
reblog: 投稿がブーストされた時
|
||||||
report: 通報を受けた時
|
report: 通報を受けた時
|
||||||
trending_tag: 未審査のハッシュタグが人気の時
|
trending_tag: 未審査のハッシュタグが人気の時
|
||||||
|
|
|
@ -333,6 +333,7 @@ Rails.application.routes.draw do
|
||||||
scope module: :statuses do
|
scope module: :statuses do
|
||||||
resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
|
resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
|
||||||
resources :favourited_by, controller: :favourited_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
|
resources :mentioned_by, controller: :mentioned_by_accounts, only: :index
|
||||||
resource :reblog, only: :create
|
resource :reblog, only: :create
|
||||||
post :unreblog, to: 'reblogs#destroy'
|
post :unreblog, to: 'reblogs#destroy'
|
||||||
|
@ -348,6 +349,9 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
resource :pin, only: :create
|
resource :pin, only: :create
|
||||||
post :unpin, to: 'pins#destroy'
|
post :unpin, to: 'pins#destroy'
|
||||||
|
|
||||||
|
resources :emoji_reactions, only: :update, constraints: { id: /[^\/]+/ }
|
||||||
|
post :emoji_unreaction, to: 'emoji_reactions#destroy'
|
||||||
end
|
end
|
||||||
|
|
||||||
member do
|
member do
|
||||||
|
@ -402,16 +406,17 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :media, only: [:create, :update, :show]
|
resources :media, only: [:create, :update, :show]
|
||||||
resources :blocks, only: [:index]
|
resources :blocks, only: [:index]
|
||||||
resources :mutes, only: [:index]
|
resources :mutes, only: [:index]
|
||||||
resources :favourites, only: [:index]
|
resources :favourites, only: [:index]
|
||||||
resources :bookmarks, only: [:index]
|
resources :bookmarks, only: [:index]
|
||||||
resources :reports, only: [:create]
|
resources :emoji_reactions, only: [:index]
|
||||||
resources :trends, only: [:index]
|
resources :reports, only: [:create]
|
||||||
resources :filters, only: [:index, :create, :show, :update, :destroy]
|
resources :trends, only: [:index]
|
||||||
resources :endorsements, only: [:index]
|
resources :filters, only: [:index, :create, :show, :update, :destroy]
|
||||||
resources :markers, only: [:index, :create]
|
resources :endorsements, only: [:index]
|
||||||
|
resources :markers, only: [:index, :create]
|
||||||
|
|
||||||
namespace :apps do
|
namespace :apps do
|
||||||
get :verify_credentials, to: 'credentials#show'
|
get :verify_credentials, to: 'credentials#show'
|
||||||
|
|
|
@ -50,10 +50,12 @@ defaults: &defaults
|
||||||
place_tab_bar_at_bottom: false
|
place_tab_bar_at_bottom: false
|
||||||
show_tab_bar_label: false
|
show_tab_bar_label: false
|
||||||
enable_limited_timeline: false
|
enable_limited_timeline: false
|
||||||
|
enable_reaction: true
|
||||||
notification_emails:
|
notification_emails:
|
||||||
follow: false
|
follow: false
|
||||||
reblog: false
|
reblog: false
|
||||||
favourite: false
|
favourite: false
|
||||||
|
emoji_reaction: false
|
||||||
mention: false
|
mention: false
|
||||||
follow_request: true
|
follow_request: true
|
||||||
digest: 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
|
t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true
|
||||||
end
|
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|
|
create_table "encrypted_messages", id: :bigint, default: -> { "timestamp_id('encrypted_messages'::text)" }, force: :cascade do |t|
|
||||||
t.bigint "device_id"
|
t.bigint "device_id"
|
||||||
t.bigint "from_account_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 "domain_subscribes", "lists", on_delete: :cascade
|
||||||
add_foreign_key "domains", "accounts", column: "contact_account_id"
|
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 "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", "accounts", column: "from_account_id", on_delete: :cascade
|
||||||
add_foreign_key "encrypted_messages", "devices", on_delete: :cascade
|
add_foreign_key "encrypted_messages", "devices", on_delete: :cascade
|
||||||
add_foreign_key "favourite_domains", "accounts", on_delete: :cascade
|
add_foreign_key "favourite_domains", "accounts", on_delete: :cascade
|
||||||
|
|
Loading…
Reference in a new issue