Merge pull request #135 from Y-zu-don-maintenance-org/features/emoji
Features/emoji
This commit is contained in:
commit
5b1eb8f8d7
@ -1,8 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::BaseController < ApplicationController
|
||||
DEFAULT_STATUSES_LIMIT = 20
|
||||
DEFAULT_ACCOUNTS_LIMIT = 40
|
||||
DEFAULT_STATUSES_LIMIT = 20
|
||||
DEFAULT_ACCOUNTS_LIMIT = 40
|
||||
|
||||
include Api::RateLimitHeaders
|
||||
include Api::AccessTokenTrackingConcern
|
||||
|
@ -0,0 +1,71 @@
|
||||
# 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::EmojiReactionAccountSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_accounts
|
||||
scope = default_accounts
|
||||
# scope = scope.where.not(account_id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
|
||||
scope.merge(paginated_emoji_reactions).to_a
|
||||
end
|
||||
|
||||
def default_accounts
|
||||
EmojiReaction
|
||||
.where(status_id: @status.id)
|
||||
#.where(account: { suspended_at: nil })
|
||||
end
|
||||
|
||||
def paginated_emoji_reactions
|
||||
EmojiReaction.paginate_by_max_id(
|
||||
limit_param(1000), #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
|
||||
api_v1_status_emoji_reactioned_by_index_url pagination_params(max_id: pagination_max_id) if records_continue?
|
||||
end
|
||||
|
||||
def prev_path
|
||||
api_v1_status_emoji_reactioned_by_index_url pagination_params(since_id: pagination_since_id) unless @accounts.empty?
|
||||
end
|
||||
|
||||
def pagination_max_id
|
||||
@accounts.last.id
|
||||
end
|
||||
|
||||
def pagination_since_id
|
||||
@accounts.first.id
|
||||
end
|
||||
|
||||
def records_continue?
|
||||
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
|
||||
end
|
||||
|
||||
def set_status
|
||||
@status = Status.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,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Statuses::EmojiReactionedByAccountsSlimController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:accounts' }
|
||||
before_action :set_status
|
||||
|
||||
def index
|
||||
@accounts = load_emoji_reactions
|
||||
|
||||
# TODO for serialize hash object
|
||||
render json: @accounts, each_serializer: REST::EmojiReactedBySlimSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_emoji_reactions
|
||||
@status.generate_emoji_reactions_grouped_by_account
|
||||
end
|
||||
|
||||
def set_status
|
||||
@status = Status.find(params[:status_id])
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Statuses::EmojiReactionsController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:emoji_reactions' }
|
||||
before_action :require_user!
|
||||
before_action :set_status, only: %i(create update destroy)
|
||||
before_action :set_status_without_authorize, only: [:destroy]
|
||||
|
||||
def create
|
||||
create_private(params[:emoji])
|
||||
end
|
||||
|
||||
# For compatible with Fedibird API
|
||||
def update
|
||||
create_private(params[:id])
|
||||
end
|
||||
|
||||
def destroy
|
||||
emoji = params[:emoji] || params[:id]
|
||||
|
||||
if emoji
|
||||
shortcode, domain = emoji.split('@')
|
||||
emoji_reaction = EmojiReaction.where(account_id: current_account.id).where(status_id: @status.id).where(name: shortcode)
|
||||
.find { |reaction| domain == '' ? reaction.custom_emoji.nil? : reaction.custom_emoji&.domain == domain }
|
||||
|
||||
authorize @status, :show? if emoji_reaction.nil?
|
||||
end
|
||||
|
||||
UnEmojiReactWorker.perform_async(current_account.id, @status.id, emoji)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(
|
||||
[@status], current_account.id, emoji_reactions_map: { @status.id => false }
|
||||
)
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_private(emoji)
|
||||
count = EmojiReaction.where(account: current_account, status: @status).count
|
||||
|
||||
if count >= EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT
|
||||
bad_request
|
||||
return
|
||||
end
|
||||
|
||||
EmojiReactService.new.call(current_account, @status, emoji)
|
||||
render json: @status, serializer: REST::StatusSerializer
|
||||
end
|
||||
|
||||
def set_status
|
||||
set_status_without_authorize
|
||||
authorize @status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def set_status_without_authorize
|
||||
@status = Status.find(params[:status_id])
|
||||
end
|
||||
end
|
@ -40,6 +40,10 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||
normalStatus.filtered = status.filtered.map(normalizeFilterResult);
|
||||
}
|
||||
|
||||
if (status.emoji_reactions) {
|
||||
normalStatus.emojiReactions = status.emoji_reactions;
|
||||
}
|
||||
|
||||
// Only calculate these values when status first encountered and
|
||||
// when the underlying values change. Otherwise keep the ones
|
||||
// already in the reducer
|
||||
|
@ -15,6 +15,10 @@ export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
|
||||
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
|
||||
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
|
||||
|
||||
export const EMOJIREACT_REQUEST = 'EMOJIREACT_REQUEST';
|
||||
export const EMOJIREACT_SUCCESS = 'EMOJIREACT_SUCCESS';
|
||||
export const EMOJIREACT_FAIL = 'EMOJIREACT_FAIL';
|
||||
|
||||
export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
|
||||
export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
|
||||
export const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
|
||||
@ -23,6 +27,10 @@ export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
|
||||
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
|
||||
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
|
||||
|
||||
export const UNEMOJIREACT_REQUEST = 'UNEMOJIREACT_REQUEST';
|
||||
export const UNEMOJIREACT_SUCCESS = 'UNEMOJIREACT_SUCCESS';
|
||||
export const UNEMOJIREACT_FAIL = 'UNEMOJIREACT_FAIL';
|
||||
|
||||
export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
|
||||
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
|
||||
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
|
||||
@ -33,7 +41,11 @@ export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
|
||||
|
||||
export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST';
|
||||
export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
|
||||
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
|
||||
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_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 PIN_REQUEST = 'PIN_REQUEST';
|
||||
export const PIN_SUCCESS = 'PIN_SUCCESS';
|
||||
@ -205,6 +217,88 @@ export function unfavouriteFail(status, error) {
|
||||
};
|
||||
}
|
||||
|
||||
export function emojiReact(status, emoji) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(emojiReactRequest(status, emoji));
|
||||
|
||||
const api_emoji = typeof emoji !== 'string' ? (emoji.custom ? (emoji.name + (emoji.domain || '')) : emoji.native) : emoji;
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_reactions`, { emoji: api_emoji }).then(function () {
|
||||
dispatch(emojiReactSuccess(status, emoji));
|
||||
}).catch(function (error) {
|
||||
dispatch(emojiReactFail(status, emoji, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function unEmojiReact(status, emoji) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(unEmojiReactRequest(status, emoji));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_unreaction`, { emoji }).then(() => {
|
||||
dispatch(unEmojiReactSuccess(status, emoji));
|
||||
}).catch(error => {
|
||||
dispatch(unEmojiReactFail(status, emoji, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function emojiReactRequest(status, emoji) {
|
||||
return {
|
||||
type: EMOJIREACT_REQUEST,
|
||||
status: status,
|
||||
emoji: emoji,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function emojiReactSuccess(status, emoji) {
|
||||
return {
|
||||
type: EMOJIREACT_SUCCESS,
|
||||
status: status,
|
||||
emoji: emoji,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function emojiReactFail(status, emoji, error) {
|
||||
return {
|
||||
type: EMOJIREACT_FAIL,
|
||||
status: status,
|
||||
emoji: emoji,
|
||||
error: error,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function unEmojiReactRequest(status, emoji) {
|
||||
return {
|
||||
type: UNEMOJIREACT_REQUEST,
|
||||
status: status,
|
||||
emoji: emoji,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function unEmojiReactSuccess(status, emoji) {
|
||||
return {
|
||||
type: UNEMOJIREACT_SUCCESS,
|
||||
status: status,
|
||||
emoji: emoji,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function unEmojiReactFail(status, emoji, error) {
|
||||
return {
|
||||
type: UNEMOJIREACT_FAIL,
|
||||
status: status,
|
||||
emoji: emoji,
|
||||
error: error,
|
||||
skipLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function bookmark(status) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(bookmarkRequest(status));
|
||||
@ -441,6 +535,85 @@ export function expandFavouritesFail(id, error) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchEmojiReactions(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchEmojiReactionsRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/emoji_reactioned_by`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(importFetchedAccounts(response.data.map((er) => er.account)));
|
||||
dispatch(fetchEmojiReactionsSuccess(id, response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchEmojiReactionsFail(id, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchEmojiReactionsRequest(id) {
|
||||
return {
|
||||
type: EMOJI_REACTIONS_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchEmojiReactionsSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: EMOJI_REACTIONS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchEmojiReactionsFail(id, error) {
|
||||
return {
|
||||
type: EMOJI_REACTIONS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandEmojiReactions(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'emoji_reactioned_by', id, 'next']);
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandEmojiReactionsRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedAccounts(response.data.map((er) => er.account)));
|
||||
dispatch(expandEmojiReactionsSuccess(id, response.data, next ? next.uri : null));
|
||||
}).catch(error => dispatch(expandEmojiReactionsFail(id, error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function expandEmojiReactionsRequest(id) {
|
||||
return {
|
||||
type: EMOJI_REACTIONS_EXPAND_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandEmojiReactionsSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: EMOJI_REACTIONS_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandEmojiReactionsFail(id, error) {
|
||||
return {
|
||||
type: EMOJI_REACTIONS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function pin(status) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(pinRequest(status));
|
||||
|
@ -21,6 +21,7 @@ import { submitMarkers } from './markers';
|
||||
import { notificationsUpdate } from "./notifications_typed";
|
||||
import { register as registerPushNotifications } from './push_notifications';
|
||||
import { saveSettings } from './settings';
|
||||
import { STATUS_EMOJI_REACTION_UPDATE } from './statuses';
|
||||
|
||||
export * from "./notifications_typed";
|
||||
|
||||
@ -61,6 +62,15 @@ export const loadPending = () => ({
|
||||
type: NOTIFICATIONS_LOAD_PENDING,
|
||||
});
|
||||
|
||||
export function updateEmojiReactions(emoji_reaction, accountId) {
|
||||
return (dispatch) =>
|
||||
dispatch({
|
||||
type: STATUS_EMOJI_REACTION_UPDATE,
|
||||
emoji_reaction,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
return (dispatch, getState) => {
|
||||
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
|
||||
@ -130,10 +140,13 @@ const excludeTypesFromFilter = filter => {
|
||||
'follow',
|
||||
'follow_request',
|
||||
'favourite',
|
||||
'emoji_reaction',
|
||||
'reblog',
|
||||
'status_reference',
|
||||
'mention',
|
||||
'poll',
|
||||
'status',
|
||||
'list_status',
|
||||
'update',
|
||||
'admin.sign_up',
|
||||
'admin.report',
|
||||
|
@ -42,6 +42,8 @@ export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
|
||||
export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
|
||||
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
|
||||
|
||||
export const STATUS_EMOJI_REACTION_UPDATE = 'STATUS_EMOJI_REACTION_UPDATE';
|
||||
|
||||
export function fetchStatusRequest(id, skipLoading) {
|
||||
return {
|
||||
type: STATUS_FETCH_REQUEST,
|
||||
@ -372,4 +374,9 @@ export function revealQuote(ids) {
|
||||
type: QUOTE_REVEAL,
|
||||
ids,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const updateEmojiReaction = (emoji_reaction) => ({
|
||||
type: STATUS_EMOJI_REACTION_UPDATE,
|
||||
emoji_reaction,
|
||||
});
|
@ -10,7 +10,7 @@ import {
|
||||
deleteAnnouncement,
|
||||
} from './announcements';
|
||||
import { updateConversations } from './conversations';
|
||||
import { updateNotifications, expandNotifications } from './notifications';
|
||||
import { updateNotifications, expandNotifications, updateEmojiReactions } from './notifications';
|
||||
import { updateStatus } from './statuses';
|
||||
import {
|
||||
updateTimeline,
|
||||
@ -24,6 +24,8 @@ import {
|
||||
fillListTimelineGaps,
|
||||
} from './timelines';
|
||||
|
||||
const { messages } = getLocale();
|
||||
|
||||
/**
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
@ -102,6 +104,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
// @ts-expect-error
|
||||
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
|
||||
break;
|
||||
case 'emoji_reaction':
|
||||
// @ts-expect-error
|
||||
dispatch(updateEmojiReactions(JSON.parse(data.payload), getState().getIn(['meta', 'me'])));
|
||||
break;
|
||||
case 'conversation':
|
||||
// @ts-expect-error
|
||||
dispatch(updateConversations(JSON.parse(data.payload)));
|
||||
|
@ -46,6 +46,8 @@ class Account extends ImmutablePureComponent {
|
||||
minimal: PropTypes.bool,
|
||||
defaultAction: PropTypes.string,
|
||||
withBio: PropTypes.bool,
|
||||
onActionClick: PropTypes.func,
|
||||
children: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -73,7 +75,7 @@ class Account extends ImmutablePureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account, intl, hidden, withBio, defaultAction, size, minimal } = this.props;
|
||||
const { account, intl, hidden, onActionClick, actionIcon, actionTitle, withBio, defaultAction, size, minimal, children } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <EmptyAccount size={size} minimal={minimal} />;
|
||||
@ -156,11 +158,13 @@ class Account extends ImmutablePureComponent {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{withBio && (account.get('note').length > 0 ? (
|
||||
|
33
app/javascript/mastodon/components/emoji_view.jsx
Normal file
33
app/javascript/mastodon/components/emoji_view.jsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl } from 'react-intl';
|
||||
import emojify from '../features/emoji/emoji';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class EmojiView extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
name: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
staticUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { name, url, staticUrl } = this.props;
|
||||
|
||||
let emojiHtml = null;
|
||||
if (url) {
|
||||
let customEmojis = {};
|
||||
customEmojis[`:${name}:`] = { url, static_url: staticUrl };
|
||||
emojiHtml = emojify(`:${name}:`, customEmojis);
|
||||
} else {
|
||||
emojiHtml = emojify(name);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='emoji' dangerouslySetInnerHTML={{ __html: emojiHtml }} />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import StatusEmojiReactionsBar from './status_emoji_reactions_bar';
|
||||
import AttachmentList from './attachment_list';
|
||||
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@ -163,6 +166,8 @@ class Status extends ImmutablePureComponent {
|
||||
onClick: PropTypes.func,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onEmojiReact: PropTypes.func,
|
||||
onUnEmojiReact: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
@ -198,6 +203,7 @@ class Status extends ImmutablePureComponent {
|
||||
}),
|
||||
contextType: PropTypes.string,
|
||||
...WithOptionalRouterPropTypes,
|
||||
withoutEmojiReactions: PropTypes.bool,
|
||||
};
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
@ -677,6 +683,12 @@ class Status extends ImmutablePureComponent {
|
||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
|
||||
|
||||
let emojiReactionsBar = null;
|
||||
if (!this.props.withoutEmojiReactions && status.get('emoji_reactions')) {
|
||||
const emojiReactions = status.get('emoji_reactions');
|
||||
emojiReactionsBar = <StatusEmojiReactionsBar emojiReactions={emojiReactions} status={status} onEmojiReact={this.props.onEmojiReact} onUnEmojiReact={this.props.onUnEmojiReact} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||
@ -720,6 +732,8 @@ class Status extends ImmutablePureComponent {
|
||||
|
||||
{expanded && hashtagBar}
|
||||
|
||||
{emojiReactionsBar}
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@ -51,6 +52,7 @@ const messages = defineMessages({
|
||||
quote: { id: 'status.quote', defaultMessage: 'Quote' },
|
||||
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||
emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Emoji Reaction' },
|
||||
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
|
||||
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
@ -88,6 +90,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
relationship: ImmutablePropTypes.map,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onEmojiReact: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onQuote: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
@ -150,6 +153,16 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
handleEmojiPick = (data) => {
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
if (signedIn) {
|
||||
this.props.onEmojiReact(this.props.status, data);
|
||||
} else {
|
||||
this.props.onInteractionModal('favourite', this.props.status);
|
||||
}
|
||||
};
|
||||
|
||||
handleReblogClick = e => {
|
||||
const { signedIn } = this.context.identity;
|
||||
|
||||
@ -403,6 +416,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
||||
|
||||
const emojiPickerButton = (
|
||||
<IconButton className='status__action-bar__button' title={intl.formatMessage(messages.emojiReaction)} icon='smile-o' />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
|
||||
@ -410,6 +427,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
<IconButton className='status__action-bar__button' disabled={!publicStatus} title={StatusActionBar.quoteTitle(intl, messages, publicStatus)} icon='format-quote' iconComponent={FormatQuoteIcon} onClick={this.handleQuoteClick} />
|
||||
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
|
||||
|
||||
{filterButton}
|
||||
|
||||
|
@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl } from 'react-intl';
|
||||
import emojify from '../features/emoji/emoji';
|
||||
import classNames from 'classnames';
|
||||
import EmojiView from './emoji_view';
|
||||
|
||||
class EmojiReactionButton extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
name: PropTypes.string,
|
||||
domain: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
staticUrl: PropTypes.string,
|
||||
count: PropTypes.number.isRequired,
|
||||
me: PropTypes.bool,
|
||||
onEmojiReact: PropTypes.func,
|
||||
onUnEmojiReact: PropTypes.func,
|
||||
};
|
||||
|
||||
onClick = () => {
|
||||
const { name, domain, me } = this.props;
|
||||
|
||||
const nameParameter = domain ? `${name}@${domain}` : name;
|
||||
if (me) {
|
||||
if (this.props.onUnEmojiReact) this.props.onUnEmojiReact(nameParameter);
|
||||
} else {
|
||||
if (this.props.onEmojiReact) this.props.onEmojiReact(nameParameter);
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { name, url, staticUrl, count, me } = this.props;
|
||||
|
||||
const classList = {
|
||||
'reactions-bar__item': true,
|
||||
'active': me,
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={classNames(classList)} type='button' onClick={this.onClick}>
|
||||
<span className='reactions-bar__item__emoji'>
|
||||
<EmojiView name={name} url={url} staticUrl={staticUrl} />
|
||||
</span>
|
||||
<span className='reactions-bar__item__count'>{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class StatusEmojiReactionsBar extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
emojiReactions: ImmutablePropTypes.list.isRequired,
|
||||
status: ImmutablePropTypes.map,
|
||||
onEmojiReact: PropTypes.func,
|
||||
onUnEmojiReact: PropTypes.func,
|
||||
};
|
||||
|
||||
onEmojiReact = (name) => {
|
||||
if (!this.props.onEmojiReact) return;
|
||||
this.props.onEmojiReact(this.props.status, name);
|
||||
};
|
||||
|
||||
onUnEmojiReact = (name) => {
|
||||
if (!this.props.onUnEmojiReact) return;
|
||||
this.props.onUnEmojiReact(this.props.status, name);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { emojiReactions } = this.props;
|
||||
|
||||
const emojiButtons = Array.from(emojiReactions).map((emoji, index) => (
|
||||
<EmojiReactionButton
|
||||
key={index}
|
||||
name={emoji.get('name')}
|
||||
count={emoji.get('count')}
|
||||
me={emoji.get('me')}
|
||||
url={emoji.get('url')}
|
||||
staticUrl={emoji.get('static_url')}
|
||||
domain={emoji.get('domain')}
|
||||
onEmojiReact={this.onEmojiReact}
|
||||
onUnEmojiReact={this.onUnEmojiReact}
|
||||
/>));
|
||||
|
||||
return (
|
||||
<div className='status__emoji-reactions-bar'>
|
||||
{emojiButtons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export default injectIntl(StatusEmojiReactionsBar);
|
@ -25,9 +25,11 @@ import {
|
||||
import {
|
||||
reblog,
|
||||
favourite,
|
||||
emojiReact,
|
||||
bookmark,
|
||||
unreblog,
|
||||
unfavourite,
|
||||
unEmojiReact,
|
||||
unbookmark,
|
||||
pin,
|
||||
unpin,
|
||||
@ -140,6 +142,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
|
||||
}
|
||||
},
|
||||
|
||||
onEmojiReact (status, emoji) {
|
||||
dispatch(emojiReact(status, emoji));
|
||||
},
|
||||
|
||||
onUnEmojiReact (status, emoji) {
|
||||
dispatch(unEmojiReact(status, emoji));
|
||||
},
|
||||
|
||||
onBookmark (status) {
|
||||
if (status.get('bookmarked')) {
|
||||
dispatch(unbookmark(status));
|
||||
|
@ -330,6 +330,7 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
state = {
|
||||
active: false,
|
||||
loading: false,
|
||||
bottom: true,
|
||||
};
|
||||
|
||||
setRef = (c) => {
|
||||
@ -337,6 +338,7 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
};
|
||||
|
||||
onShowDropdown = () => {
|
||||
this.updateDropdownPosition();
|
||||
this.setState({ active: true });
|
||||
|
||||
if (!EmojiPicker) {
|
||||
@ -357,6 +359,23 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
this.setState({ active: false });
|
||||
};
|
||||
|
||||
updateDropdownPosition = () => {
|
||||
let bottom = true;
|
||||
|
||||
if (this.target) {
|
||||
const height = window.innerHeight;
|
||||
const rect = this.target.getBoundingClientRect();
|
||||
|
||||
if (height && rect) {
|
||||
bottom = height / 2 > rect.top;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.bottom !== bottom) {
|
||||
this.setState({ bottom });
|
||||
}
|
||||
};
|
||||
|
||||
onToggle = (e) => {
|
||||
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
|
||||
if (this.state.active) {
|
||||
@ -384,7 +403,7 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
render () {
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { active, loading } = this.state;
|
||||
const { active, loading, bottom } = this.state;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}>
|
||||
@ -397,7 +416,7 @@ class EmojiPickerDropdown extends PureComponent {
|
||||
inverted
|
||||
/>
|
||||
|
||||
<Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
||||
<Overlay show={active} placement={ bottom ? 'bottom' : 'top' } target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props, placement })=> (
|
||||
<div {...props} style={{ ...props.style }}>
|
||||
<div className={`dropdown-animation ${placement}`}>
|
||||
|
121
app/javascript/mastodon/features/emoji_reactions/index.jsx
Normal file
121
app/javascript/mastodon/features/emoji_reactions/index.jsx
Normal file
@ -0,0 +1,121 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { ReactComponent as RefreshIcon } from '@material-symbols/svg-600/outlined/refresh.svg';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { fetchEmojiReactions, expandEmojiReactions } from 'mastodon/actions/interactions';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import AccountContainer from 'mastodon/containers/account_container';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
|
||||
|
||||
|
||||
import EmojiView from '../../components/emoji_view';
|
||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||
|
||||
const messages = defineMessages({
|
||||
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
return {
|
||||
accountIds: state.getIn(['user_lists', 'emoji_reactioned_by', props.params.statusId, 'items']),
|
||||
hasMore: !!state.getIn(['user_lists', 'emoji_reactioned_by', props.params.statusId, 'next']),
|
||||
isLoading: state.getIn(['user_lists', 'emoji_reactioned_by', props.params.statusId, 'isLoading'], true),
|
||||
};
|
||||
};
|
||||
|
||||
class EmojiReactions extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
if (!this.props.accountIds) {
|
||||
this.props.dispatch(fetchEmojiReactions(this.props.params.statusId));
|
||||
}
|
||||
}
|
||||
|
||||
handleRefresh = () => {
|
||||
this.props.dispatch(fetchEmojiReactions(this.props.params.statusId));
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandEmojiReactions(this.props.params.statusId));
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
let groups = {};
|
||||
for (const emoji_reaction of accountIds) {
|
||||
const key = emoji_reaction.account_id;
|
||||
const value = emoji_reaction;
|
||||
if (!groups[key]) groups[key] = [value];
|
||||
else groups[key].push(value);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.emoji_reactions' defaultMessage='No one has reacted with emoji this post yet. When someone does, they will show up here.' />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnHeader
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
extraButton={(
|
||||
<button type='button' className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' icon={RefreshIcon} /></button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='emoji_reactions'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{Object.keys(groups).map((key) =>(
|
||||
<AccountContainer key={key} id={key} withNote={false} hideButtons>
|
||||
<div style={{ 'maxWidth': '100px' }}>
|
||||
{groups[key].map((value, index2) => <EmojiView key={index2} name={value.name} url={value.url} staticUrl={value.static_url} />)}
|
||||
</div>
|
||||
</AccountContainer>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(EmojiReactions));
|
@ -119,6 +119,17 @@ export default class ColumnSettings extends PureComponent {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role='group' aria-labelledby='notifications-emoji_reaction'>
|
||||
<span id='notifications-emoji_reaction' className='column-settings__section'><FormattedMessage id='notifications.column_settings.emoji_reaction' defaultMessage='Emoji 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 role='group' aria-labelledby='notifications-mention'>
|
||||
<span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
|
||||
|
||||
|
@ -5,6 +5,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import EmojiReactionIcon from '@material-icons/400-24px/mood.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||
@ -14,6 +15,7 @@ import { Icon } from 'mastodon/components/icon';
|
||||
const tooltips = defineMessages({
|
||||
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
|
||||
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' },
|
||||
emojiReactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Stamps' },
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
|
||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||
@ -81,6 +83,13 @@ class FilterBar extends PureComponent {
|
||||
>
|
||||
<Icon id='star' icon={StarIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'emoji_reaction' ? 'active' : ''}
|
||||
onClick={this.onClick('emoji_reaction')}
|
||||
title={intl.formatMessage(tooltips.emojiReactions)}
|
||||
>
|
||||
<Icon id='smile-o' icon={EmojiReactionIcon} fixedWidth />
|
||||
</button>
|
||||
<button
|
||||
className={selectedFilter === 'reblog' ? 'active' : ''}
|
||||
onClick={this.onClick('reblog')}
|
||||
|
@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
@ -27,9 +28,11 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
import FollowRequestContainer from '../containers/follow_request_container';
|
||||
|
||||
import Report from './report';
|
||||
import EmojiView from '../../../components/emoji_view';
|
||||
|
||||
const messages = defineMessages({
|
||||
favourite: { id: 'notification.favourite', defaultMessage: '{name} favorited your status' },
|
||||
emojiReaction: { id: 'notification.emoji_reaction', defaultMessage: '{name} reacted your status with emoji' },
|
||||
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
|
||||
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
|
||||
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
|
||||
@ -183,6 +186,7 @@ class Notification extends ImmutablePureComponent {
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
unread={this.props.unread}
|
||||
withoutEmojiReactions={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -211,6 +215,41 @@ class Notification extends ImmutablePureComponent {
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
withoutEmojiReactions={true}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderEmojiReaction (notification, link) {
|
||||
const { intl, unread } = this.props;
|
||||
const emoji_reaction = notification.get('emoji_reaction');
|
||||
|
||||
return (
|
||||
<HotKeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-emoji_reaction focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.emojiReaction, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<div className='notification__emoji_reaction-icon-wrapper'>
|
||||
<EmojiView name={emoji_reaction.get('name')} url={emoji_reaction.get('url')} staticUrl={emoji_reaction.get('static_url')} className='star-icon' fixedWidth />
|
||||
</div>
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.emoji_reaction' defaultMessage='{name} reacted your status with emoji' 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}
|
||||
withoutEmojiReactions={true}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
@ -241,6 +280,7 @@ class Notification extends ImmutablePureComponent {
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
withoutEmojiReactions={true}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
@ -276,6 +316,7 @@ class Notification extends ImmutablePureComponent {
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
withoutEmojiReactions={true}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
@ -311,6 +352,7 @@ class Notification extends ImmutablePureComponent {
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
withoutEmojiReactions={true}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
@ -352,6 +394,7 @@ class Notification extends ImmutablePureComponent {
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
withoutEmojiReactions={true}
|
||||
/>
|
||||
</div>
|
||||
</HotKeys>
|
||||
@ -421,6 +464,8 @@ class Notification extends ImmutablePureComponent {
|
||||
return this.renderMention(notification);
|
||||
case 'favourite':
|
||||
return this.renderFavourite(notification, link);
|
||||
case 'emoji_reaction':
|
||||
return this.renderEmojiReaction(notification, link);
|
||||
case 'reblog':
|
||||
return this.renderReblog(notification, link);
|
||||
case 'status':
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
favourite,
|
||||
unreblog,
|
||||
unfavourite,
|
||||
emojiReact,
|
||||
} from '../../../actions/interactions';
|
||||
import {
|
||||
hideStatus,
|
||||
@ -62,6 +63,10 @@ const mapDispatchToProps = dispatch => ({
|
||||
}
|
||||
},
|
||||
|
||||
onEmojiReact (status, emoji) {
|
||||
dispatch(emojiReact(status, emoji));
|
||||
},
|
||||
|
||||
onToggleHidden (status) {
|
||||
if (status.get('hidden')) {
|
||||
dispatch(revealStatus(status.get('id')));
|
||||
|
@ -1,6 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@ -84,6 +85,7 @@ class ActionBar extends PureComponent {
|
||||
onReblog: PropTypes.func.isRequired,
|
||||
onQuote: PropTypes.func,
|
||||
onFavourite: PropTypes.func.isRequired,
|
||||
onEmojiReact: PropTypes.func.isRequired,
|
||||
onBookmark: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
@ -206,6 +208,10 @@ class ActionBar extends PureComponent {
|
||||
navigator.clipboard.writeText(url);
|
||||
};
|
||||
|
||||
handleEmojiPick = (data) => {
|
||||
this.props.onEmojiReact(this.props.status, data);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl } = this.props;
|
||||
const { signedIn, permissions } = this.context.identity;
|
||||
@ -327,6 +333,7 @@ class ActionBar extends PureComponent {
|
||||
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton disabled={!publicStatus} title={StatusActionBar.quoteTitle(intl, messages, publicStatus)} icon='format-quote' iconComponent={FormatQuoteIcon} onClick={this.handleQuoteClick} /></div>
|
||||
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>
|
||||
<div className='detailed-status__button'><EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /></div>
|
||||
<div className='detailed-status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
|
||||
</div>
|
||||
|
@ -6,6 +6,7 @@ import classNames from 'classnames';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import StatusEmojiReactionsBar from '../../../components/status_emoji_reactions_bar';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
@ -53,6 +54,8 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
onToggleMediaVisibility: PropTypes.func,
|
||||
onQuoteToggleMediaVisibility: PropTypes.func,
|
||||
...WithRouterPropTypes,
|
||||
onEmojiReact: PropTypes.func,
|
||||
onUnEmojiReact: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -171,6 +174,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
const reblogIcon = 'retweet';
|
||||
const reblogIconComponent = RepeatIcon;
|
||||
let favouriteLink = '';
|
||||
let emojiReactionsLink = '';
|
||||
let edited = '';
|
||||
|
||||
if (this.props.measureHeight) {
|
||||
@ -261,9 +265,14 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
return (<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia}
|
||||
card={status.get('card', null)} quote={quote} />);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let emojiReactionsBar = null;
|
||||
if (status.get('emoji_reactions')) {
|
||||
const emojiReactions = status.get('emoji_reactions');
|
||||
emojiReactionsBar = <StatusEmojiReactionsBar emojiReactions={emojiReactions} status={status} onEmojiReact={this.props.onEmojiReact} onUnEmojiReact={this.props.onUnEmojiReact} />;
|
||||
}
|
||||
|
||||
if (status.get('application')) {
|
||||
applicationLink = <> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></>;
|
||||
}
|
||||
@ -318,6 +327,22 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.context.router) {
|
||||
emojiReactionsLink = (
|
||||
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/emoji_reactions`} className='detailed-status__link'>
|
||||
<Icon id='smile-o' />
|
||||
<FormattedMessage id='status.emoji' defaultMessage='Emoji' />
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
emojiReactionsLink = (
|
||||
<a href={`/interact/${status.get('id')}?type=emoji_reactions`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
<Icon id='smile-o' />
|
||||
<FormattedMessage id='status.emoji' defaultMessage='Emoji' />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.get('edited_at')) {
|
||||
edited = (
|
||||
<>
|
||||
@ -355,10 +380,12 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
|
||||
{expanded && hashtagBar}
|
||||
|
||||
{emojiReactionsBar}
|
||||
|
||||
<div className='detailed-status__meta'>
|
||||
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} 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' />
|
||||
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
|
||||
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -17,6 +17,8 @@ import {
|
||||
unfavourite,
|
||||
pin,
|
||||
unpin,
|
||||
emojiReact,
|
||||
unEmojiReact,
|
||||
} from '../../../actions/interactions';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { initMuteModal } from '../../../actions/mutes';
|
||||
@ -100,6 +102,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
}
|
||||
},
|
||||
|
||||
onEmojiReact (status, emoji) {
|
||||
dispatch(emojiReact(status, emoji));
|
||||
},
|
||||
|
||||
onUnEmojiReact (status, emoji) {
|
||||
dispatch(unEmojiReact(status, emoji));
|
||||
},
|
||||
|
||||
onPin (status) {
|
||||
if (status.get('pinned')) {
|
||||
dispatch(unpin(status));
|
||||
|
@ -41,6 +41,8 @@ import {
|
||||
import {
|
||||
favourite,
|
||||
unfavourite,
|
||||
emojiReact,
|
||||
unEmojiReact,
|
||||
bookmark,
|
||||
unbookmark,
|
||||
reblog,
|
||||
@ -279,6 +281,16 @@ class Status extends ImmutablePureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
handleEmojiReact = (status, emoji) => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(emojiReact(status, emoji));
|
||||
};
|
||||
|
||||
handleUnEmojiReact = (status, emoji) => {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(unEmojiReact(status, emoji));
|
||||
};
|
||||
|
||||
handlePin = (status) => {
|
||||
if (status.get('pinned')) {
|
||||
this.props.dispatch(unpin(status));
|
||||
@ -749,6 +761,8 @@ class Status extends ImmutablePureComponent {
|
||||
onToggleMediaVisibility={this.handleToggleMediaVisibility}
|
||||
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
|
||||
pictureInPicture={pictureInPicture}
|
||||
onEmojiReact={this.handleEmojiReact}
|
||||
onUnEmojiReact={this.handleUnEmojiReact}
|
||||
/>
|
||||
|
||||
<ActionBar
|
||||
@ -756,6 +770,7 @@ class Status extends ImmutablePureComponent {
|
||||
status={status}
|
||||
onReply={this.handleReplyClick}
|
||||
onFavourite={this.handleFavouriteClick}
|
||||
onEmojiReact={this.handleEmojiReact}
|
||||
onReblog={this.handleReblogClick}
|
||||
onQuote={this.handleQuoteClick}
|
||||
onBookmark={this.handleBookmarkClick}
|
||||
|
@ -45,6 +45,7 @@ import {
|
||||
Following,
|
||||
Reblogs,
|
||||
Favourites,
|
||||
EmojiReactions,
|
||||
DirectTimeline,
|
||||
HashtagTimeline,
|
||||
Notifications,
|
||||
@ -214,6 +215,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
<WrappedRoute path='/directory' component={Directory} content={children} />
|
||||
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
|
||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||
<WrappedRoute path='/@:acct/:statusId/emoji_reactions' component={EmojiReactions} content={children} />
|
||||
|
||||
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
|
||||
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
|
||||
@ -224,6 +226,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
<WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
|
||||
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
|
||||
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/emoji_reactions' component={EmojiReactions} content={children} />
|
||||
|
||||
{/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
|
||||
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
|
||||
|
@ -82,6 +82,10 @@ export function Favourites () {
|
||||
return import(/* webpackChunkName: "features/favourites" */'../../favourites');
|
||||
}
|
||||
|
||||
export function EmojiReactions () {
|
||||
return import(/* webpackChunkName: "features/favourites" */'../../emoji_reactions');
|
||||
}
|
||||
|
||||
export function FollowRequests () {
|
||||
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
|
||||
}
|
||||
|
@ -428,7 +428,8 @@
|
||||
"not_signed_in_indicator.not_signed_in": "You need to login to access this resource.",
|
||||
"notification.admin.report": "{name} reported {target}",
|
||||
"notification.admin.sign_up": "{name} signed up",
|
||||
"notification.favourite": "{name} favorited your post",
|
||||
"notification.emoji_reaction": "{name} reacted your post with emoji",
|
||||
"notification.favourite": "{name} favourited your post",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.follow_request": "{name} has requested to follow you",
|
||||
"notification.mention": "{name} mentioned you",
|
||||
|
@ -428,6 +428,7 @@
|
||||
"not_signed_in_indicator.not_signed_in": "この機能を使うにはログインする必要があります。",
|
||||
"notification.admin.report": "{name}さんが{target}さんを通報しました",
|
||||
"notification.admin.sign_up": "{name}さんがサインアップしました",
|
||||
"notification.emoji_reaction": "{name}さんがあなたの投稿に絵文字をつけました",
|
||||
"notification.favourite": "{name}さんがあなたのトゥートに╰( ^o^)╮-=ニ=一=三★しました",
|
||||
"notification.follow": "{name}さんにフォローされました",
|
||||
"notification.follow_request": "{name}さんがあなたにフォローリクエストしました",
|
||||
|
@ -52,6 +52,7 @@ const notificationToMap = notification => ImmutableMap({
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
account: notification.account.id,
|
||||
emoji_reaction: ImmutableMap(notification.emoji_reaction),
|
||||
created_at: notification.created_at,
|
||||
status: notification.status ? notification.status.id : null,
|
||||
report: notification.report ? fromJS(notification.report) : null,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
|
||||
import { normalizeStatusTranslation } from '../actions/importer/normalizer';
|
||||
@ -26,6 +26,7 @@ import {
|
||||
STATUS_TRANSLATE_UNDO,
|
||||
STATUS_FETCH_REQUEST,
|
||||
STATUS_FETCH_FAIL,
|
||||
STATUS_EMOJI_REACTION_UPDATE,
|
||||
QUOTE_REVEAL,
|
||||
QUOTE_HIDE,
|
||||
} from '../actions/statuses';
|
||||
@ -65,6 +66,29 @@ const statusTranslateUndo = (state, id) => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateStatusEmojiReaction = (state, emoji_reaction, myId) => {
|
||||
emoji_reaction.me = emoji_reaction.account_ids ? emoji_reaction.account_ids.indexOf(myId) >= 0 : false;
|
||||
|
||||
const status = state.get(emoji_reaction.status_id);
|
||||
if (!status) return state;
|
||||
|
||||
let emoji_reactions = Array.from(status.get('emoji_reactions') || []);
|
||||
|
||||
if (emoji_reaction.count > 0) {
|
||||
const old_emoji = emoji_reactions.find((er) => er.get('name') === emoji_reaction.name && (!er.get('domain') || er.get('domain') === emoji_reaction.domain));
|
||||
if (old_emoji) {
|
||||
const index = emoji_reactions.indexOf(old_emoji);
|
||||
emoji_reactions[index] = old_emoji.merge({ account_ids: emoji_reaction.account_ids, count: emoji_reaction.count, me: emoji_reaction.me });
|
||||
} else {
|
||||
emoji_reactions.push(ImmutableMap(emoji_reaction));
|
||||
}
|
||||
} else {
|
||||
emoji_reactions = emoji_reactions.filter((er) => er.get('name') !== emoji_reaction.name || er.get('domain') !== emoji_reaction.domain);
|
||||
}
|
||||
|
||||
return state.setIn([emoji_reaction.status_id, 'emoji_reactions'], ImmutableList(emoji_reactions));
|
||||
};
|
||||
|
||||
const initialState = ImmutableMap();
|
||||
|
||||
export default function statuses(state = initialState, action) {
|
||||
@ -137,6 +161,8 @@ export default function statuses(state = initialState, action) {
|
||||
return statusTranslateSuccess(state, action.id, action.translation);
|
||||
case STATUS_TRANSLATE_UNDO:
|
||||
return statusTranslateUndo(state, action.id);
|
||||
case STATUS_EMOJI_REACTION_UPDATE:
|
||||
return updateStatusEmojiReaction(state, action.emoji_reaction, action.accountId);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||
|
||||
import {
|
||||
DIRECTORY_FETCH_REQUEST,
|
||||
DIRECTORY_FETCH_SUCCESS,
|
||||
@ -57,6 +56,7 @@ import {
|
||||
FAVOURITES_EXPAND_REQUEST,
|
||||
FAVOURITES_EXPAND_SUCCESS,
|
||||
FAVOURITES_EXPAND_FAIL,
|
||||
EMOJI_REACTIONS_FETCH_SUCCESS,
|
||||
} from '../actions/interactions';
|
||||
import {
|
||||
MUTES_FETCH_REQUEST,
|
||||
@ -79,6 +79,7 @@ const initialState = ImmutableMap({
|
||||
following: initialListState,
|
||||
reblogged_by: initialListState,
|
||||
favourited_by: initialListState,
|
||||
emoji_reactioned_by: initialListState,
|
||||
follow_requests: initialListState,
|
||||
blocks: initialListState,
|
||||
mutes: initialListState,
|
||||
@ -161,6 +162,8 @@ export default function userLists(state = initialState, action) {
|
||||
return state.setIn(['favourited_by', action.id, 'isLoading'], false);
|
||||
case notificationsUpdate.type:
|
||||
return action.payload.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.payload.notification) : state;
|
||||
case EMOJI_REACTIONS_FETCH_SUCCESS:
|
||||
return state.setIn(['emoji_reactioned_by', action.id], ImmutableList(action.accounts));
|
||||
case FOLLOW_REQUESTS_FETCH_SUCCESS:
|
||||
return normalizeList(state, ['follow_requests'], action.accounts, action.next);
|
||||
case FOLLOW_REQUESTS_EXPAND_SUCCESS:
|
||||
|
@ -1725,13 +1725,50 @@ body > [data-popper-placement] {
|
||||
color: $highlight-text-color;
|
||||
}
|
||||
}
|
||||
.status__emoji-reactions-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
// gap: 4px 8px;
|
||||
margin: 8px 0 2px 0;
|
||||
|
||||
.emoji-reactions-bar__button {
|
||||
background: $white;
|
||||
border: 1px solid lighten($ui-base-color, 8%);
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
|
||||
&.toggled {
|
||||
background-color: mix($white, $ui-highlight-color, 80%);
|
||||
border-color: mix(lighten($ui-base-color, 8%), $ui-highlight-color, 80%);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
display: block;
|
||||
height: 16px;
|
||||
img {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.count {
|
||||
display: block;
|
||||
margin: 0 2px;
|
||||
color: $ui-secondary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status__action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
//margin-top: 16px;
|
||||
}
|
||||
|
||||
.detailed-status__action-bar-dropdown {
|
||||
|
@ -37,7 +37,7 @@ class ActivityPub::Activity
|
||||
ActivityPub::Activity::Delete
|
||||
when 'Follow'
|
||||
ActivityPub::Activity::Follow
|
||||
when 'Like'
|
||||
when 'Like', 'EmojiReaction', 'EmojiReact'
|
||||
ActivityPub::Activity::Like
|
||||
when 'Block'
|
||||
ActivityPub::Activity::Block
|
||||
|
@ -2,13 +2,102 @@
|
||||
|
||||
class ActivityPub::Activity::Like < ActivityPub::Activity
|
||||
def perform
|
||||
original_status = status_from_uri(object_uri)
|
||||
@original_status = status_from_uri(object_uri)
|
||||
|
||||
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
|
||||
return if @original_status.nil? || !@original_status.account.local? || delete_arrived_first?(@json['id'])
|
||||
|
||||
favourite = original_status.favourites.create!(account: @account)
|
||||
|
||||
LocalNotificationWorker.perform_async(original_status.account_id, favourite.id, 'Favourite', 'favourite')
|
||||
Trends.statuses.register(original_status)
|
||||
if shortcode.nil?
|
||||
process_favourite
|
||||
else
|
||||
process_emoji_reaction
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_favourite
|
||||
return if @account.favourited?(@original_status)
|
||||
|
||||
favourite = @original_status.favourites.create!(account: @account)
|
||||
|
||||
LocalNotificationWorker.perform_async(@original_status.account_id, favourite.id, 'Favourite', 'favourite')
|
||||
Trends.statuses.register(@original_status)
|
||||
end
|
||||
|
||||
def process_emoji_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 EmojiReaction.where(account: @account, status: @original_status).count >= EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT
|
||||
|
||||
reaction = @original_status.emoji_reactions.create!(account: @account, name: shortcode, custom_emoji: emoji, uri: @json['id'])
|
||||
write_stream(reaction)
|
||||
|
||||
if @original_status.account.local?
|
||||
NotifyService.new.call(@original_status.account, :emoji_reaction, reaction)
|
||||
forward_for_emoji_reaction
|
||||
relay_for_emoji_reaction
|
||||
end
|
||||
rescue Seahorse::Client::NetworkingError
|
||||
nil
|
||||
end
|
||||
|
||||
def forward_for_emoji_reaction
|
||||
return unless @json['signature'].present?
|
||||
|
||||
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), @original_status.account.id, [@account.preferred_inbox_url])
|
||||
end
|
||||
|
||||
def relay_for_emoji_reaction
|
||||
return unless @json['signature'].present? && @original_status.public_visibility?
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
|
||||
[Oj.dump(@json), @original_status.account.id, inbox_url]
|
||||
end
|
||||
end
|
||||
|
||||
def shortcode
|
||||
return @shortcode if defined?(@shortcode)
|
||||
|
||||
@shortcode = begin
|
||||
if @json['_misskey_reaction'] == '⭐'
|
||||
nil
|
||||
else
|
||||
@json['content']&.delete(':')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def misskey_favourite?
|
||||
misskey_shortcode = @json['_misskey_reaction']&.delete(':')
|
||||
|
||||
return misskey_shortcode == shortcode && misskey_shortcode == '⭐'
|
||||
end
|
||||
|
||||
def emoji_tag
|
||||
return @emoji_tag if defined?(@emoji_tag)
|
||||
|
||||
@emoji_tag = @json['tag'].is_a?(Array) ? @json['tag']&.first : @json['tag']
|
||||
end
|
||||
|
||||
def write_stream(emoji_reaction)
|
||||
emoji_group = @original_status.emoji_reactions_grouped_by_name
|
||||
.find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) }
|
||||
emoji_group['status_id'] = @original_status.id.to_s
|
||||
FeedAnyJsonWorker.perform_async(render_emoji_reaction(emoji_group), @original_status.id, emoji_reaction.account_id)
|
||||
end
|
||||
|
||||
def render_emoji_reaction(emoji_group)
|
||||
@render_emoji_reaction ||= Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json)
|
||||
end
|
||||
end
|
@ -100,7 +100,7 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||
end
|
||||
end
|
||||
|
||||
def undo_like
|
||||
def undo_like_original
|
||||
status = status_from_uri(target_uri)
|
||||
|
||||
return if status.nil? || !status.account.local?
|
||||
@ -113,6 +113,75 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||
end
|
||||
end
|
||||
|
||||
def undo_like
|
||||
@original_status = status_from_uri(target_uri)
|
||||
|
||||
return if @original_status.nil?
|
||||
|
||||
if shortcode.present?
|
||||
emoji_tag = @object['tag'].is_a?(Array) ? @object['tag']&.first : @object['tag']
|
||||
|
||||
emoji = nil
|
||||
if emoji_tag.present? && emoji_tag['id'].present?
|
||||
domain = URI.split(emoji_tag['id'])[2]
|
||||
domain = nil if domain == Rails.configuration.x.local_domain || domain == Rails.configuration.x.web_domain
|
||||
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain) if emoji_tag.present? && emoji_tag['id'].present?
|
||||
end
|
||||
|
||||
emoji_reaction = @original_status.emoji_reactions.where(account: @account, name: shortcode, custom_emoji: emoji).first
|
||||
|
||||
if emoji_reaction
|
||||
emoji_reaction.destroy
|
||||
write_stream(emoji_reaction)
|
||||
end
|
||||
else
|
||||
undo_like_original
|
||||
end
|
||||
end
|
||||
|
||||
def write_stream(emoji_reaction)
|
||||
emoji_group = @original_status.emoji_reactions_grouped_by_name
|
||||
.find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) }
|
||||
if emoji_group
|
||||
emoji_group['status_id'] = @original_status.id.to_s
|
||||
else
|
||||
# name: emoji_reaction.name, count: 0, domain: emoji_reaction.domain
|
||||
emoji_group = { 'name' => emoji_reaction.name, 'count' => 0, 'account_ids' => [], 'status_id' => @original_status.id.to_s }
|
||||
emoji_group['domain'] = emoji_reaction.custom_emoji.domain if emoji_reaction.custom_emoji
|
||||
end
|
||||
DeliveryEmojiReactionWorker.perform_async(render_emoji_reaction(emoji_group), @original_status.id, emoji_reaction.account_id) if Setting.streaming_emoji_reaction && (@original_status.local? || Setting.streaming_other_servers_emoji_reaction)
|
||||
end
|
||||
|
||||
def render_emoji_reaction(emoji_group)
|
||||
@render_emoji_reaction ||= Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json)
|
||||
end
|
||||
|
||||
def forward_for_undo_emoji_reaction
|
||||
return unless @json['signature'].present?
|
||||
|
||||
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), @original_status.account.id, [@account.preferred_inbox_url])
|
||||
end
|
||||
|
||||
def relay_for_undo_emoji_reaction
|
||||
return unless @json['signature'].present? && @original_status.public_visibility?
|
||||
|
||||
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
|
||||
[Oj.dump(@json), @original_status.account.id, inbox_url]
|
||||
end
|
||||
end
|
||||
|
||||
def shortcode
|
||||
return @shortcode if defined?(@shortcode)
|
||||
|
||||
@shortcode = begin
|
||||
if @object['_misskey_reaction'] == '⭐'
|
||||
nil
|
||||
else
|
||||
@object['content']&.delete(':')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def undo_block
|
||||
target_account = account_from_uri(target_uri)
|
||||
|
||||
|
@ -45,8 +45,12 @@ class ActivityPub::TagManager
|
||||
account_status_url(target.account, target)
|
||||
when :emoji
|
||||
emoji_url(target)
|
||||
|
||||
when :flag
|
||||
target.uri
|
||||
|
||||
when :emoji_reaction
|
||||
emoji_reaction_url(target)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -14,6 +14,8 @@ class InlineRenderer
|
||||
preload_associations_for_status
|
||||
when :notification
|
||||
serializer = REST::NotificationSerializer
|
||||
when :emoji_reaction
|
||||
serializer = REST::EmojiReactionSerializer
|
||||
when :conversation
|
||||
serializer = REST::ConversationSerializer
|
||||
when :announcement
|
||||
|
32
app/lib/potential_friendship_tracker.rb
Normal file
32
app/lib/potential_friendship_tracker.rb
Normal file
@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PotentialFriendshipTracker
|
||||
EXPIRE_AFTER = 90.days.seconds
|
||||
MAX_ITEMS = 80
|
||||
|
||||
WEIGHTS = {
|
||||
reply: 1,
|
||||
emoji_reaction: 2,
|
||||
favourite: 10,
|
||||
reblog: 20,
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
include Redisable
|
||||
|
||||
def record(account_id, target_account_id, action)
|
||||
return if account_id == target_account_id
|
||||
|
||||
key = "interactions:#{account_id}"
|
||||
weight = WEIGHTS[action]
|
||||
|
||||
redis.zincrby(key, weight, target_account_id)
|
||||
redis.zremrangebyrank(key, 0, -MAX_ITEMS)
|
||||
redis.expire(key, EXPIRE_AFTER)
|
||||
end
|
||||
|
||||
def remove(account_id, target_account_id)
|
||||
redis.zrem("interactions:#{account_id}", target_account_id)
|
||||
end
|
||||
end
|
||||
end
|
@ -13,6 +13,7 @@ module Account::Associations
|
||||
# Timelines
|
||||
has_many :statuses, inverse_of: :account, dependent: :destroy
|
||||
has_many :favourites, inverse_of: :account, dependent: :destroy
|
||||
has_many :emoji_reactions, inverse_of: :account, dependent: :destroy
|
||||
has_many :bookmarks, inverse_of: :account, dependent: :destroy
|
||||
has_many :mentions, inverse_of: :account, dependent: :destroy
|
||||
has_many :notifications, inverse_of: :account, dependent: :destroy
|
||||
|
@ -28,15 +28,16 @@ class CustomEmoji < ApplicationRecord
|
||||
|
||||
SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
|
||||
|
||||
SCAN_RE = /(?<=[^[:alnum:]:]|\n|^)
|
||||
:(#{SHORTCODE_RE_FRAGMENT}):
|
||||
(?=[^[:alnum:]:]|$)/x
|
||||
SCAN_RE = /:(#{SHORTCODE_RE_FRAGMENT}):/x
|
||||
SHORTCODE_ONLY_RE = /\A#{SHORTCODE_RE_FRAGMENT}\z/
|
||||
|
||||
IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze
|
||||
|
||||
belongs_to :category, class_name: 'CustomEmojiCategory', optional: true
|
||||
|
||||
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
|
||||
has_many :emoji_reactions, inverse_of: :custom_emoji, dependent: :destroy
|
||||
|
||||
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode, inverse_of: false, dependent: nil
|
||||
|
||||
has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' } }, validate_media_type: false
|
||||
|
53
app/models/emoji_reaction.rb
Normal file
53
app/models/emoji_reaction.rb
Normal file
@ -0,0 +1,53 @@
|
||||
# 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)
|
||||
# uri :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class EmojiReaction < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
EMOJI_REACTION_LIMIT = 32767
|
||||
EMOJI_REACTION_PER_ACCOUNT_LIMIT = 5
|
||||
|
||||
update_index('statuses', :status)
|
||||
|
||||
belongs_to :account, inverse_of: :emoji_reactions
|
||||
belongs_to :status, inverse_of: :emoji_reactions
|
||||
belongs_to :custom_emoji, optional: true
|
||||
|
||||
has_one :notification, as: :activity, dependent: :destroy
|
||||
|
||||
after_create :refresh_cache
|
||||
after_destroy :refresh_cache
|
||||
after_destroy :invalidate_cleanup_info
|
||||
|
||||
private
|
||||
|
||||
def refresh_cache
|
||||
status&.refresh_emoji_reactions_grouped_by_name!
|
||||
end
|
||||
|
||||
def invalidate_cleanup_info
|
||||
return unless status&.account_id == account_id && account.local?
|
||||
|
||||
account.statuses_cleanup_policy&.invalidate_last_inspected(status, :unfav)
|
||||
end
|
||||
|
||||
def paginate_by_max_id(limit, max_id = nil, since_id = nil)
|
||||
query = order(arel_table[:id].desc).limit(limit)
|
||||
query = query.where(arel_table[:id].lt(max_id)) if max_id.present?
|
||||
query = query.where(arel_table[:id].gt(since_id)) if since_id.present?
|
||||
query
|
||||
end
|
||||
end
|
@ -25,6 +25,7 @@ class Notification < ApplicationRecord
|
||||
'Follow' => :follow,
|
||||
'FollowRequest' => :follow_request,
|
||||
'Favourite' => :favourite,
|
||||
'EmojiReaction' => :emoji_reaction,
|
||||
'Poll' => :poll,
|
||||
}.freeze
|
||||
|
||||
@ -35,6 +36,7 @@ class Notification < ApplicationRecord
|
||||
follow
|
||||
follow_request
|
||||
favourite
|
||||
emoji_reaction
|
||||
poll
|
||||
update
|
||||
admin.sign_up
|
||||
@ -46,6 +48,7 @@ class Notification < ApplicationRecord
|
||||
reblog: [status: :reblog],
|
||||
mention: [mention: :status],
|
||||
favourite: [favourite: :status],
|
||||
emoji_reaction: [emoji_reaction: :status],
|
||||
poll: [poll: :status],
|
||||
update: :status,
|
||||
'admin.report': [report: :target_account],
|
||||
@ -61,6 +64,8 @@ class Notification < ApplicationRecord
|
||||
belongs_to :follow, inverse_of: :notification
|
||||
belongs_to :follow_request, inverse_of: :notification
|
||||
belongs_to :favourite, inverse_of: :notification
|
||||
belongs_to :emoji_reaction, inverse_of: :notification
|
||||
|
||||
belongs_to :poll, inverse_of: false
|
||||
belongs_to :report, inverse_of: false
|
||||
end
|
||||
@ -81,6 +86,8 @@ class Notification < ApplicationRecord
|
||||
status&.reblog
|
||||
when :favourite
|
||||
favourite&.status
|
||||
when :emoji_reaction
|
||||
emoji_reaction&.status
|
||||
when :mention
|
||||
mention&.status
|
||||
when :poll
|
||||
@ -130,6 +137,8 @@ class Notification < ApplicationRecord
|
||||
notification.status.reblog = cached_status
|
||||
when :favourite
|
||||
notification.favourite.status = cached_status
|
||||
when :emoji_reaction
|
||||
notification.emoji_reaction.status = cached_status
|
||||
when :mention
|
||||
notification.mention.status = cached_status
|
||||
when :poll
|
||||
@ -150,7 +159,7 @@ class Notification < ApplicationRecord
|
||||
return unless new_record?
|
||||
|
||||
case activity_type
|
||||
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report'
|
||||
when 'Status', 'Follow', 'Favourite', 'EmojiReaction', 'FollowRequest', 'Poll', 'Report'
|
||||
self.from_account_id = activity&.account_id
|
||||
when 'Mention'
|
||||
self.from_account_id = activity&.status&.account_id
|
||||
|
@ -30,6 +30,8 @@
|
||||
# ordered_media_attachment_ids :bigint(8) is an Array
|
||||
#
|
||||
|
||||
require 'ostruct'
|
||||
|
||||
class Status < ApplicationRecord
|
||||
include Cacheable
|
||||
include Discard::Model
|
||||
@ -67,6 +69,7 @@ class Status < ApplicationRecord
|
||||
end
|
||||
|
||||
has_many :favourites, inverse_of: :status, dependent: :destroy
|
||||
has_many :emoji_reactions, inverse_of: :status, dependent: :destroy
|
||||
has_many :bookmarks, inverse_of: :status, dependent: :destroy
|
||||
has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
|
||||
has_many :reblogged_by_accounts, through: :reblogs, class_name: 'Account', source: :account
|
||||
@ -323,6 +326,34 @@ class Status < ApplicationRecord
|
||||
update_status_stat!(key => [public_send(key) - 1, 0].max)
|
||||
end
|
||||
|
||||
def emoji_reactions_grouped_by_name(account = nil)
|
||||
(Oj.load(status_stat&.emoji_reactions || '', mode: :strict) || []).tap do |emoji_reactions|
|
||||
if account.present?
|
||||
emoji_reactions.each do |emoji_reaction|
|
||||
emoji_reaction['me'] = emoji_reaction['account_ids'].include?(account.id.to_s)
|
||||
emoji_reaction['account_ids'] -= account.excluded_from_timeline_account_ids.map(&:to_s)
|
||||
emoji_reaction['count'] = emoji_reaction['account_ids'].size
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def generate_emoji_reactions_grouped_by_name
|
||||
records = emoji_reactions.group(:name).order(Arel.sql('MIN(created_at) ASC')).select('name, min(custom_emoji_id) as custom_emoji_id, count(*) as count, array_agg(account_id::text order by created_at) as account_ids')
|
||||
Oj.dump(ActiveModelSerializers::SerializableResource.new(records, each_serializer: REST::EmojiReactionsGroupedByNameSerializer, scope: nil, scope_name: :current_user))
|
||||
end
|
||||
|
||||
def refresh_emoji_reactions_grouped_by_name!
|
||||
generate_emoji_reactions_grouped_by_name.tap do |emoji_reactions|
|
||||
update_status_stat!(emoji_reactions: emoji_reactions)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_emoji_reactions_grouped_by_account
|
||||
# TODO for serializer
|
||||
EmojiReaction.where(status_id: id).group_by(&:account)
|
||||
end
|
||||
|
||||
def trendable?
|
||||
if attributes['trendable'].nil?
|
||||
account.trendable?
|
||||
@ -364,6 +395,10 @@ class Status < ApplicationRecord
|
||||
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
|
||||
end
|
||||
|
||||
def emoji_reactions_map(status_ids, account_id)
|
||||
EmojiReaction.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |e, h| h[e.status_id] = true }
|
||||
end
|
||||
|
||||
def reload_stale_associations!(cached_items)
|
||||
account_ids = []
|
||||
|
||||
|
@ -0,0 +1,14 @@
|
||||
class StatusEmojiReactionsGroupedByAccountObject
|
||||
def initialize(account, emoji_reactions)
|
||||
@account = account
|
||||
@emoji_reactions = emoji_reactions
|
||||
end
|
||||
|
||||
def read_attribute_for_serialization(_)
|
||||
REST::EmojiReactedBySlimSerializer
|
||||
end
|
||||
|
||||
def model_name
|
||||
'EmojiReaction'
|
||||
end
|
||||
end
|
@ -11,6 +11,7 @@
|
||||
# favourites_count :bigint(8) default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# emoji_reactions :string
|
||||
#
|
||||
|
||||
class StatusStat < ApplicationRecord
|
||||
@ -27,4 +28,14 @@ class StatusStat < ApplicationRecord
|
||||
def favourites_count
|
||||
[attributes['favourites_count'], 0].max
|
||||
end
|
||||
|
||||
def emoji_reactions
|
||||
attributes['emoji_reactions'] || ''
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reset_parent_cache
|
||||
Rails.cache.delete("statuses/#{status_id}")
|
||||
end
|
||||
end
|
||||
|
@ -27,6 +27,10 @@ class StatusPolicy < ApplicationPolicy
|
||||
show? && !blocking_author?
|
||||
end
|
||||
|
||||
def emoji_reaction?
|
||||
show? && !blocking_author?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
owned?
|
||||
end
|
||||
|
@ -4,16 +4,17 @@ class StatusRelationshipsPresenter
|
||||
PINNABLE_VISIBILITIES = %w(public unlisted private).freeze
|
||||
|
||||
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
|
||||
:bookmarks_map, :filters_map, :attributes_map
|
||||
:bookmarks_map, :filters_map, :attributes_map, :emoji_reactions_map
|
||||
|
||||
def initialize(statuses, current_account_id = nil, **options)
|
||||
if current_account_id.nil?
|
||||
@reblogs_map = {}
|
||||
@favourites_map = {}
|
||||
@bookmarks_map = {}
|
||||
@mutes_map = {}
|
||||
@pins_map = {}
|
||||
@filters_map = {}
|
||||
@reblogs_map = {}
|
||||
@favourites_map = {}
|
||||
@bookmarks_map = {}
|
||||
@mutes_map = {}
|
||||
@pins_map = {}
|
||||
@filters_map = {}
|
||||
@emoji_reactions_map = {}
|
||||
else
|
||||
statuses = statuses.compact
|
||||
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
|
||||
@ -27,6 +28,7 @@ class StatusRelationshipsPresenter
|
||||
@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] || {})
|
||||
@attributes_map = options[:attributes_map] || {}
|
||||
@emoji_reactions_map = Status.emoji_reactions_map(status_ids, current_account_id).merge(options[:emoji_reactions_map] || {})
|
||||
end
|
||||
end
|
||||
|
||||
|
35
app/serializers/activitypub/emoji_reaction_serializer.rb
Normal file
35
app/serializers/activitypub/emoji_reaction_serializer.rb
Normal file
@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::EmojiReactionSerializer < ActivityPub::Serializer
|
||||
attributes :id, :type, :actor, :content
|
||||
attribute :virtual_object, key: :object
|
||||
|
||||
has_many :virtual_tags, key: :tag, unless: -> { object.custom_emoji.nil? }
|
||||
|
||||
def id
|
||||
[ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join
|
||||
end
|
||||
|
||||
def type
|
||||
'EmojiReact'
|
||||
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 virtual_tags
|
||||
[object.custom_emoji]
|
||||
end
|
||||
|
||||
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
|
||||
end
|
||||
end
|
@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::UndoEmojiReactionSerializer < ActivityPub::Serializer
|
||||
attributes :id, :type, :actor, :content
|
||||
|
||||
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
|
||||
|
||||
def content
|
||||
object.custom_emoji.nil? ? object.name : ":#{object.name}:"
|
||||
end
|
||||
end
|
@ -3,11 +3,11 @@
|
||||
class REST::CustomEmojiSerializer < ActiveModel::Serializer
|
||||
include RoutingHelper
|
||||
|
||||
# Please update `app/javascript/mastodon/api_types/custom_emoji.ts` when making changes to the attributes
|
||||
|
||||
attributes :shortcode, :url, :static_url, :visible_in_picker
|
||||
|
||||
attribute :category, if: :category_loaded?
|
||||
attribute :width, if: :width?
|
||||
attribute :height, if: :height?
|
||||
|
||||
def url
|
||||
full_asset_url(object.image.url)
|
||||
@ -24,4 +24,20 @@ class REST::CustomEmojiSerializer < ActiveModel::Serializer
|
||||
def category_loaded?
|
||||
object.association(:category).loaded? && object.category.present?
|
||||
end
|
||||
|
||||
def width?
|
||||
object.respond_to?(:image_width) || object.respond_to?(:width)
|
||||
end
|
||||
|
||||
def height?
|
||||
object.respond_to?(:image_height) || object.respond_to?(:height)
|
||||
end
|
||||
|
||||
def width
|
||||
object.respond_to?(:image_width) ? object.image_width : object.width
|
||||
end
|
||||
|
||||
def height
|
||||
object.respond_to?(:image_height) ? object.image_height : object.height
|
||||
end
|
||||
end
|
||||
|
@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::EmojiReactedBySlimReactionSerializer < ActiveModel::Serializer
|
||||
include RoutingHelper
|
||||
|
||||
attributes :name
|
||||
|
||||
attribute :url, if: :custom_emoji?
|
||||
attribute :static_url, if: :custom_emoji?
|
||||
attribute :domain, if: :custom_emoji?
|
||||
|
||||
def url
|
||||
full_asset_url(object.custom_emoji.image.url)
|
||||
end
|
||||
|
||||
def static_url
|
||||
full_asset_url(object.custom_emoji.image.url(:static))
|
||||
end
|
||||
|
||||
def domain
|
||||
object.custom_emoji.domain
|
||||
end
|
||||
|
||||
def custom_emoji?
|
||||
object.custom_emoji.present?
|
||||
end
|
||||
end
|
21
app/serializers/rest/emoji_reacted_by_slim_serializer.rb
Normal file
21
app/serializers/rest/emoji_reacted_by_slim_serializer.rb
Normal file
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::EmojiReactedBySlimSerializer < ActiveModel::Serializer
|
||||
|
||||
belongs_to :account, serializer: REST::AccountSerializer
|
||||
has_many :emoji_reactions, serializer: REST::EmojiReactedBySlimReactionSerializer
|
||||
|
||||
def attributes
|
||||
{ account => emoji_reactions }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
object.first
|
||||
end
|
||||
|
||||
def emoji_reactions
|
||||
object.last
|
||||
end
|
||||
end
|
34
app/serializers/rest/emoji_reaction_account_serializer.rb
Normal file
34
app/serializers/rest/emoji_reaction_account_serializer.rb
Normal file
@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::EmojiReactionAccountSerializer < ActiveModel::Serializer
|
||||
include RoutingHelper
|
||||
include FormattingHelper
|
||||
|
||||
attributes :id, :name
|
||||
|
||||
attribute :url, if: :custom_emoji?
|
||||
attribute :static_url, if: :custom_emoji?
|
||||
attribute :domain, if: :custom_emoji?
|
||||
|
||||
belongs_to :account, serializer: REST::AccountSerializer
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
|
||||
def url
|
||||
full_asset_url(object.custom_emoji.image.url)
|
||||
end
|
||||
|
||||
def static_url
|
||||
full_asset_url(object.custom_emoji.image.url(:static))
|
||||
end
|
||||
|
||||
def domain
|
||||
object.custom_emoji.domain
|
||||
end
|
||||
|
||||
def custom_emoji?
|
||||
object.custom_emoji.present?
|
||||
end
|
||||
end
|
23
app/serializers/rest/emoji_reaction_serializer.rb
Normal file
23
app/serializers/rest/emoji_reaction_serializer.rb
Normal file
@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::EmojiReactionSerializer < ActiveModel::Serializer
|
||||
attributes :name
|
||||
|
||||
attribute :count, if: :count?
|
||||
attribute :url, if: :custom_emoji?
|
||||
attribute :static_url, if: :custom_emoji?
|
||||
attribute :domain, if: :custom_emoji?
|
||||
attribute :account_ids, if: :account_ids?
|
||||
|
||||
def count?
|
||||
object.respond_to?(:count)
|
||||
end
|
||||
|
||||
def custom_emoji?
|
||||
object.respond_to?(:url)
|
||||
end
|
||||
|
||||
def account_ids?
|
||||
object.respond_to?(:account_ids)
|
||||
end
|
||||
end
|
@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::EmojiReactionsGroupedByNameSerializer < ActiveModel::Serializer
|
||||
include RoutingHelper
|
||||
|
||||
attributes :name, :count
|
||||
|
||||
attribute :me, if: :current_user?
|
||||
attribute :url, if: :custom_emoji?
|
||||
attribute :static_url, if: :custom_emoji?
|
||||
attribute :domain, if: :custom_emoji?
|
||||
attribute :account_ids, if: :account_ids?
|
||||
|
||||
def current_user?
|
||||
!current_user.nil?
|
||||
end
|
||||
|
||||
def custom_emoji?
|
||||
object.custom_emoji.present?
|
||||
end
|
||||
|
||||
def account_ids?
|
||||
object.respond_to?(:account_ids)
|
||||
end
|
||||
|
||||
def url
|
||||
full_asset_url(object.custom_emoji.image.url)
|
||||
end
|
||||
|
||||
def static_url
|
||||
full_asset_url(object.custom_emoji.image.url(:static))
|
||||
end
|
||||
|
||||
def domain
|
||||
object.custom_emoji.domain
|
||||
end
|
||||
end
|
@ -11,7 +11,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
||||
|
||||
attributes :domain, :title, :version, :source_url, :description,
|
||||
:usage, :thumbnail, :languages, :configuration,
|
||||
:registrations, :feature_quote
|
||||
:registrations, :feature_quote, :fedibird_capabilities
|
||||
|
||||
has_one :contact, serializer: ContactSerializer
|
||||
has_many :rules, serializer: REST::RuleSerializer
|
||||
@ -81,6 +81,11 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
||||
translation: {
|
||||
enabled: TranslationService.configured?,
|
||||
},
|
||||
|
||||
emoji_reactions: {
|
||||
max_reactions: EmojiReaction::EMOJI_REACTION_LIMIT,
|
||||
max_reactions_per_account: EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
@ -97,6 +102,17 @@ class REST::InstanceSerializer < ActiveModel::Serializer
|
||||
true
|
||||
end
|
||||
|
||||
# for third party apps
|
||||
def fedibird_capabilities
|
||||
capabilities = [
|
||||
:emoji_reaction,
|
||||
]
|
||||
|
||||
capabilities << :profile_search unless Chewy.enabled?
|
||||
|
||||
capabilities
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def registrations_enabled?
|
||||
|
@ -6,16 +6,21 @@ class REST::NotificationSerializer < ActiveModel::Serializer
|
||||
belongs_to :from_account, key: :account, serializer: REST::AccountSerializer
|
||||
belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer
|
||||
belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer
|
||||
belongs_to :emoji_reaction, if: :emoji_reaction_type?, serializer: REST::NotifyEmojiReactionSerializer
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
|
||||
def status_type?
|
||||
[:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
|
||||
[:favourite, :emoji_reaction, :reblog, :status, :mention, :poll, :update].include?(object.type)
|
||||
end
|
||||
|
||||
def report_type?
|
||||
object.type == :'admin.report'
|
||||
end
|
||||
|
||||
def emoji_reaction_type?
|
||||
object.type == :emoji_reaction
|
||||
end
|
||||
end
|
||||
|
36
app/serializers/rest/notify_emoji_reaction_serializer.rb
Normal file
36
app/serializers/rest/notify_emoji_reaction_serializer.rb
Normal file
@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::NotifyEmojiReactionSerializer < ActiveModel::Serializer
|
||||
include RoutingHelper
|
||||
|
||||
attributes :name
|
||||
|
||||
attribute :count, if: :count?
|
||||
attribute :url, if: :custom_emoji?
|
||||
attribute :static_url, if: :custom_emoji?
|
||||
attribute :domain, if: :custom_emoji?
|
||||
|
||||
def count?
|
||||
object.respond_to?(:count)
|
||||
end
|
||||
|
||||
def custom_emoji?
|
||||
object.respond_to?(:custom_emoji) && object.custom_emoji.present?
|
||||
end
|
||||
|
||||
def account_ids?
|
||||
object.respond_to?(:account_ids)
|
||||
end
|
||||
|
||||
def url
|
||||
full_asset_url(object.custom_emoji.image.url)
|
||||
end
|
||||
|
||||
def static_url
|
||||
full_asset_url(object.custom_emoji.image.url(:static))
|
||||
end
|
||||
|
||||
def domain
|
||||
object.custom_emoji.domain
|
||||
end
|
||||
end
|
@ -6,7 +6,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
|
||||
:sensitive, :spoiler_text, :visibility, :language,
|
||||
:uri, :url, :replies_count, :reblogs_count,
|
||||
:favourites_count, :edited_at
|
||||
:favourites_count, :emoji_reactions, :edited_at
|
||||
|
||||
attribute :favourited, if: :current_user?
|
||||
attribute :reblogged, if: :current_user?
|
||||
@ -103,6 +103,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||
end
|
||||
end
|
||||
|
||||
def emoji_reactions
|
||||
object.emoji_reactions_grouped_by_name(current_user&.account)
|
||||
end
|
||||
|
||||
def reblogged
|
||||
if relationships
|
||||
relationships.reblogs_map[object.id] || false
|
||||
|
@ -6,7 +6,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
|
||||
attributes :uri, :title, :short_description, :description, :email,
|
||||
:version, :urls, :stats, :thumbnail,
|
||||
:languages, :registrations, :approval_required, :invites_enabled,
|
||||
:configuration
|
||||
:configuration, :fedibird_capabilities
|
||||
|
||||
has_one :contact_account, serializer: REST::AccountSerializer
|
||||
|
||||
@ -83,6 +83,11 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
|
||||
min_expiration: PollValidator::MIN_EXPIRATION,
|
||||
max_expiration: PollValidator::MAX_EXPIRATION,
|
||||
},
|
||||
|
||||
emoji_reactions: {
|
||||
max_reactions: EmojiReaction::EMOJI_REACTION_LIMIT,
|
||||
max_reactions_per_account: EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
@ -98,6 +103,17 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
|
||||
UserRole.everyone.can?(:invite_users)
|
||||
end
|
||||
|
||||
# for third party apps
|
||||
def fedibird_capabilities
|
||||
capabilities = [
|
||||
:emoji_reaction,
|
||||
]
|
||||
|
||||
capabilities << :profile_search unless Chewy.enabled?
|
||||
|
||||
capabilities
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def instance_presenter
|
||||
|
38
app/services/concerns/account_limitable.rb
Normal file
38
app/services/concerns/account_limitable.rb
Normal file
@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AccountLimitable
|
||||
def scope_status(status)
|
||||
case status.visibility.to_sym
|
||||
when :public, :unlisted
|
||||
#scope_local.merge(scope_list_following_account(status.account))
|
||||
scope_local
|
||||
when :private
|
||||
scope_account_local_followers(status.account)
|
||||
when :limited
|
||||
scope_status_mentioned(status)
|
||||
else
|
||||
scope_status_mentioned(status)
|
||||
end
|
||||
end
|
||||
|
||||
def scope_local
|
||||
Account.local.select(:id)
|
||||
end
|
||||
|
||||
def scope_account_local_followers(account)
|
||||
account.followers_for_local_distribution.select(:id).reorder(nil)
|
||||
end
|
||||
|
||||
def scope_status_mentioned(status)
|
||||
status.active_mentions.where.joins(:account).merge(Account.local).select('account_id AS id').reorder(nil)
|
||||
end
|
||||
|
||||
# TODO: not work
|
||||
def scope_list_following_account(account)
|
||||
account.lists_for_local_distribution.select(:id).reorder(nil)
|
||||
end
|
||||
|
||||
def scope_tag_following_account(status)
|
||||
TagFollow.where(tag_id: @status.tags.map(&:id)).select('account_id AS id').reorder(nil)
|
||||
end
|
||||
end
|
@ -147,6 +147,7 @@ class DeleteAccountService < BaseService
|
||||
purge_polls!
|
||||
purge_generated_notifications!
|
||||
purge_favourites!
|
||||
purge_emoji_reactions!
|
||||
purge_bookmarks!
|
||||
purge_feeds!
|
||||
purge_other_associations!
|
||||
@ -193,6 +194,16 @@ class DeleteAccountService < BaseService
|
||||
end
|
||||
end
|
||||
|
||||
def purge_emoji_reactions!
|
||||
@account.emoji_reactions.in_batches do |reactions|
|
||||
reactions.each do |reaction|
|
||||
reaction.status.refresh_emoji_reactions_grouped_by_name
|
||||
end
|
||||
Chewy.strategy.current.update(StatusesIndex, reactions.pluck(:status_id)) if Chewy.enabled?
|
||||
reactions.delete_all
|
||||
end
|
||||
end
|
||||
|
||||
def purge_bookmarks!
|
||||
@account.bookmarks.in_batches do |bookmarks|
|
||||
Chewy.strategy.current.update(StatusesIndex, bookmarks.pluck(:status_id)) if Chewy.enabled?
|
||||
|
78
app/services/emoji_react_service.rb
Normal file
78
app/services/emoji_react_service.rb
Normal file
@ -0,0 +1,78 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EmojiReactService < BaseService
|
||||
include Authorization
|
||||
include Payloadable
|
||||
include Redisable
|
||||
|
||||
# React a status with emoji and notify remote user
|
||||
# @param [Account] account
|
||||
# @param [Status] status
|
||||
# @param [string] name
|
||||
# @return [Favourite]
|
||||
def call(account, status, name)
|
||||
authorize_with account, status, :emoji_reaction?
|
||||
|
||||
emoji_reaction = EmojiReaction.find_by(account: account, status: status, name: name)
|
||||
|
||||
return emoji_reaction unless emoji_reaction.nil?
|
||||
|
||||
shortcode, domain = name.split('@')
|
||||
|
||||
custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain)
|
||||
|
||||
emoji_reaction = EmojiReaction.create!(account: account, status: status, name: shortcode, custom_emoji: custom_emoji)
|
||||
|
||||
Trends.statuses.register(status)
|
||||
|
||||
create_notification(emoji_reaction)
|
||||
notify_to_followers(emoji_reaction)
|
||||
bump_potential_friendship(account, status)
|
||||
write_stream(emoji_reaction)
|
||||
|
||||
emoji_reaction
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_notification(emoji_reaction)
|
||||
status = emoji_reaction.status
|
||||
|
||||
if status.account.local?
|
||||
LocalNotificationWorker.perform_async(status.account_id, emoji_reaction.id, 'EmojiReaction', '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 notify_to_followers(emoji_reaction)
|
||||
status = emoji_reaction.status
|
||||
|
||||
return unless status.account.local?
|
||||
|
||||
ActivityPub::RawDistributionWorker.perform_async(build_json(emoji_reaction), status.account_id)
|
||||
end
|
||||
|
||||
def write_stream(emoji_reaction)
|
||||
emoji_group = emoji_reaction.status.emoji_reactions_grouped_by_name
|
||||
.find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) }
|
||||
emoji_group['status_id'] = emoji_reaction.status_id.to_s
|
||||
FeedAnyJsonWorker.perform_async(render_emoji_reaction(emoji_group), emoji_reaction.status_id, emoji_reaction.account_id)
|
||||
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
|
||||
|
||||
def render_emoji_reaction(emoji_group)
|
||||
# @rendered_emoji_reaction ||= InlineRenderer.render(HashObject.new(emoji_group), nil, :emoji_reaction)
|
||||
@render_emoji_reaction ||= Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json)
|
||||
end
|
||||
end
|
63
app/services/un_emoji_react_service.rb
Normal file
63
app/services/un_emoji_react_service.rb
Normal file
@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnEmojiReactService < BaseService
|
||||
include Redisable
|
||||
include Payloadable
|
||||
|
||||
def call(account_id, status_id, emoji_reaction = nil)
|
||||
@account_id = account_id
|
||||
@account = Account.find(account_id)
|
||||
@status = Status.find(status_id)
|
||||
|
||||
p '==================================== DEBUG'
|
||||
p emoji_reaction
|
||||
|
||||
if emoji_reaction
|
||||
emoji_reaction.destroy!
|
||||
create_notification(emoji_reaction) if !@account.local? && @account.activitypub?
|
||||
notify_to_followers(emoji_reaction) if @account.local?
|
||||
write_stream(emoji_reaction)
|
||||
else
|
||||
bulk(@account, @status)
|
||||
end
|
||||
emoji_reaction
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bulk(account, status)
|
||||
EmojiReaction.where(account: account).where(status: status).each do |emoji_reaction|
|
||||
call(account.id, status.id, emoji_reaction)
|
||||
end
|
||||
end
|
||||
|
||||
def create_notification(emoji_reaction)
|
||||
ActivityPub::DeliveryWorker.perform_async(build_json(emoji_reaction), @account_id, @account.inbox_url)
|
||||
end
|
||||
|
||||
def notify_to_followers(emoji_reaction)
|
||||
ActivityPub::RawDistributionWorker.perform_async(build_json(emoji_reaction), @account_id)
|
||||
end
|
||||
|
||||
def write_stream(emoji_reaction)
|
||||
emoji_group = @status.emoji_reactions_grouped_by_name
|
||||
.find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) }
|
||||
if emoji_group
|
||||
emoji_group['status_id'] = @status.id.to_s
|
||||
else
|
||||
# name: emoji_reaction.name, count: 0, domain: emoji_reaction.domain
|
||||
emoji_group = { 'name' => emoji_reaction.name, 'count' => 0, 'account_ids' => [], 'status_id' => @status.id.to_s }
|
||||
emoji_group['domain'] = emoji_reaction.custom_emoji.domain if emoji_reaction.custom_emoji
|
||||
end
|
||||
FeedAnyJsonWorker.perform_async(render_emoji_reaction(emoji_group), @status.id, @account_id)
|
||||
end
|
||||
|
||||
def build_json(emoji_reaction)
|
||||
Oj.dump(serialize_payload(emoji_reaction, ActivityPub::UndoEmojiReactionSerializer))
|
||||
end
|
||||
|
||||
def render_emoji_reaction(emoji_group)
|
||||
# @rendered_emoji_reaction ||= InlineRenderer.render(emoji_group, nil, :emoji_reaction)
|
||||
Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json)
|
||||
end
|
||||
end
|
40
app/workers/feed_any_json_worker.rb
Normal file
40
app/workers/feed_any_json_worker.rb
Normal file
@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class FeedAnyJsonWorker
|
||||
include Sidekiq::Worker
|
||||
include Redisable
|
||||
include Lockable
|
||||
include AccountLimitable
|
||||
|
||||
def perform(payload_json, status_id, my_account_id = nil)
|
||||
p '========================================= DEBUG AAA'
|
||||
redis.publish("timeline:#{my_account_id}", payload_json) if my_account_id.present?
|
||||
p '========================================= DEBUG AA'
|
||||
p status_id
|
||||
p status_id.to_i
|
||||
|
||||
status = Status.find(status_id.to_i)
|
||||
p '========================================= DEBUG AAAAAAAA'
|
||||
p status.present?
|
||||
|
||||
if status.present?
|
||||
p '========================================= DEBUG A'
|
||||
p scope_status(status)
|
||||
p '========================================= DEBUG C'
|
||||
scope_status(status).find_each do |account|
|
||||
p '========================================= DEBUG D'
|
||||
p redis.exists?("subscribed:timeline:#{account.id}")
|
||||
redis.publish("timeline:#{account.id}", payload_json) if redis.exists?("subscribed:timeline:#{account.id}")
|
||||
end
|
||||
|
||||
if status.visibility.to_sym != :public && status.visibility.to_sym != :unlisted && status.account_id != my_account_id &&
|
||||
redis.exists?("subscribed:timeline:#{status.account_id}")
|
||||
redis.publish("timeline:#{status.account_id}", payload_json)
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
end
|
19
app/workers/un_emoji_react_worker.rb
Normal file
19
app/workers/un_emoji_react_worker.rb
Normal file
@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UnEmojiReactWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
def perform(account_id, status_id, emoji = nil)
|
||||
emoji_reaction = nil
|
||||
|
||||
if emoji
|
||||
shortcode, domain = emoji.split('@')
|
||||
emoji_reaction = EmojiReaction.where(account_id: account_id).where(status_id: status_id).where(name: shortcode)
|
||||
.find { |reaction| domain == '' ? reaction.custom_emoji.nil? : reaction.custom_emoji&.domain == domain }
|
||||
end
|
||||
|
||||
UnEmojiReactService.new.call(account_id.to_i, status_id.to_i, emoji_reaction)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
end
|
@ -1459,9 +1459,13 @@ en:
|
||||
sign_up:
|
||||
subject: "%{name} signed up"
|
||||
favourite:
|
||||
body: 'Your post was favorited by %{name}:'
|
||||
subject: "%{name} favorited your post"
|
||||
title: New favorite
|
||||
body: 'Your post was favourited by %{name}:'
|
||||
subject: "%{name} favourited your post"
|
||||
title: New favourite
|
||||
emoji_reaction:
|
||||
body: 'Your post was reacted with emoji by %{name}:'
|
||||
subject: "%{name} reacted your post with emoji"
|
||||
title: New emoji reaction
|
||||
follow:
|
||||
body: "%{name} is now following you!"
|
||||
subject: "%{name} is now following you"
|
||||
|
@ -1436,6 +1436,10 @@ ja:
|
||||
body: "%{name}さんにお気に入り登録された、あなたのトゥートがあります:"
|
||||
subject: "%{name}さんにお気に入りに登録されました"
|
||||
title: 新たなお気に入り登録
|
||||
emoji_reaction:
|
||||
body: "%{name}さんに絵文字リアクションされた、あなたの投稿があります:"
|
||||
subject: "%{name}さんに絵文字リアクションされました"
|
||||
title: 新たな絵文字リアクション
|
||||
follow:
|
||||
body: "%{name}さんにフォローされています!"
|
||||
subject: "%{name}さんにフォローされています"
|
||||
|
@ -29,6 +29,7 @@ Rails.application.routes.draw do
|
||||
/lists/(*any)
|
||||
/notifications
|
||||
/favourites
|
||||
/emoji_reactions
|
||||
/bookmarks
|
||||
/pinned
|
||||
/start/(*any)
|
||||
|
@ -10,6 +10,8 @@ namespace :api, format: false do
|
||||
scope module: :statuses do
|
||||
resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
|
||||
resources :favourited_by, controller: :favourited_by_accounts, only: :index
|
||||
resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index
|
||||
resources :emoji_reactioned_by_slim, controller: :emoji_reactioned_by_accounts_slim, only: :index
|
||||
resource :reblog, only: :create
|
||||
post :unreblog, to: 'reblogs#destroy'
|
||||
|
||||
@ -29,6 +31,9 @@ namespace :api, format: false do
|
||||
resource :source, only: :show
|
||||
|
||||
post :translate, to: 'translations#create'
|
||||
|
||||
resources :emoji_reactions, only: [:create, :update, :destroy], constraints: { id: /[^\/]+/ }
|
||||
post :emoji_unreaction, to: 'emoji_reactions#destroy'
|
||||
end
|
||||
|
||||
member do
|
||||
|
13
db/migrate/20230222232121_create_emoji_reactions.rb
Normal file
13
db/migrate/20230222232121_create_emoji_reactions.rb
Normal file
@ -0,0 +1,13 @@
|
||||
class CreateEmojiReactions < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
create_table :emoji_reactions do |t|
|
||||
t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade }
|
||||
t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade }
|
||||
t.string :name, null: false, default: ''
|
||||
t.belongs_to :custom_emoji, foreign_key: { on_delete: :cascade }
|
||||
t.string :uri
|
||||
t.datetime :created_at, null: false
|
||||
t.datetime :updated_at, null: false
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,5 @@
|
||||
class AddEmojiReactionsToStatusStats < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :status_stats, :emoji_reactions, :string
|
||||
end
|
||||
end
|
17
db/schema.rb
17
db/schema.rb
@ -439,6 +439,19 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) do
|
||||
t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true
|
||||
end
|
||||
|
||||
create_table "emoji_reactions", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "status_id", null: false
|
||||
t.string "name", default: "", null: false
|
||||
t.bigint "custom_emoji_id"
|
||||
t.string "uri"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_emoji_reactions_on_account_id"
|
||||
t.index ["custom_emoji_id"], name: "index_emoji_reactions_on_custom_emoji_id"
|
||||
t.index ["status_id"], name: "index_emoji_reactions_on_status_id"
|
||||
end
|
||||
|
||||
create_table "encrypted_messages", id: :bigint, default: -> { "timestamp_id('encrypted_messages'::text)" }, force: :cascade do |t|
|
||||
t.bigint "device_id"
|
||||
t.bigint "from_account_id"
|
||||
@ -967,6 +980,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) do
|
||||
t.bigint "favourites_count", default: 0, null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.string "emoji_reactions"
|
||||
t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true
|
||||
end
|
||||
|
||||
@ -1226,6 +1240,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) do
|
||||
add_foreign_key "devices", "accounts", on_delete: :cascade
|
||||
add_foreign_key "devices", "oauth_access_tokens", column: "access_token_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", "devices", on_delete: :cascade
|
||||
add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
|
||||
|
61
lib/mastodon/cache_cli.rb
Normal file
61
lib/mastodon/cache_cli.rb
Normal file
@ -0,0 +1,61 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative '../../config/boot'
|
||||
require_relative '../../config/environment'
|
||||
require_relative 'cli_helper'
|
||||
|
||||
module Mastodon
|
||||
class CacheCLI < Thor
|
||||
include CLIHelper
|
||||
|
||||
def self.exit_on_failure?
|
||||
true
|
||||
end
|
||||
|
||||
desc 'clear', 'Clear out the cache storage'
|
||||
def clear
|
||||
Rails.cache.clear
|
||||
say('OK', :green)
|
||||
end
|
||||
|
||||
option :concurrency, type: :numeric, default: 5, aliases: [:c]
|
||||
option :verbose, type: :boolean, aliases: [:v]
|
||||
desc 'recount TYPE', 'Update hard-cached counters'
|
||||
long_desc <<~LONG_DESC
|
||||
Update hard-cached counters of TYPE by counting referenced
|
||||
records from scratch. TYPE can be "accounts" or "statuses".
|
||||
|
||||
It may take a very long time to finish, depending on the
|
||||
size of the database.
|
||||
LONG_DESC
|
||||
def recount(type)
|
||||
case type
|
||||
when 'accounts'
|
||||
processed, = parallelize_with_progress(Account.local.includes(:account_stat)) do |account|
|
||||
account_stat = account.account_stat
|
||||
account_stat.following_count = account.active_relationships.count
|
||||
account_stat.followers_count = account.passive_relationships.count
|
||||
account_stat.statuses_count = account.statuses.where.not(visibility: :direct).count
|
||||
|
||||
account_stat.save if account_stat.changed?
|
||||
end
|
||||
when 'statuses'
|
||||
processed, = parallelize_with_progress(Status.includes(:status_stat)) do |status|
|
||||
status_stat = status.status_stat
|
||||
status_stat.replies_count = status.replies.where.not(visibility: :direct).count
|
||||
status_stat.reblogs_count = status.reblogs.count
|
||||
status_stat.favourites_count = status.favourites.count
|
||||
status_stat.emoji_reactions = status.generate_emoji_reactions_grouped_by_name
|
||||
|
||||
status_stat.save if status_stat.changed?
|
||||
end
|
||||
else
|
||||
say("Unknown type: #{type}", :red)
|
||||
exit(1)
|
||||
end
|
||||
|
||||
say
|
||||
say("OK, recounted #{processed} records", :green)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user