Merge pull request #135 from Y-zu-don-maintenance-org/features/emoji

Features/emoji
This commit is contained in:
YorimiMochida 2024-02-26 11:35:20 +09:00 committed by GitHub
commit 5b1eb8f8d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 1912 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,6 +46,8 @@ class Account extends ImmutablePureComponent {
minimal: PropTypes.bool,
defaultAction: PropTypes.string,
withBio: PropTypes.bool,
onActionClick: PropTypes.func,
children: PropTypes.object,
};
static defaultProps = {
@ -73,7 +75,7 @@ class Account extends ImmutablePureComponent {
};
render () {
const { account, intl, hidden, withBio, defaultAction, size, minimal } = this.props;
const { account, intl, hidden, onActionClick, actionIcon, actionTitle, withBio, defaultAction, size, minimal, children } = this.props;
if (!account) {
return <EmptyAccount size={size} minimal={minimal} />;
@ -156,11 +158,13 @@ class Account extends ImmutablePureComponent {
</div>
</Link>
{!minimal && (
<div className='account__relationship'>
{buttons}
</div>
)}
<div>
{children}
</div>
<div className='account__relationship'>
{buttons}
</div>
</div>
{withBio && (account.get('note').length > 0 ? (

View File

@ -0,0 +1,33 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import emojify from '../features/emoji/emoji';
import classNames from 'classnames';
export default class EmojiView extends React.PureComponent {
static propTypes = {
name: PropTypes.string,
url: PropTypes.string,
staticUrl: PropTypes.string,
};
render () {
const { name, url, staticUrl } = this.props;
let emojiHtml = null;
if (url) {
let customEmojis = {};
customEmojis[`:${name}:`] = { url, static_url: staticUrl };
emojiHtml = emojify(`:${name}:`, customEmojis);
} else {
emojiHtml = emojify(name);
}
return (
<span className='emoji' dangerouslySetInnerHTML={{ __html: emojiHtml }} />
);
}
}

View File

@ -1,5 +1,8 @@
import PropTypes from 'prop-types';
import StatusEmojiReactionsBar from './status_emoji_reactions_bar';
import AttachmentList from './attachment_list';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
@ -163,6 +166,8 @@ class Status extends ImmutablePureComponent {
onClick: PropTypes.func,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onEmojiReact: PropTypes.func,
onUnEmojiReact: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
@ -198,6 +203,7 @@ class Status extends ImmutablePureComponent {
}),
contextType: PropTypes.string,
...WithOptionalRouterPropTypes,
withoutEmojiReactions: PropTypes.bool,
};
// Avoid checking props that are functions (and whose equality will always
@ -677,6 +683,12 @@ class Status extends ImmutablePureComponent {
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
let emojiReactionsBar = null;
if (!this.props.withoutEmojiReactions && status.get('emoji_reactions')) {
const emojiReactions = status.get('emoji_reactions');
emojiReactionsBar = <StatusEmojiReactionsBar emojiReactions={emojiReactions} status={status} onEmojiReact={this.props.onEmojiReact} onUnEmojiReact={this.props.onUnEmojiReact} />;
}
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
@ -720,6 +732,8 @@ class Status extends ImmutablePureComponent {
{expanded && hashtagBar}
{emojiReactionsBar}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
</div>
</div>

View File

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
@ -51,6 +52,7 @@ const messages = defineMessages({
quote: { id: 'status.quote', defaultMessage: 'Quote' },
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Emoji Reaction' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
@ -88,6 +90,7 @@ class StatusActionBar extends ImmutablePureComponent {
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onEmojiReact: PropTypes.func,
onReblog: PropTypes.func,
onQuote: PropTypes.func,
onDelete: PropTypes.func,
@ -150,6 +153,16 @@ class StatusActionBar extends ImmutablePureComponent {
}
};
handleEmojiPick = (data) => {
const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onEmojiReact(this.props.status, data);
} else {
this.props.onInteractionModal('favourite', this.props.status);
}
};
handleReblogClick = e => {
const { signedIn } = this.context.identity;
@ -403,6 +416,10 @@ class StatusActionBar extends ImmutablePureComponent {
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
const emojiPickerButton = (
<IconButton className='status__action-bar__button' title={intl.formatMessage(messages.emojiReaction)} icon='smile-o' />
);
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
@ -410,6 +427,7 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<IconButton className='status__action-bar__button' disabled={!publicStatus} title={StatusActionBar.quoteTitle(intl, messages, publicStatus)} icon='format-quote' iconComponent={FormatQuoteIcon} onClick={this.handleQuoteClick} />
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
{filterButton}

View File

@ -0,0 +1,96 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import emojify from '../features/emoji/emoji';
import classNames from 'classnames';
import EmojiView from './emoji_view';
class EmojiReactionButton extends React.PureComponent {
static propTypes = {
name: PropTypes.string,
domain: PropTypes.string,
url: PropTypes.string,
staticUrl: PropTypes.string,
count: PropTypes.number.isRequired,
me: PropTypes.bool,
onEmojiReact: PropTypes.func,
onUnEmojiReact: PropTypes.func,
};
onClick = () => {
const { name, domain, me } = this.props;
const nameParameter = domain ? `${name}@${domain}` : name;
if (me) {
if (this.props.onUnEmojiReact) this.props.onUnEmojiReact(nameParameter);
} else {
if (this.props.onEmojiReact) this.props.onEmojiReact(nameParameter);
}
};
render () {
const { name, url, staticUrl, count, me } = this.props;
const classList = {
'reactions-bar__item': true,
'active': me,
};
return (
<button className={classNames(classList)} type='button' onClick={this.onClick}>
<span className='reactions-bar__item__emoji'>
<EmojiView name={name} url={url} staticUrl={staticUrl} />
</span>
<span className='reactions-bar__item__count'>{count}</span>
</button>
);
}
}
class StatusEmojiReactionsBar extends React.PureComponent {
static propTypes = {
emojiReactions: ImmutablePropTypes.list.isRequired,
status: ImmutablePropTypes.map,
onEmojiReact: PropTypes.func,
onUnEmojiReact: PropTypes.func,
};
onEmojiReact = (name) => {
if (!this.props.onEmojiReact) return;
this.props.onEmojiReact(this.props.status, name);
};
onUnEmojiReact = (name) => {
if (!this.props.onUnEmojiReact) return;
this.props.onUnEmojiReact(this.props.status, name);
};
render () {
const { emojiReactions } = this.props;
const emojiButtons = Array.from(emojiReactions).map((emoji, index) => (
<EmojiReactionButton
key={index}
name={emoji.get('name')}
count={emoji.get('count')}
me={emoji.get('me')}
url={emoji.get('url')}
staticUrl={emoji.get('static_url')}
domain={emoji.get('domain')}
onEmojiReact={this.onEmojiReact}
onUnEmojiReact={this.onUnEmojiReact}
/>));
return (
<div className='status__emoji-reactions-bar'>
{emojiButtons}
</div>
);
}
}
export default injectIntl(StatusEmojiReactionsBar);

View File

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

View File

@ -330,6 +330,7 @@ class EmojiPickerDropdown extends PureComponent {
state = {
active: false,
loading: false,
bottom: true,
};
setRef = (c) => {
@ -337,6 +338,7 @@ class EmojiPickerDropdown extends PureComponent {
};
onShowDropdown = () => {
this.updateDropdownPosition();
this.setState({ active: true });
if (!EmojiPicker) {
@ -357,6 +359,23 @@ class EmojiPickerDropdown extends PureComponent {
this.setState({ active: false });
};
updateDropdownPosition = () => {
let bottom = true;
if (this.target) {
const height = window.innerHeight;
const rect = this.target.getBoundingClientRect();
if (height && rect) {
bottom = height / 2 > rect.top;
}
}
if (this.state.bottom !== bottom) {
this.setState({ bottom });
}
};
onToggle = (e) => {
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
if (this.state.active) {
@ -384,7 +403,7 @@ class EmojiPickerDropdown extends PureComponent {
render () {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading } = this.state;
const { active, loading, bottom } = this.state;
return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}>
@ -397,7 +416,7 @@ class EmojiPickerDropdown extends PureComponent {
inverted
/>
<Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
<Overlay show={active} placement={ bottom ? 'bottom' : 'top' } target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
{({ props, placement })=> (
<div {...props} style={{ ...props.style }}>
<div className={`dropdown-animation ${placement}`}>

View File

@ -0,0 +1,121 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { ReactComponent as RefreshIcon } from '@material-symbols/svg-600/outlined/refresh.svg';
import { debounce } from 'lodash';
import { fetchEmojiReactions, expandEmojiReactions } from 'mastodon/actions/interactions';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import AccountContainer from 'mastodon/containers/account_container';
import Column from 'mastodon/features/ui/components/column';
import EmojiView from '../../components/emoji_view';
import { LoadingIndicator } from '../../components/loading_indicator';
const messages = defineMessages({
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
});
const mapStateToProps = (state, props) => {
return {
accountIds: state.getIn(['user_lists', 'emoji_reactioned_by', props.params.statusId, 'items']),
hasMore: !!state.getIn(['user_lists', 'emoji_reactioned_by', props.params.statusId, 'next']),
isLoading: state.getIn(['user_lists', 'emoji_reactioned_by', props.params.statusId, 'isLoading'], true),
};
};
class EmojiReactions extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
componentWillMount () {
if (!this.props.accountIds) {
this.props.dispatch(fetchEmojiReactions(this.props.params.statusId));
}
}
handleRefresh = () => {
this.props.dispatch(fetchEmojiReactions(this.props.params.statusId));
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandEmojiReactions(this.props.params.statusId));
}, 300, { leading: true });
render () {
const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let groups = {};
for (const emoji_reaction of accountIds) {
const key = emoji_reaction.account_id;
const value = emoji_reaction;
if (!groups[key]) groups[key] = [value];
else groups[key].push(value);
}
const emptyMessage = <FormattedMessage id='empty_column.emoji_reactions' defaultMessage='No one has reacted with emoji this post yet. When someone does, they will show up here.' />;
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
showBackButton
multiColumn={multiColumn}
extraButton={(
<button type='button' className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' icon={RefreshIcon} /></button>
)}
/>
<ScrollableList
scrollKey='emoji_reactions'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{Object.keys(groups).map((key) =>(
<AccountContainer key={key} id={key} withNote={false} hideButtons>
<div style={{ 'maxWidth': '100px' }}>
{groups[key].map((value, index2) => <EmojiView key={index2} name={value.name} url={value.url} staticUrl={value.static_url} />)}
</div>
</AccountContainer>
))}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(EmojiReactions));

View File

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

View File

@ -5,6 +5,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import EmojiReactionIcon from '@material-icons/400-24px/mood.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
@ -14,6 +15,7 @@ import { Icon } from 'mastodon/components/icon';
const tooltips = defineMessages({
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' },
emojiReactions: { id: 'notifications.filter.emoji_reactions', defaultMessage: 'Stamps' },
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
@ -81,6 +83,13 @@ class FilterBar extends PureComponent {
>
<Icon id='star' icon={StarIcon} />
</button>
<button
className={selectedFilter === 'emoji_reaction' ? 'active' : ''}
onClick={this.onClick('emoji_reaction')}
title={intl.formatMessage(tooltips.emojiReactions)}
>
<Icon id='smile-o' icon={EmojiReactionIcon} fixedWidth />
</button>
<button
className={selectedFilter === 'reblog' ? 'active' : ''}
onClick={this.onClick('reblog')}

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
@ -27,9 +28,11 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import FollowRequestContainer from '../containers/follow_request_container';
import Report from './report';
import EmojiView from '../../../components/emoji_view';
const messages = defineMessages({
favourite: { id: 'notification.favourite', defaultMessage: '{name} favorited your status' },
emojiReaction: { id: 'notification.emoji_reaction', defaultMessage: '{name} reacted your status with emoji' },
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
@ -183,6 +186,7 @@ class Notification extends ImmutablePureComponent {
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
unread={this.props.unread}
withoutEmojiReactions={true}
/>
);
}
@ -211,6 +215,41 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
withoutEmojiReactions={true}
/>
</div>
</HotKeys>
);
}
renderEmojiReaction (notification, link) {
const { intl, unread } = this.props;
const emoji_reaction = notification.get('emoji_reaction');
return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-emoji_reaction focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.emojiReaction, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__emoji_reaction-icon-wrapper'>
<EmojiView name={emoji_reaction.get('name')} url={emoji_reaction.get('url')} staticUrl={emoji_reaction.get('static_url')} className='star-icon' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.emoji_reaction' defaultMessage='{name} reacted your status with emoji' values={{ name: link }} />
</span>
</div>
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={!!this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
withoutEmojiReactions={true}
/>
</div>
</HotKeys>
@ -241,6 +280,7 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
withoutEmojiReactions={true}
/>
</div>
</HotKeys>
@ -276,6 +316,7 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
withoutEmojiReactions={true}
/>
</div>
</HotKeys>
@ -311,6 +352,7 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
withoutEmojiReactions={true}
/>
</div>
</HotKeys>
@ -352,6 +394,7 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
withoutEmojiReactions={true}
/>
</div>
</HotKeys>
@ -421,6 +464,8 @@ class Notification extends ImmutablePureComponent {
return this.renderMention(notification);
case 'favourite':
return this.renderFavourite(notification, link);
case 'emoji_reaction':
return this.renderEmojiReaction(notification, link);
case 'reblog':
return this.renderReblog(notification, link);
case 'status':

View File

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

View File

@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
@ -84,6 +85,7 @@ class ActionBar extends PureComponent {
onReblog: PropTypes.func.isRequired,
onQuote: PropTypes.func,
onFavourite: PropTypes.func.isRequired,
onEmojiReact: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
@ -206,6 +208,10 @@ class ActionBar extends PureComponent {
navigator.clipboard.writeText(url);
};
handleEmojiPick = (data) => {
this.props.onEmojiReact(this.props.status, data);
};
render () {
const { status, relationship, intl } = this.props;
const { signedIn, permissions } = this.context.identity;
@ -327,6 +333,7 @@ class ActionBar extends PureComponent {
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'><IconButton disabled={!publicStatus} title={StatusActionBar.quoteTitle(intl, messages, publicStatus)} icon='format-quote' iconComponent={FormatQuoteIcon} onClick={this.handleQuoteClick} /></div>
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>
<div className='detailed-status__button'><EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /></div>
<div className='detailed-status__action-bar-dropdown'>
<DropdownMenuContainer icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
</div>

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusEmojiReactionsBar from '../../../components/status_emoji_reactions_bar';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
@ -53,6 +54,8 @@ class DetailedStatus extends ImmutablePureComponent {
onToggleMediaVisibility: PropTypes.func,
onQuoteToggleMediaVisibility: PropTypes.func,
...WithRouterPropTypes,
onEmojiReact: PropTypes.func,
onUnEmojiReact: PropTypes.func,
};
state = {
@ -171,6 +174,7 @@ class DetailedStatus extends ImmutablePureComponent {
const reblogIcon = 'retweet';
const reblogIconComponent = RepeatIcon;
let favouriteLink = '';
let emojiReactionsLink = '';
let edited = '';
if (this.props.measureHeight) {
@ -261,9 +265,14 @@ class DetailedStatus extends ImmutablePureComponent {
return (<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia}
card={status.get('card', null)} quote={quote} />);
}
}
let emojiReactionsBar = null;
if (status.get('emoji_reactions')) {
const emojiReactions = status.get('emoji_reactions');
emojiReactionsBar = <StatusEmojiReactionsBar emojiReactions={emojiReactions} status={status} onEmojiReact={this.props.onEmojiReact} onUnEmojiReact={this.props.onUnEmojiReact} />;
}
if (status.get('application')) {
applicationLink = <> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></>;
}
@ -318,6 +327,22 @@ class DetailedStatus extends ImmutablePureComponent {
);
}
if (this.context.router) {
emojiReactionsLink = (
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/emoji_reactions`} className='detailed-status__link'>
<Icon id='smile-o' />
<FormattedMessage id='status.emoji' defaultMessage='Emoji' />
</Link>
);
} else {
emojiReactionsLink = (
<a href={`/interact/${status.get('id')}?type=emoji_reactions`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id='smile-o' />
<FormattedMessage id='status.emoji' defaultMessage='Emoji' />
</a>
);
}
if (status.get('edited_at')) {
edited = (
<>
@ -355,10 +380,12 @@ class DetailedStatus extends ImmutablePureComponent {
{expanded && hashtagBar}
{emojiReactionsBar}
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink}
</div>
</div>
</div>

View File

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

View File

@ -41,6 +41,8 @@ import {
import {
favourite,
unfavourite,
emojiReact,
unEmojiReact,
bookmark,
unbookmark,
reblog,
@ -279,6 +281,16 @@ class Status extends ImmutablePureComponent {
}
};
handleEmojiReact = (status, emoji) => {
const { dispatch } = this.props;
dispatch(emojiReact(status, emoji));
};
handleUnEmojiReact = (status, emoji) => {
const { dispatch } = this.props;
dispatch(unEmojiReact(status, emoji));
};
handlePin = (status) => {
if (status.get('pinned')) {
this.props.dispatch(unpin(status));
@ -749,6 +761,8 @@ class Status extends ImmutablePureComponent {
onToggleMediaVisibility={this.handleToggleMediaVisibility}
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
pictureInPicture={pictureInPicture}
onEmojiReact={this.handleEmojiReact}
onUnEmojiReact={this.handleUnEmojiReact}
/>
<ActionBar
@ -756,6 +770,7 @@ class Status extends ImmutablePureComponent {
status={status}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onEmojiReact={this.handleEmojiReact}
onReblog={this.handleReblogClick}
onQuote={this.handleQuoteClick}
onBookmark={this.handleBookmarkClick}

View File

@ -45,6 +45,7 @@ import {
Following,
Reblogs,
Favourites,
EmojiReactions,
DirectTimeline,
HashtagTimeline,
Notifications,
@ -214,6 +215,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/directory' component={Directory} content={children} />
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
<WrappedRoute path='/@:acct/:statusId/emoji_reactions' component={EmojiReactions} content={children} />
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
@ -224,6 +226,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/statuses/:statusId/emoji_reactions' component={EmojiReactions} content={children} />
{/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />

View File

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

View File

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

View File

@ -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}さんがあなたにフォローリクエストしました",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
class PotentialFriendshipTracker
EXPIRE_AFTER = 90.days.seconds
MAX_ITEMS = 80
WEIGHTS = {
reply: 1,
emoji_reaction: 2,
favourite: 10,
reblog: 20,
}.freeze
class << self
include Redisable
def record(account_id, target_account_id, action)
return if account_id == target_account_id
key = "interactions:#{account_id}"
weight = WEIGHTS[action]
redis.zincrby(key, weight, target_account_id)
redis.zremrangebyrank(key, 0, -MAX_ITEMS)
redis.expire(key, EXPIRE_AFTER)
end
def remove(account_id, target_account_id)
redis.zrem("interactions:#{account_id}", target_account_id)
end
end
end

View File

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

View File

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

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: emoji_reactions
#
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# status_id :bigint(8) not null
# name :string default(""), not null
# custom_emoji_id :bigint(8)
# uri :string
# created_at :datetime not null
# updated_at :datetime not null
#
class EmojiReaction < ApplicationRecord
include Paginable
EMOJI_REACTION_LIMIT = 32767
EMOJI_REACTION_PER_ACCOUNT_LIMIT = 5
update_index('statuses', :status)
belongs_to :account, inverse_of: :emoji_reactions
belongs_to :status, inverse_of: :emoji_reactions
belongs_to :custom_emoji, optional: true
has_one :notification, as: :activity, dependent: :destroy
after_create :refresh_cache
after_destroy :refresh_cache
after_destroy :invalidate_cleanup_info
private
def refresh_cache
status&.refresh_emoji_reactions_grouped_by_name!
end
def invalidate_cleanup_info
return unless status&.account_id == account_id && account.local?
account.statuses_cleanup_policy&.invalidate_last_inspected(status, :unfav)
end
def paginate_by_max_id(limit, max_id = nil, since_id = nil)
query = order(arel_table[:id].desc).limit(limit)
query = query.where(arel_table[:id].lt(max_id)) if max_id.present?
query = query.where(arel_table[:id].gt(since_id)) if since_id.present?
query
end
end

View File

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

View File

@ -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 = []

View File

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

View File

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

View File

@ -27,6 +27,10 @@ class StatusPolicy < ApplicationPolicy
show? && !blocking_author?
end
def emoji_reaction?
show? && !blocking_author?
end
def destroy?
owned?
end

View File

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

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
class ActivityPub::EmojiReactionSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :content
attribute :virtual_object, key: :object
has_many :virtual_tags, key: :tag, unless: -> { object.custom_emoji.nil? }
def id
[ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join
end
def type
'EmojiReact'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def virtual_object
ActivityPub::TagManager.instance.uri_for(object.status)
end
def content
object.custom_emoji.nil? ? object.name : ":#{object.name}:"
end
def virtual_tags
[object.custom_emoji]
end
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
end
end

View File

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

View File

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

View File

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

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class REST::EmojiReactedBySlimSerializer < ActiveModel::Serializer
belongs_to :account, serializer: REST::AccountSerializer
has_many :emoji_reactions, serializer: REST::EmojiReactedBySlimReactionSerializer
def attributes
{ account => emoji_reactions }
end
private
def account
object.first
end
def emoji_reactions
object.last
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
class REST::EmojiReactionAccountSerializer < ActiveModel::Serializer
include RoutingHelper
include FormattingHelper
attributes :id, :name
attribute :url, if: :custom_emoji?
attribute :static_url, if: :custom_emoji?
attribute :domain, if: :custom_emoji?
belongs_to :account, serializer: REST::AccountSerializer
def id
object.id.to_s
end
def url
full_asset_url(object.custom_emoji.image.url)
end
def static_url
full_asset_url(object.custom_emoji.image.url(:static))
end
def domain
object.custom_emoji.domain
end
def custom_emoji?
object.custom_emoji.present?
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class REST::EmojiReactionSerializer < ActiveModel::Serializer
attributes :name
attribute :count, if: :count?
attribute :url, if: :custom_emoji?
attribute :static_url, if: :custom_emoji?
attribute :domain, if: :custom_emoji?
attribute :account_ids, if: :account_ids?
def count?
object.respond_to?(:count)
end
def custom_emoji?
object.respond_to?(:url)
end
def account_ids?
object.respond_to?(:account_ids)
end
end

View File

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

View File

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

View File

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

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
class REST::NotifyEmojiReactionSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :name
attribute :count, if: :count?
attribute :url, if: :custom_emoji?
attribute :static_url, if: :custom_emoji?
attribute :domain, if: :custom_emoji?
def count?
object.respond_to?(:count)
end
def custom_emoji?
object.respond_to?(:custom_emoji) && object.custom_emoji.present?
end
def account_ids?
object.respond_to?(:account_ids)
end
def url
full_asset_url(object.custom_emoji.image.url)
end
def static_url
full_asset_url(object.custom_emoji.image.url(:static))
end
def domain
object.custom_emoji.domain
end
end

View File

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

View File

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

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
module AccountLimitable
def scope_status(status)
case status.visibility.to_sym
when :public, :unlisted
#scope_local.merge(scope_list_following_account(status.account))
scope_local
when :private
scope_account_local_followers(status.account)
when :limited
scope_status_mentioned(status)
else
scope_status_mentioned(status)
end
end
def scope_local
Account.local.select(:id)
end
def scope_account_local_followers(account)
account.followers_for_local_distribution.select(:id).reorder(nil)
end
def scope_status_mentioned(status)
status.active_mentions.where.joins(:account).merge(Account.local).select('account_id AS id').reorder(nil)
end
# TODO: not work
def scope_list_following_account(account)
account.lists_for_local_distribution.select(:id).reorder(nil)
end
def scope_tag_following_account(status)
TagFollow.where(tag_id: @status.tags.map(&:id)).select('account_id AS id').reorder(nil)
end
end

View File

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

View File

@ -0,0 +1,78 @@
# frozen_string_literal: true
class EmojiReactService < BaseService
include Authorization
include Payloadable
include Redisable
# React a status with emoji and notify remote user
# @param [Account] account
# @param [Status] status
# @param [string] name
# @return [Favourite]
def call(account, status, name)
authorize_with account, status, :emoji_reaction?
emoji_reaction = EmojiReaction.find_by(account: account, status: status, name: name)
return emoji_reaction unless emoji_reaction.nil?
shortcode, domain = name.split('@')
custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain)
emoji_reaction = EmojiReaction.create!(account: account, status: status, name: shortcode, custom_emoji: custom_emoji)
Trends.statuses.register(status)
create_notification(emoji_reaction)
notify_to_followers(emoji_reaction)
bump_potential_friendship(account, status)
write_stream(emoji_reaction)
emoji_reaction
end
private
def create_notification(emoji_reaction)
status = emoji_reaction.status
if status.account.local?
LocalNotificationWorker.perform_async(status.account_id, emoji_reaction.id, 'EmojiReaction', 'emoji_reaction')
elsif status.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(emoji_reaction), emoji_reaction.account_id, status.account.inbox_url)
end
end
def notify_to_followers(emoji_reaction)
status = emoji_reaction.status
return unless status.account.local?
ActivityPub::RawDistributionWorker.perform_async(build_json(emoji_reaction), status.account_id)
end
def write_stream(emoji_reaction)
emoji_group = emoji_reaction.status.emoji_reactions_grouped_by_name
.find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) }
emoji_group['status_id'] = emoji_reaction.status_id.to_s
FeedAnyJsonWorker.perform_async(render_emoji_reaction(emoji_group), emoji_reaction.status_id, emoji_reaction.account_id)
end
def bump_potential_friendship(account, status)
ActivityTracker.increment('activity:interactions')
return if account.following?(status.account_id)
PotentialFriendshipTracker.record(account.id, status.account_id, :emoji_reaction)
end
def build_json(emoji_reaction)
Oj.dump(serialize_payload(emoji_reaction, ActivityPub::EmojiReactionSerializer))
end
def render_emoji_reaction(emoji_group)
# @rendered_emoji_reaction ||= InlineRenderer.render(HashObject.new(emoji_group), nil, :emoji_reaction)
@render_emoji_reaction ||= Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json)
end
end

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
class UnEmojiReactService < BaseService
include Redisable
include Payloadable
def call(account_id, status_id, emoji_reaction = nil)
@account_id = account_id
@account = Account.find(account_id)
@status = Status.find(status_id)
p '==================================== DEBUG'
p emoji_reaction
if emoji_reaction
emoji_reaction.destroy!
create_notification(emoji_reaction) if !@account.local? && @account.activitypub?
notify_to_followers(emoji_reaction) if @account.local?
write_stream(emoji_reaction)
else
bulk(@account, @status)
end
emoji_reaction
end
private
def bulk(account, status)
EmojiReaction.where(account: account).where(status: status).each do |emoji_reaction|
call(account.id, status.id, emoji_reaction)
end
end
def create_notification(emoji_reaction)
ActivityPub::DeliveryWorker.perform_async(build_json(emoji_reaction), @account_id, @account.inbox_url)
end
def notify_to_followers(emoji_reaction)
ActivityPub::RawDistributionWorker.perform_async(build_json(emoji_reaction), @account_id)
end
def write_stream(emoji_reaction)
emoji_group = @status.emoji_reactions_grouped_by_name
.find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) }
if emoji_group
emoji_group['status_id'] = @status.id.to_s
else
# name: emoji_reaction.name, count: 0, domain: emoji_reaction.domain
emoji_group = { 'name' => emoji_reaction.name, 'count' => 0, 'account_ids' => [], 'status_id' => @status.id.to_s }
emoji_group['domain'] = emoji_reaction.custom_emoji.domain if emoji_reaction.custom_emoji
end
FeedAnyJsonWorker.perform_async(render_emoji_reaction(emoji_group), @status.id, @account_id)
end
def build_json(emoji_reaction)
Oj.dump(serialize_payload(emoji_reaction, ActivityPub::UndoEmojiReactionSerializer))
end
def render_emoji_reaction(emoji_group)
# @rendered_emoji_reaction ||= InlineRenderer.render(emoji_group, nil, :emoji_reaction)
Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json)
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
class FeedAnyJsonWorker
include Sidekiq::Worker
include Redisable
include Lockable
include AccountLimitable
def perform(payload_json, status_id, my_account_id = nil)
p '========================================= DEBUG AAA'
redis.publish("timeline:#{my_account_id}", payload_json) if my_account_id.present?
p '========================================= DEBUG AA'
p status_id
p status_id.to_i
status = Status.find(status_id.to_i)
p '========================================= DEBUG AAAAAAAA'
p status.present?
if status.present?
p '========================================= DEBUG A'
p scope_status(status)
p '========================================= DEBUG C'
scope_status(status).find_each do |account|
p '========================================= DEBUG D'
p redis.exists?("subscribed:timeline:#{account.id}")
redis.publish("timeline:#{account.id}", payload_json) if redis.exists?("subscribed:timeline:#{account.id}")
end
if status.visibility.to_sym != :public && status.visibility.to_sym != :unlisted && status.account_id != my_account_id &&
redis.exists?("subscribed:timeline:#{status.account_id}")
redis.publish("timeline:#{status.account_id}", payload_json)
end
end
true
rescue ActiveRecord::RecordNotFound
true
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class UnEmojiReactWorker
include Sidekiq::Worker
def perform(account_id, status_id, emoji = nil)
emoji_reaction = nil
if emoji
shortcode, domain = emoji.split('@')
emoji_reaction = EmojiReaction.where(account_id: account_id).where(status_id: status_id).where(name: shortcode)
.find { |reaction| domain == '' ? reaction.custom_emoji.nil? : reaction.custom_emoji&.domain == domain }
end
UnEmojiReactService.new.call(account_id.to_i, status_id.to_i, emoji_reaction)
rescue ActiveRecord::RecordNotFound
true
end
end

View File

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

View File

@ -1436,6 +1436,10 @@ ja:
body: "%{name}さんにお気に入り登録された、あなたのトゥートがあります:"
subject: "%{name}さんにお気に入りに登録されました"
title: 新たなお気に入り登録
emoji_reaction:
body: "%{name}さんに絵文字リアクションされた、あなたの投稿があります:"
subject: "%{name}さんに絵文字リアクションされました"
title: 新たな絵文字リアクション
follow:
body: "%{name}さんにフォローされています!"
subject: "%{name}さんにフォローされています"

View File

@ -29,6 +29,7 @@ Rails.application.routes.draw do
/lists/(*any)
/notifications
/favourites
/emoji_reactions
/bookmarks
/pinned
/start/(*any)

View File

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

View File

@ -0,0 +1,13 @@
class CreateEmojiReactions < ActiveRecord::Migration[6.1]
def change
create_table :emoji_reactions do |t|
t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade }
t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade }
t.string :name, null: false, default: ''
t.belongs_to :custom_emoji, foreign_key: { on_delete: :cascade }
t.string :uri
t.datetime :created_at, null: false
t.datetime :updated_at, null: false
end
end
end

View File

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

View File

@ -439,6 +439,19 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) do
t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true
end
create_table "emoji_reactions", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "status_id", null: false
t.string "name", default: "", null: false
t.bigint "custom_emoji_id"
t.string "uri"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_emoji_reactions_on_account_id"
t.index ["custom_emoji_id"], name: "index_emoji_reactions_on_custom_emoji_id"
t.index ["status_id"], name: "index_emoji_reactions_on_status_id"
end
create_table "encrypted_messages", id: :bigint, default: -> { "timestamp_id('encrypted_messages'::text)" }, force: :cascade do |t|
t.bigint "device_id"
t.bigint "from_account_id"
@ -967,6 +980,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) do
t.bigint "favourites_count", default: 0, null: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.string "emoji_reactions"
t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true
end
@ -1226,6 +1240,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) do
add_foreign_key "devices", "accounts", on_delete: :cascade
add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade
add_foreign_key "email_domain_blocks", "email_domain_blocks", column: "parent_id", on_delete: :cascade
add_foreign_key "emoji_reactions", "accounts", on_delete: :cascade
add_foreign_key "emoji_reactions", "custom_emojis", on_delete: :cascade
add_foreign_key "emoji_reactions", "statuses", on_delete: :cascade
add_foreign_key "encrypted_messages", "accounts", column: "from_account_id", on_delete: :cascade
add_foreign_key "encrypted_messages", "devices", on_delete: :cascade
add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade

61
lib/mastodon/cache_cli.rb Normal file
View File

@ -0,0 +1,61 @@
# frozen_string_literal: true
require_relative '../../config/boot'
require_relative '../../config/environment'
require_relative 'cli_helper'
module Mastodon
class CacheCLI < Thor
include CLIHelper
def self.exit_on_failure?
true
end
desc 'clear', 'Clear out the cache storage'
def clear
Rails.cache.clear
say('OK', :green)
end
option :concurrency, type: :numeric, default: 5, aliases: [:c]
option :verbose, type: :boolean, aliases: [:v]
desc 'recount TYPE', 'Update hard-cached counters'
long_desc <<~LONG_DESC
Update hard-cached counters of TYPE by counting referenced
records from scratch. TYPE can be "accounts" or "statuses".
It may take a very long time to finish, depending on the
size of the database.
LONG_DESC
def recount(type)
case type
when 'accounts'
processed, = parallelize_with_progress(Account.local.includes(:account_stat)) do |account|
account_stat = account.account_stat
account_stat.following_count = account.active_relationships.count
account_stat.followers_count = account.passive_relationships.count
account_stat.statuses_count = account.statuses.where.not(visibility: :direct).count
account_stat.save if account_stat.changed?
end
when 'statuses'
processed, = parallelize_with_progress(Status.includes(:status_stat)) do |status|
status_stat = status.status_stat
status_stat.replies_count = status.replies.where.not(visibility: :direct).count
status_stat.reblogs_count = status.reblogs.count
status_stat.favourites_count = status.favourites.count
status_stat.emoji_reactions = status.generate_emoji_reactions_grouped_by_name
status_stat.save if status_stat.changed?
end
else
say("Unknown type: #{type}", :red)
exit(1)
end
say
say("OK, recounted #{processed} records", :green)
end
end
end