diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 98fa1897e..20e1c8223 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb
new file mode 100644
index 000000000..03817c682
--- /dev/null
+++ b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_slim_controller.rb b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_slim_controller.rb
new file mode 100644
index 000000000..222017452
--- /dev/null
+++ b/app/controllers/api/v1/statuses/emoji_reactioned_by_accounts_slim_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/statuses/emoji_reactions_controller.rb b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb
new file mode 100644
index 000000000..1b56033bc
--- /dev/null
+++ b/app/controllers/api/v1/statuses/emoji_reactions_controller.rb
@@ -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
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 0b1f024a4..f652f4c92 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -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
diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js
index 7d0144438..d34eb5c8c 100644
--- a/app/javascript/mastodon/actions/interactions.js
+++ b/app/javascript/mastodon/actions/interactions.js
@@ -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));
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index eafbf42d1..2674405d2 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -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',
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index f76303335..7b34ba1c6 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -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,
+});
\ No newline at end of file
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index 9daeb3c60..89e71c893 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -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)));
diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx
index 4a99dd0bb..7e0f915b7 100644
--- a/app/javascript/mastodon/components/account.jsx
+++ b/app/javascript/mastodon/components/account.jsx
@@ -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 ;
@@ -156,11 +158,13 @@ class Account extends ImmutablePureComponent {
- {!minimal && (
-
- {buttons}
-
- )}
+
+ {children}
+
+
+
+ {buttons}
+
{withBio && (account.get('note').length > 0 ? (
diff --git a/app/javascript/mastodon/components/emoji_view.jsx b/app/javascript/mastodon/components/emoji_view.jsx
new file mode 100644
index 000000000..ac6e92a2e
--- /dev/null
+++ b/app/javascript/mastodon/components/emoji_view.jsx
@@ -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 (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
index 65441b7b9..045b4b283 100644
--- a/app/javascript/mastodon/components/status.jsx
+++ b/app/javascript/mastodon/components/status.jsx
@@ -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 = ;
+ }
+
return (
@@ -720,6 +732,8 @@ class Status extends ImmutablePureComponent {
{expanded && hashtagBar}
+ {emojiReactionsBar}
+
diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx
index 0c2408c39..90d8205a7 100644
--- a/app/javascript/mastodon/components/status_action_bar.jsx
+++ b/app/javascript/mastodon/components/status_action_bar.jsx
@@ -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 = (
+
+ );
+
return (
@@ -410,6 +427,7 @@ class StatusActionBar extends ImmutablePureComponent {
+
{filterButton}
diff --git a/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx b/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx
new file mode 100644
index 000000000..603a217c5
--- /dev/null
+++ b/app/javascript/mastodon/components/status_emoji_reactions_bar.jsx
@@ -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 (
+
+ );
+ }
+
+}
+
+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) => (
+
));
+
+ return (
+
+ {emojiButtons}
+
+ );
+ }
+
+}
+export default injectIntl(StatusEmojiReactionsBar);
\ No newline at end of file
diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx
index fea108c23..8c239038f 100644
--- a/app/javascript/mastodon/containers/status_container.jsx
+++ b/app/javascript/mastodon/containers/status_container.jsx
@@ -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));
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
index 37017f4cc..bb2d6a46b 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
@@ -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 (
@@ -397,7 +416,7 @@ class EmojiPickerDropdown extends PureComponent {
inverted
/>
-
+
{({ props, placement })=> (
diff --git a/app/javascript/mastodon/features/emoji_reactions/index.jsx b/app/javascript/mastodon/features/emoji_reactions/index.jsx
new file mode 100644
index 000000000..4f5c3987f
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji_reactions/index.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ 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 =
;
+
+ return (
+
+
+ )}
+ />
+
+
+ {Object.keys(groups).map((key) =>(
+
+
+ {groups[key].map((value, index2) => )}
+
+
+ ))}
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default connect(mapStateToProps)(injectIntl(EmojiReactions));
\ No newline at end of file
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.jsx b/app/javascript/mastodon/features/notifications/components/column_settings.jsx
index 09154f257..c9b990e62 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.jsx
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.jsx
@@ -119,6 +119,17 @@ export default class ColumnSettings extends PureComponent {
+
+
+
+
+
+ {showPushSettings && }
+
+
+
+
+
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx b/app/javascript/mastodon/features/notifications/components/filter_bar.jsx
index c288c2c0d..364b03b7f 100644
--- a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx
+++ b/app/javascript/mastodon/features/notifications/components/filter_bar.jsx
@@ -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 {
>
+
);
}
@@ -211,6 +215,41 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
+ withoutEmojiReactions={true}
+ />
+
+
+ );
+ }
+
+ renderEmojiReaction (notification, link) {
+ const { intl, unread } = this.props;
+ const emoji_reaction = notification.get('emoji_reaction');
+
+ return (
+
+
@@ -241,6 +280,7 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
+ withoutEmojiReactions={true}
/>
@@ -276,6 +316,7 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
+ withoutEmojiReactions={true}
/>
@@ -311,6 +352,7 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
+ withoutEmojiReactions={true}
/>
@@ -352,6 +394,7 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
+ withoutEmojiReactions={true}
/>
@@ -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':
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js
index 4458fd7bc..4b3238b49 100644
--- a/app/javascript/mastodon/features/notifications/containers/notification_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js
@@ -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')));
diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx
index 7bc6e7429..055e6836e 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.jsx
+++ b/app/javascript/mastodon/features/status/components/action_bar.jsx
@@ -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 {
+
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx
index feeed1079..37dd05fe7 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.jsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx
@@ -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 ();
}
-
}
+ let emojiReactionsBar = null;
+ if (status.get('emoji_reactions')) {
+ const emojiReactions = status.get('emoji_reactions');
+ emojiReactionsBar = ;
+ }
+
if (status.get('application')) {
applicationLink = <> · {status.getIn(['application', 'name'])}>;
}
@@ -318,6 +327,22 @@ class DetailedStatus extends ImmutablePureComponent {
);
}
+ if (this.context.router) {
+ emojiReactionsLink = (
+
+
+
+
+ );
+ } else {
+ emojiReactionsLink = (
+
+
+
+
+ );
+ }
+
if (status.get('edited_at')) {
edited = (
<>
@@ -355,10 +380,12 @@ class DetailedStatus extends ImmutablePureComponent {
{expanded && hashtagBar}
+ {emojiReactionsBar}
+
- {edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
+ {edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink}
diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
index 44491127f..a472a085c 100644
--- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js
+++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
@@ -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));
diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx
index fa66c3c39..b7ba1538a 100644
--- a/app/javascript/mastodon/features/status/index.jsx
+++ b/app/javascript/mastodon/features/status/index.jsx
@@ -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}
/>
+
@@ -224,6 +226,7 @@ class SwitchingColumnsArea extends PureComponent {
+
{/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 7b968204b..2dba7de7d 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -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');
}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 8155ca9ad..9942f30f2 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -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",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 761c7a1d1..480bbffe8 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -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}さんがあなたにフォローリクエストしました",
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 2ca301b19..8673e51ec 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -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,
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index e3cf12965..233a90eed 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -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;
}
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
index 2f17fed5f..0b8dc69e9 100644
--- a/app/javascript/mastodon/reducers/user_lists.js
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -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:
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 998a0b548..4b985d253 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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 {
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 322f3e27a..96a0836aa 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -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
diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb
index aa1dc3040..93e5d7dcc 100644
--- a/app/lib/activitypub/activity/like.rb
+++ b/app/lib/activitypub/activity/like.rb
@@ -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
\ No newline at end of file
diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb
index 9eff1b71c..9b5a72b80 100644
--- a/app/lib/activitypub/activity/undo.rb
+++ b/app/lib/activitypub/activity/undo.rb
@@ -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)
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 864328631..0cd211d1f 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -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
diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb
index 0aebb13fc..fae713704 100644
--- a/app/lib/inline_renderer.rb
+++ b/app/lib/inline_renderer.rb
@@ -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
diff --git a/app/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb
new file mode 100644
index 000000000..f98acef30
--- /dev/null
+++ b/app/lib/potential_friendship_tracker.rb
@@ -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
diff --git a/app/models/concerns/account/associations.rb b/app/models/concerns/account/associations.rb
index 2bb6fed5a..5c5ef3efa 100644
--- a/app/models/concerns/account/associations.rb
+++ b/app/models/concerns/account/associations.rb
@@ -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
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 1c9b44395..e8cc7d45d 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -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
diff --git a/app/models/emoji_reaction.rb b/app/models/emoji_reaction.rb
new file mode 100644
index 000000000..44e880d9f
--- /dev/null
+++ b/app/models/emoji_reaction.rb
@@ -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
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 54212d675..52265fc5f 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -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
diff --git a/app/models/status.rb b/app/models/status.rb
index 1637da292..a8684b617 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -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 = []
diff --git a/app/models/status_emoji_reactions_grouped_by_account_object.rb b/app/models/status_emoji_reactions_grouped_by_account_object.rb
new file mode 100644
index 000000000..4dd33d805
--- /dev/null
+++ b/app/models/status_emoji_reactions_grouped_by_account_object.rb
@@ -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
diff --git a/app/models/status_stat.rb b/app/models/status_stat.rb
index 47aa14477..55e6a431b 100644
--- a/app/models/status_stat.rb
+++ b/app/models/status_stat.rb
@@ -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
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index 540e26642..cc8d0439c 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -27,6 +27,10 @@ class StatusPolicy < ApplicationPolicy
show? && !blocking_author?
end
+ def emoji_reaction?
+ show? && !blocking_author?
+ end
+
def destroy?
owned?
end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index 5d53040fb..2991c7748 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -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
diff --git a/app/serializers/activitypub/emoji_reaction_serializer.rb b/app/serializers/activitypub/emoji_reaction_serializer.rb
new file mode 100644
index 000000000..01ff4e172
--- /dev/null
+++ b/app/serializers/activitypub/emoji_reaction_serializer.rb
@@ -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
diff --git a/app/serializers/activitypub/undo_emoji_reaction_serializer.rb b/app/serializers/activitypub/undo_emoji_reaction_serializer.rb
new file mode 100644
index 000000000..7bba3c88b
--- /dev/null
+++ b/app/serializers/activitypub/undo_emoji_reaction_serializer.rb
@@ -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
diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb
index 33da69da5..2d4706864 100644
--- a/app/serializers/rest/custom_emoji_serializer.rb
+++ b/app/serializers/rest/custom_emoji_serializer.rb
@@ -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
diff --git a/app/serializers/rest/emoji_reacted_by_slim_reaction_serializer.rb b/app/serializers/rest/emoji_reacted_by_slim_reaction_serializer.rb
new file mode 100644
index 000000000..234218f2e
--- /dev/null
+++ b/app/serializers/rest/emoji_reacted_by_slim_reaction_serializer.rb
@@ -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
diff --git a/app/serializers/rest/emoji_reacted_by_slim_serializer.rb b/app/serializers/rest/emoji_reacted_by_slim_serializer.rb
new file mode 100644
index 000000000..6a0888d11
--- /dev/null
+++ b/app/serializers/rest/emoji_reacted_by_slim_serializer.rb
@@ -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
diff --git a/app/serializers/rest/emoji_reaction_account_serializer.rb b/app/serializers/rest/emoji_reaction_account_serializer.rb
new file mode 100644
index 000000000..192575407
--- /dev/null
+++ b/app/serializers/rest/emoji_reaction_account_serializer.rb
@@ -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
diff --git a/app/serializers/rest/emoji_reaction_serializer.rb b/app/serializers/rest/emoji_reaction_serializer.rb
new file mode 100644
index 000000000..fd58a125f
--- /dev/null
+++ b/app/serializers/rest/emoji_reaction_serializer.rb
@@ -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
diff --git a/app/serializers/rest/emoji_reactions_grouped_by_name_serializer.rb b/app/serializers/rest/emoji_reactions_grouped_by_name_serializer.rb
new file mode 100644
index 000000000..d4230de61
--- /dev/null
+++ b/app/serializers/rest/emoji_reactions_grouped_by_name_serializer.rb
@@ -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
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index af6c17d3c..5194857b4 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -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?
diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb
index 137fc53dd..f3235b3b1 100644
--- a/app/serializers/rest/notification_serializer.rb
+++ b/app/serializers/rest/notification_serializer.rb
@@ -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
diff --git a/app/serializers/rest/notify_emoji_reaction_serializer.rb b/app/serializers/rest/notify_emoji_reaction_serializer.rb
new file mode 100644
index 000000000..a78740d56
--- /dev/null
+++ b/app/serializers/rest/notify_emoji_reaction_serializer.rb
@@ -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
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index ce49cb04f..504f8241b 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -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
diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb
index fdf939cfc..fc405850a 100644
--- a/app/serializers/rest/v1/instance_serializer.rb
+++ b/app/serializers/rest/v1/instance_serializer.rb
@@ -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
diff --git a/app/services/concerns/account_limitable.rb b/app/services/concerns/account_limitable.rb
new file mode 100644
index 000000000..8a55d857c
--- /dev/null
+++ b/app/services/concerns/account_limitable.rb
@@ -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
diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb
index 7c7cb97df..9dda1631e 100644
--- a/app/services/delete_account_service.rb
+++ b/app/services/delete_account_service.rb
@@ -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?
diff --git a/app/services/emoji_react_service.rb b/app/services/emoji_react_service.rb
new file mode 100644
index 000000000..d8f51548a
--- /dev/null
+++ b/app/services/emoji_react_service.rb
@@ -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
diff --git a/app/services/un_emoji_react_service.rb b/app/services/un_emoji_react_service.rb
new file mode 100644
index 000000000..69b1a9392
--- /dev/null
+++ b/app/services/un_emoji_react_service.rb
@@ -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
diff --git a/app/workers/feed_any_json_worker.rb b/app/workers/feed_any_json_worker.rb
new file mode 100644
index 000000000..33aa25477
--- /dev/null
+++ b/app/workers/feed_any_json_worker.rb
@@ -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
diff --git a/app/workers/un_emoji_react_worker.rb b/app/workers/un_emoji_react_worker.rb
new file mode 100644
index 000000000..fce8019fb
--- /dev/null
+++ b/app/workers/un_emoji_react_worker.rb
@@ -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
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 9d739be07..9ffe65eca 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -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"
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 02bbe5171..4e95073fc 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -1436,6 +1436,10 @@ ja:
body: "%{name}さんにお気に入り登録された、あなたのトゥートがあります:"
subject: "%{name}さんにお気に入りに登録されました"
title: 新たなお気に入り登録
+ emoji_reaction:
+ body: "%{name}さんに絵文字リアクションされた、あなたの投稿があります:"
+ subject: "%{name}さんに絵文字リアクションされました"
+ title: 新たな絵文字リアクション
follow:
body: "%{name}さんにフォローされています!"
subject: "%{name}さんにフォローされています"
diff --git a/config/routes.rb b/config/routes.rb
index bb088821f..26a8688f0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -29,6 +29,7 @@ Rails.application.routes.draw do
/lists/(*any)
/notifications
/favourites
+ /emoji_reactions
/bookmarks
/pinned
/start/(*any)
diff --git a/config/routes/api.rb b/config/routes/api.rb
index 853a44e0e..a70ed0edb 100644
--- a/config/routes/api.rb
+++ b/config/routes/api.rb
@@ -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
diff --git a/db/migrate/20230222232121_create_emoji_reactions.rb b/db/migrate/20230222232121_create_emoji_reactions.rb
new file mode 100644
index 000000000..7edd6f289
--- /dev/null
+++ b/db/migrate/20230222232121_create_emoji_reactions.rb
@@ -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
diff --git a/db/migrate/20230223102416_add_emoji_reactions_to_status_stats.rb b/db/migrate/20230223102416_add_emoji_reactions_to_status_stats.rb
new file mode 100644
index 000000000..7f2ec68cc
--- /dev/null
+++ b/db/migrate/20230223102416_add_emoji_reactions_to_status_stats.rb
@@ -0,0 +1,5 @@
+class AddEmojiReactionsToStatusStats < ActiveRecord::Migration[6.1]
+ def change
+ add_column :status_stats, :emoji_reactions, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index ff6a675eb..cfdb39606 100644
--- a/db/schema.rb
+++ b/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
diff --git a/lib/mastodon/cache_cli.rb b/lib/mastodon/cache_cli.rb
new file mode 100644
index 000000000..71da96179
--- /dev/null
+++ b/lib/mastodon/cache_cli.rb
@@ -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