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