Compare commits

...

46 Commits

Author SHA1 Message Date
1cd3856fb8 Merge branch 'features/main' into features/emoji 2024-02-26 11:35:14 +09:00
c4712ed22c Merge pull request #132 from Y-zu-don-maintenance-org/features/quote
Features/quote
2024-02-04 11:40:37 +09:00
f16b068156 Merge remote-tracking branch 'origin/features/main' into features/quote 2024-02-04 11:40:23 +09:00
a0474b9a53 Merge pull request #131 from Y-zu-don-maintenance-org/features/4.3alpha
Features/4.3alpha
2024-02-02 22:18:48 +09:00
kyori19
4b2272c34e Merge remote-tracking branch 'upstream/main' into custom/quote 2024-02-01 20:00:27 +00:00
kyori19
d1369ca959 Merge remote-tracking branch 'upstream/main' into custom/quote 2024-01-05 07:04:07 +00:00
03e6239278 Merge pull request #128 from Y-zu-don-maintenance-org/features/main
fix tabs bar bottom
2024-01-02 19:36:59 +09:00
2b2851b05b fix css 2024-01-01 11:08:57 +00:00
05611d249b fix css 2024-01-01 10:43:45 +00:00
0bf9f469c5 fix emoji undo error 2024-01-01 10:09:41 +00:00
KMY
c81b901f08 Fix custom emoji error 2024-01-01 09:59:28 +00:00
KMY
7cf177d11e Remove debug code 2024-01-01 09:58:53 +00:00
KMY
96220dc2f4 Fix browser error (IconButton title) 2024-01-01 09:34:23 +00:00
KMY
bcb6f80ac3 Fix emoji reaction popup position 2024-01-01 09:32:00 +00:00
KMY
5c786b4868 Add no-spacing custom emoji code support 2024-01-01 09:31:41 +00:00
KMY
93e61bf392 Remove muted accounts emoji reaction 2024-01-01 09:31:05 +00:00
KMY
3f5142c2f8 Move emoji reaction limitation constraints 2024-01-01 09:30:22 +00:00
1f82788629 fix emoji reaction realtime 2024-01-01 09:22:02 +00:00
3ad062a886 fix notification 500 2024-01-01 09:10:17 +00:00
57eed5696e fix conflict 2023-12-31 11:46:54 +00:00
KMY
0190688af0 Follow fedibird delete emoji-reaction api 2023-12-31 11:44:38 +00:00
KMY
b9f8e7731b Fix third party app emoji reaction feature 2023-12-31 11:41:57 +00:00
KMY
fed10012e4 Remove emoji reactions view by notification 2023-12-31 11:41:04 +00:00
KMY
f8ca193b89 Fix third party app emoji reaction feature 2023-12-31 11:23:31 +00:00
KMY
d61e7dbdc1 Fix emoji reaction push notifications 2023-12-31 11:21:48 +00:00
KMY
fb1c2abc7e Fix remove emoji reactions bulk 2023-12-31 11:19:25 +00:00
KMY
5fc29d03dd Fix custom emoji in notification 2023-12-31 11:18:33 +00:00
KMY
2a912a0698 Fix emoji count color 2023-12-31 11:16:33 +00:00
KMY
5baee4e1ca Add emoji icon support on notification (expects custom) 2023-12-31 11:16:12 +00:00
KMY
3b5d2b3b35 Add emoji link on detailed status 2023-12-31 11:14:08 +00:00
KMY
837c572d8b Add emoji_reactioned_by_slim api (not work) 2023-12-31 11:12:17 +00:00
KMY
d7ce457a3f Add emoji reaction detail status 2023-12-31 11:10:07 +00:00
KMY
0ea43929b7 Add emoji reaction notification support 2023-12-31 09:58:07 +00:00
KMY
90b9b5d893 Fix streaming emoji reaction self only 2023-12-31 09:57:11 +00:00
KMY
546c2417e9 Change activitypub type Like to EmojiReact 2023-12-31 09:56:49 +00:00
KMY
68c7def8d3 Add activitypub emoji_reaction receiving 2023-12-31 09:55:50 +00:00
KMY
432b68c5d1 Create new stream event type: emoji_reaction 2023-12-31 09:25:29 +00:00
KMY
5b9031f36e Fix rubocop lint 2023-12-31 09:14:30 +00:00
KMY
88e9b8d4fa Add emoji react api POST method (same PUT) 2023-12-31 09:06:41 +00:00
KMY
3747409526 Add emoji reaction deletion api 2023-12-31 09:04:57 +00:00
KMY
ba989d9db6 Add api for set emoji reactions to toot 2023-12-31 08:59:58 +00:00
KMY
53e4c6adff Fix ruby lint 2023-12-31 08:50:08 +00:00
KMY
471540d9aa Add emoji reaction bar into status view 2023-12-31 08:49:45 +00:00
KMY
dca26cc504 Add emoji_reactions property to status api object 2023-12-31 08:29:16 +00:00
KMY
b88e3942cc Create emoji reactions table 2023-12-31 08:27:16 +00:00
KMY
e443f3c90a Add emoji-reaction icons as an item of action bar 2023-12-31 08:25:38 +00:00
94 changed files with 2078 additions and 157 deletions

View File

@@ -1,8 +1,8 @@
# frozen_string_literal: true
class Api::BaseController < ApplicationController
DEFAULT_STATUSES_LIMIT = 20
DEFAULT_ACCOUNTS_LIMIT = 40
DEFAULT_STATUSES_LIMIT = 20
DEFAULT_ACCOUNTS_LIMIT = 40
include Api::RateLimitHeaders
include Api::AccessTokenTrackingConcern

View File

@@ -8,9 +8,9 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
def index
cache_if_unauthenticated!
@statuses = load_statuses
@statuses = load_statuses
accounts = @statuses.filter_map { |status| status.quote&.account }.uniq
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
accounts = Account.where(id: account_ids)
render json: @statuses, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
account_relationships: AccountRelationshipsPresenter.new(accounts, current_user&.account_id)

View File

@@ -6,9 +6,9 @@ class Api::V1::BookmarksController < Api::BaseController
after_action :insert_pagination_headers
def index
@statuses = load_statuses
@statuses = load_statuses
accounts = @statuses.filter_map { |status| status.quote&.account }.uniq
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
accounts = Account.where(id: account_ids)
render json: @statuses, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
account_relationships: AccountRelationshipsPresenter.new(accounts, current_user&.account_id)

View File

@@ -6,9 +6,9 @@ class Api::V1::FavouritesController < Api::BaseController
after_action :insert_pagination_headers
def index
@statuses = load_statuses
@statuses = load_statuses
accounts = @statuses.filter_map { |status| status.quote&.account }.uniq
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
accounts = Account.where(id: account_ids)
render json: @statuses, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
account_relationships: AccountRelationshipsPresenter.new(accounts, current_user&.account_id)

View File

@@ -0,0 +1,71 @@
# frozen_string_literal: true
class Api::V1::Statuses::EmojiReactionedByAccountsController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:accounts' }
before_action :set_status
after_action :insert_pagination_headers
def index
@accounts = load_accounts
render json: @accounts, each_serializer: REST::EmojiReactionAccountSerializer
end
private
def load_accounts
scope = default_accounts
# scope = scope.where.not(account_id: current_account.excluded_from_timeline_account_ids) unless current_account.nil?
scope.merge(paginated_emoji_reactions).to_a
end
def default_accounts
EmojiReaction
.where(status_id: @status.id)
#.where(account: { suspended_at: nil })
end
def paginated_emoji_reactions
EmojiReaction.paginate_by_max_id(
limit_param(1000), #limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_status_emoji_reactioned_by_index_url pagination_params(max_id: pagination_max_id) if records_continue?
end
def prev_path
api_v1_status_emoji_reactioned_by_index_url pagination_params(since_id: pagination_since_id) unless @accounts.empty?
end
def pagination_max_id
@accounts.last.id
end
def pagination_since_id
@accounts.first.id
end
def records_continue?
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View File

@@ -0,0 +1,28 @@
# frozen_string_literal: true
class Api::V1::Statuses::EmojiReactionedByAccountsSlimController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:accounts' }
before_action :set_status
def index
@accounts = load_emoji_reactions
# TODO for serialize hash object
render json: @accounts, each_serializer: REST::EmojiReactedBySlimSerializer
end
private
def load_emoji_reactions
@status.generate_emoji_reactions_grouped_by_account
end
def set_status
@status = Status.find(params[:status_id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
end

View File

@@ -0,0 +1,64 @@
# frozen_string_literal: true
class Api::V1::Statuses::EmojiReactionsController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :write, :'write:emoji_reactions' }
before_action :require_user!
before_action :set_status, only: %i(create update destroy)
before_action :set_status_without_authorize, only: [:destroy]
def create
create_private(params[:emoji])
end
# For compatible with Fedibird API
def update
create_private(params[:id])
end
def destroy
emoji = params[:emoji] || params[:id]
if emoji
shortcode, domain = emoji.split('@')
emoji_reaction = EmojiReaction.where(account_id: current_account.id).where(status_id: @status.id).where(name: shortcode)
.find { |reaction| domain == '' ? reaction.custom_emoji.nil? : reaction.custom_emoji&.domain == domain }
authorize @status, :show? if emoji_reaction.nil?
end
UnEmojiReactWorker.perform_async(current_account.id, @status.id, emoji)
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(
[@status], current_account.id, emoji_reactions_map: { @status.id => false }
)
rescue Mastodon::NotPermittedError
not_found
end
private
def create_private(emoji)
count = EmojiReaction.where(account: current_account, status: @status).count
if count >= EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT
bad_request
return
end
EmojiReactService.new.call(current_account, @status, emoji)
render json: @status, serializer: REST::StatusSerializer
end
def set_status
set_status_without_authorize
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
def set_status_without_authorize
@status = Status.find(params[:status_id])
end
end

View File

@@ -47,10 +47,10 @@ class Api::V1::StatusesController < Api::BaseController
loaded_ancestors = cache_collection(ancestors_results, Status)
loaded_descendants = cache_collection(descendants_results, Status)
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
accounts = statuses.filter_map { |status| status.quote&.account }.uniq
account_ids = statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
accounts = Account.where(id: account_ids)
render json: @context, serializer: REST::ContextSerializer,
relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id),

View File

@@ -10,8 +10,8 @@ class Api::V1::Timelines::HomeController < Api::V1::Timelines::BaseController
with_read_replica do
@statuses = load_statuses
@relationships = StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
accounts = @statuses.filter_map { |status| status.quote&.account }.uniq
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
accounts = Account.where(id: account_ids)
@account_relationships = AccountRelationshipsPresenter.new(accounts, current_user&.account_id)
end

View File

@@ -9,8 +9,8 @@ class Api::V1::Timelines::ListController < Api::V1::Timelines::BaseController
PERMITTED_PARAMS = %i(limit).freeze
def show
accounts = @statuses.filter_map { |status| status.quote&.account }.uniq
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
accounts = Account.where(id: account_ids)
render json: @statuses,
each_serializer: REST::StatusSerializer,

View File

@@ -7,9 +7,9 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
def show
cache_if_unauthenticated!
@statuses = load_statuses
@statuses = load_statuses
accounts = @statuses.filter_map { |status| status.quote&.account }.uniq
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
accounts = Account.where(id: account_ids)
render json: @statuses, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),

View File

@@ -8,9 +8,9 @@ class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
def show
cache_if_unauthenticated!
@statuses = load_statuses
@statuses = load_statuses
accounts = @statuses.filter_map { |status| status.quote&.account }.uniq
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
accounts = Account.where(id: account_ids)
render json: @statuses, each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),

View File

@@ -25,7 +25,6 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
export const COMPOSE_QUOTE_CANCEL = 'COMPOSE_QUOTE_CANCEL';
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_RESET = 'COMPOSE_RESET';
@@ -137,13 +136,7 @@ export function quoteCompose(status, routerHistory) {
ensureComposeIsVisible(getState, routerHistory);
};
};
export function cancelQuoteCompose() {
return {
type: COMPOSE_QUOTE_CANCEL,
};
};
}
export function resetCompose() {
return {

View File

@@ -40,6 +40,10 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.filtered = status.filtered.map(normalizeFilterResult);
}
if (status.emoji_reactions) {
normalStatus.emojiReactions = status.emoji_reactions;
}
// Only calculate these values when status first encountered and
// when the underlying values change. Otherwise keep the ones
// already in the reducer

View File

@@ -15,6 +15,10 @@ export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
export const EMOJIREACT_REQUEST = 'EMOJIREACT_REQUEST';
export const EMOJIREACT_SUCCESS = 'EMOJIREACT_SUCCESS';
export const EMOJIREACT_FAIL = 'EMOJIREACT_FAIL';
export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
export const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
@@ -23,6 +27,10 @@ export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
export const UNEMOJIREACT_REQUEST = 'UNEMOJIREACT_REQUEST';
export const UNEMOJIREACT_SUCCESS = 'UNEMOJIREACT_SUCCESS';
export const UNEMOJIREACT_FAIL = 'UNEMOJIREACT_FAIL';
export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
@@ -33,7 +41,11 @@ export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST';
export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
export const EMOJI_REACTIONS_FETCH_REQUEST = 'EMOJI_REACTIONS_FETCH_REQUEST';
export const EMOJI_REACTIONS_FETCH_SUCCESS = 'EMOJI_REACTIONS_FETCH_SUCCESS';
export const EMOJI_REACTIONS_FETCH_FAIL = 'EMOJI_REACTIONS_FETCH_FAIL';
export const PIN_REQUEST = 'PIN_REQUEST';
export const PIN_SUCCESS = 'PIN_SUCCESS';
@@ -205,6 +217,88 @@ export function unfavouriteFail(status, error) {
};
}
export function emojiReact(status, emoji) {
return function (dispatch, getState) {
dispatch(emojiReactRequest(status, emoji));
const api_emoji = typeof emoji !== 'string' ? (emoji.custom ? (emoji.name + (emoji.domain || '')) : emoji.native) : emoji;
api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_reactions`, { emoji: api_emoji }).then(function () {
dispatch(emojiReactSuccess(status, emoji));
}).catch(function (error) {
dispatch(emojiReactFail(status, emoji, error));
});
};
}
export function unEmojiReact(status, emoji) {
return (dispatch, getState) => {
dispatch(unEmojiReactRequest(status, emoji));
api(getState).post(`/api/v1/statuses/${status.get('id')}/emoji_unreaction`, { emoji }).then(() => {
dispatch(unEmojiReactSuccess(status, emoji));
}).catch(error => {
dispatch(unEmojiReactFail(status, emoji, error));
});
};
}
export function emojiReactRequest(status, emoji) {
return {
type: EMOJIREACT_REQUEST,
status: status,
emoji: emoji,
skipLoading: true,
};
}
export function emojiReactSuccess(status, emoji) {
return {
type: EMOJIREACT_SUCCESS,
status: status,
emoji: emoji,
skipLoading: true,
};
}
export function emojiReactFail(status, emoji, error) {
return {
type: EMOJIREACT_FAIL,
status: status,
emoji: emoji,
error: error,
skipLoading: true,
};
}
export function unEmojiReactRequest(status, emoji) {
return {
type: UNEMOJIREACT_REQUEST,
status: status,
emoji: emoji,
skipLoading: true,
};
}
export function unEmojiReactSuccess(status, emoji) {
return {
type: UNEMOJIREACT_SUCCESS,
status: status,
emoji: emoji,
skipLoading: true,
};
}
export function unEmojiReactFail(status, emoji, error) {
return {
type: UNEMOJIREACT_FAIL,
status: status,
emoji: emoji,
error: error,
skipLoading: true,
};
}
export function bookmark(status) {
return function (dispatch, getState) {
dispatch(bookmarkRequest(status));
@@ -441,6 +535,85 @@ export function expandFavouritesFail(id, error) {
};
}
export function fetchEmojiReactions(id) {
return (dispatch, getState) => {
dispatch(fetchEmojiReactionsRequest(id));
api(getState).get(`/api/v1/statuses/${id}/emoji_reactioned_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map((er) => er.account)));
dispatch(fetchEmojiReactionsSuccess(id, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(fetchEmojiReactionsFail(id, error));
});
};
}
export function fetchEmojiReactionsRequest(id) {
return {
type: EMOJI_REACTIONS_FETCH_REQUEST,
id,
};
}
export function fetchEmojiReactionsSuccess(id, accounts, next) {
return {
type: EMOJI_REACTIONS_FETCH_SUCCESS,
id,
accounts,
next,
};
}
export function fetchEmojiReactionsFail(id, error) {
return {
type: EMOJI_REACTIONS_FETCH_FAIL,
error,
};
}
export function expandEmojiReactions(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'emoji_reactioned_by', id, 'next']);
if (url === null) {
return;
}
dispatch(expandEmojiReactionsRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map((er) => er.account)));
dispatch(expandEmojiReactionsSuccess(id, response.data, next ? next.uri : null));
}).catch(error => dispatch(expandEmojiReactionsFail(id, error)));
};
}
export function expandEmojiReactionsRequest(id) {
return {
type: EMOJI_REACTIONS_EXPAND_REQUEST,
id,
};
}
export function expandEmojiReactionsSuccess(id, accounts, next) {
return {
type: EMOJI_REACTIONS_EXPAND_SUCCESS,
id,
accounts,
next,
};
}
export function expandEmojiReactionsFail(id, error) {
return {
type: EMOJI_REACTIONS_EXPAND_FAIL,
id,
error,
};
}
export function pin(status) {
return (dispatch, getState) => {
dispatch(pinRequest(status));

View File

@@ -21,6 +21,7 @@ import { submitMarkers } from './markers';
import { notificationsUpdate } from "./notifications_typed";
import { register as registerPushNotifications } from './push_notifications';
import { saveSettings } from './settings';
import { STATUS_EMOJI_REACTION_UPDATE } from './statuses';
export * from "./notifications_typed";
@@ -61,6 +62,15 @@ export const loadPending = () => ({
type: NOTIFICATIONS_LOAD_PENDING,
});
export function updateEmojiReactions(emoji_reaction, accountId) {
return (dispatch) =>
dispatch({
type: STATUS_EMOJI_REACTION_UPDATE,
emoji_reaction,
accountId,
});
}
export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => {
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
@@ -130,10 +140,13 @@ const excludeTypesFromFilter = filter => {
'follow',
'follow_request',
'favourite',
'emoji_reaction',
'reblog',
'status_reference',
'mention',
'poll',
'status',
'list_status',
'update',
'admin.sign_up',
'admin.report',

View File

@@ -42,6 +42,8 @@ export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
export const STATUS_EMOJI_REACTION_UPDATE = 'STATUS_EMOJI_REACTION_UPDATE';
export function fetchStatusRequest(id, skipLoading) {
return {
type: STATUS_FETCH_REQUEST,
@@ -361,7 +363,7 @@ export function hideQuote(ids) {
type: QUOTE_HIDE,
ids,
};
};
}
export function revealQuote(ids) {
if (!Array.isArray(ids)) {
@@ -373,3 +375,8 @@ export function revealQuote(ids) {
ids,
};
};
export const updateEmojiReaction = (emoji_reaction) => ({
type: STATUS_EMOJI_REACTION_UPDATE,
emoji_reaction,
});

View File

@@ -10,7 +10,7 @@ import {
deleteAnnouncement,
} from './announcements';
import { updateConversations } from './conversations';
import { updateNotifications, expandNotifications } from './notifications';
import { updateNotifications, expandNotifications, updateEmojiReactions } from './notifications';
import { updateStatus } from './statuses';
import {
updateTimeline,
@@ -24,6 +24,8 @@ import {
fillListTimelineGaps,
} from './timelines';
const { messages } = getLocale();
/**
* @param {number} max
* @returns {number}
@@ -102,6 +104,10 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
// @ts-expect-error
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
break;
case 'emoji_reaction':
// @ts-expect-error
dispatch(updateEmojiReactions(JSON.parse(data.payload), getState().getIn(['meta', 'me'])));
break;
case 'conversation':
// @ts-expect-error
dispatch(updateConversations(JSON.parse(data.payload)));

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
import PropTypes from 'prop-types';
import StatusEmojiReactionsBar from './status_emoji_reactions_bar';
import AttachmentList from './attachment_list';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
@@ -134,7 +137,7 @@ export const quote = (status, muted, quoteMuted, handleQuoteClick, handleExpande
return (
<div>
<div className='status__info'>
{identity(quoteStatus, null, null, true)}
{identity(quoteStatus, null, true)}
</div>
<StatusContent status={quoteStatus} onClick={handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={handleExpandedQuoteToggle} quote />
{media(quoteStatus, true)}
@@ -146,7 +149,6 @@ export const quote = (status, muted, quoteMuted, handleQuoteClick, handleExpande
<div
className={classNames('quote-status', `status-${quoteStatus.get('visibility')}`, { muted: muted })}
data-id={quoteStatus.get('id')}
dataurl={quoteStatus.get('url')}
>
{quoteInner}
</div>
@@ -164,6 +166,8 @@ class Status extends ImmutablePureComponent {
onClick: PropTypes.func,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onEmojiReact: PropTypes.func,
onUnEmojiReact: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
@@ -199,6 +203,7 @@ class Status extends ImmutablePureComponent {
}),
contextType: PropTypes.string,
...WithOptionalRouterPropTypes,
withoutEmojiReactions: PropTypes.bool,
};
// Avoid checking props that are functions (and whose equality will always
@@ -238,7 +243,7 @@ class Status extends ImmutablePureComponent {
handleToggleQuoteMediaVisibility = () => {
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
}
};
handleClick = e => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
@@ -253,10 +258,6 @@ class Status extends ImmutablePureComponent {
};
handlePrependAccountClick = e => {
this.handleAccountClick(e, false);
};
handleAccountClick = (e, proper = true) => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
@@ -266,17 +267,29 @@ class Status extends ImmutablePureComponent {
e.stopPropagation();
}
this._openProfile(proper);
this._openProfile(false);
};
handleQuoteClick = () => {
if (!this.props) {
handleAccountClick = (e) => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
const { status } = this.props;
this.props.history.push(`/statuses/${status.getIn(['reblog', 'quote', 'id'], status.getIn(['quote', 'id']))}`);
}
if (e) {
e.preventDefault();
e.stopPropagation();
}
const acct = e.currentTarget.getAttribute('data-acct');
this.props.history.push(`/@${acct}`);
};
handleQuoteClick = () => {
if (this.props.history) {
const status = this._properStatus();
this.props.history.push(`/@${status.getIn(['quote', 'account', 'acct'])}/${status.getIn(['quote', 'id'])}`);
}
};
handleQuoteUserClick = () =>{
if (!this.props) {
@@ -301,7 +314,7 @@ class Status extends ImmutablePureComponent {
handleExpandedQuoteToggle = () => {
this.props.onQuoteToggleHidden(this._properStatus());
}
};
getAttachmentAspectRatio () {
const attachments = this._properStatus().get('media_attachments');
@@ -554,9 +567,10 @@ class Status extends ImmutablePureComponent {
</div>
);
}
const media = (status, quote = false) => {
if (pictureInPicture.get('inUse')) {
return <PictureInPicturePlaceholder aspectRatio={this.getAttachmentAspectRatio()} />;
return <PictureInPicturePlaceholder aspectRatio={this.getAttachmentAspectRatio()} width={this.props.cachedMediaWidth} />;
} else if (status.get('media_attachments').size > 0) {
const language = status.getIn(['translation', 'language']) || status.get('language');
@@ -655,9 +669,9 @@ class Status extends ImmutablePureComponent {
return <AvatarOverlay account={status.get('account')} friend={account} />;
}
};
const identity = (status, account, _0, quote = false) => (
<a onClick={quote ? this.handleQuoteUserClick : this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' rel='noopener noreferrer'>
const identity = (status, account) => (
<a onClick={this.handleAccountClick} data-acct={status.getIn(['account', 'acct'])} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>
{statusAvatar(status, account)}
</div>
@@ -669,6 +683,12 @@ class Status extends ImmutablePureComponent {
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
let emojiReactionsBar = null;
if (!this.props.withoutEmojiReactions && status.get('emoji_reactions')) {
const emojiReactions = status.get('emoji_reactions');
emojiReactionsBar = <StatusEmojiReactionsBar emojiReactions={emojiReactions} status={status} onEmojiReact={this.props.onEmojiReact} onUnEmojiReact={this.props.onUnEmojiReact} />;
}
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
@@ -712,6 +732,8 @@ class Status extends ImmutablePureComponent {
{expanded && hashtagBar}
{emojiReactionsBar}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
</div>
</div>
@@ -721,4 +743,4 @@ class Status extends ImmutablePureComponent {
}
export default withOptionalRouter(injectIntl(Status));
export default connect(mapStateToProps)(withOptionalRouter(injectIntl(Status)));

View File

@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import EmojiPickerDropdown from '../features/compose/containers/emoji_picker_dropdown_container';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
@@ -11,6 +12,7 @@ import { connect } from 'react-redux';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
@@ -50,6 +52,7 @@ const messages = defineMessages({
quote: { id: 'status.quote', defaultMessage: 'Quote' },
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
emojiReaction: { id: 'status.emoji_reaction', defaultMessage: 'Emoji Reaction' },
bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
@@ -87,6 +90,7 @@ class StatusActionBar extends ImmutablePureComponent {
relationship: ImmutablePropTypes.map,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onEmojiReact: PropTypes.func,
onReblog: PropTypes.func,
onQuote: PropTypes.func,
onDelete: PropTypes.func,
@@ -149,6 +153,16 @@ class StatusActionBar extends ImmutablePureComponent {
}
};
handleEmojiPick = (data) => {
const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onEmojiReact(this.props.status, data);
} else {
this.props.onInteractionModal('favourite', this.props.status);
}
};
handleReblogClick = e => {
const { signedIn } = this.context.identity;
@@ -160,8 +174,8 @@ class StatusActionBar extends ImmutablePureComponent {
};
handleQuoteClick = () => {
this.props.onQuote(this.props.status, this.props.history);
}
this.props.onQuote(this.props.status);
};
handleBookmarkClick = () => {
this.props.onBookmark(this.props.status);
@@ -262,7 +276,7 @@ class StatusActionBar extends ImmutablePureComponent {
} else {
return intl.formatMessage(messages.cannot_quote);
}
}
};
render () {
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
@@ -402,13 +416,18 @@ class StatusActionBar extends ImmutablePureComponent {
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
const emojiPickerButton = (
<IconButton className='status__action-bar__button' title={intl.formatMessage(messages.emojiReaction)} icon='smile-o' />
);
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
<IconButton className='status__action-bar__button star-icon' animate active={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='quote' iconComponent={QuoteIcon} onClick={this.handleQuoteClick} />
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
<IconButton className='status__action-bar__button' disabled={!publicStatus} title={StatusActionBar.quoteTitle(intl, messages, publicStatus)} icon='format-quote' iconComponent={FormatQuoteIcon} onClick={this.handleQuoteClick} />
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
{filterButton}

View File

@@ -241,7 +241,7 @@ class StatusContent extends PureComponent {
};
render () {
const { status, intl, statusContent, quote } = this.props;
const { status, intl, quote, statusContent } = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
const renderReadMore = this.props.onClick && status.get('collapsed');

View File

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

View File

@@ -25,9 +25,11 @@ import {
import {
reblog,
favourite,
emojiReact,
bookmark,
unreblog,
unfavourite,
unEmojiReact,
unbookmark,
pin,
unpin,
@@ -140,6 +142,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
onEmojiReact (status, emoji) {
dispatch(emojiReact(status, emoji));
},
onUnEmojiReact (status, emoji) {
dispatch(unEmojiReact(status, emoji));
},
onBookmark (status) {
if (status.get('bookmarked')) {
dispatch(unbookmark(status));

View File

@@ -29,6 +29,7 @@ import { CharacterCounter } from './character_counter';
import { EditIndicator } from './edit_indicator';
import { NavigationBar } from './navigation_bar';
import { PollForm } from "./poll_form";
import { QuoteIndicator } from './quote_indicator';
import { ReplyIndicator } from './reply_indicator';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
@@ -296,6 +297,8 @@ class ComposeForm extends ImmutablePureComponent {
<UploadFormContainer />
<PollForm />
<QuoteIndicator />
<div className='compose-form__footer'>
<div className='compose-form__dropdowns'>
<PrivacyDropdownContainer disabled={this.props.isEditing} />

View File

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

View File

@@ -0,0 +1,62 @@
import { useCallback } from "react";
import { FormattedMessage, defineMessages, useIntl } from "react-intl";
import { Link } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import BarChart4BarsIcon from 'mastodon/../material-icons/400-24px/bar_chart_4_bars.svg?react';
import CloseIcon from 'mastodon/../material-icons/400-24px/close.svg?react';
import PhotoLibraryIcon from 'mastodon/../material-icons/400-24px/photo_library.svg?react';
import { cancelReplyCompose } from "mastodon/actions/compose";
import { Avatar } from "mastodon/components/avatar";
import { DisplayName } from "mastodon/components/display_name";
import { Icon } from "mastodon/components/icon";
import { IconButton } from "mastodon/components/icon_button";
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
export const QuoteIndicator = () => {
const intl = useIntl();
const dispatch = useDispatch();
const id = useSelector(state => state.getIn(['compose', 'quote_from']));
const status = useSelector(state => state.getIn(['statuses', id]));
const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
const handleCancelClick = useCallback(() => {
dispatch(cancelReplyCompose());
}, [dispatch]);
if (!status) {
return null;
}
const content = { __html: status.get('contentHtml') };
return (
<div className='edit-indicator'>
<div className='edit-indicator__header'>
<Link to={`/@${account.get('acct')}`} className='status__display-name'>
<Avatar account={account} size={46} />
<DisplayName account={account} />
</Link>
<div className='edit-indicator__cancel'>
<IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={handleCancelClick} inverted />
</div>
</div>
<div className='edit-indicator__content translate' dangerouslySetInnerHTML={content} />
{(status.get('poll') || status.get('media_attachments').size > 0) && (
<div className='edit-indicator__attachments'>
{status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
{status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
</div>
)}
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import {
favourite,
unreblog,
unfavourite,
emojiReact,
} from '../../../actions/interactions';
import {
hideStatus,
@@ -62,6 +63,10 @@ const mapDispatchToProps = dispatch => ({
}
},
onEmojiReact (status, emoji) {
dispatch(emojiReact(status, emoji));
},
onToggleHidden (status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));

View File

@@ -167,7 +167,7 @@ class Footer extends ImmutablePureComponent {
}
dispatch(quoteCompose(status, router.history));
}
};
handleQuoteClick = () => {
const { dispatch, askReplyConfirmation, intl } = this.props;
@@ -181,7 +181,7 @@ class Footer extends ImmutablePureComponent {
} else {
this._performQuote();
}
}
};
handleOpenClick = e => {
if (e.button !== 0 || !history) {

View File

@@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
@@ -11,6 +12,7 @@ import { connect } from 'react-redux';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
@@ -83,6 +85,7 @@ class ActionBar extends PureComponent {
onReblog: PropTypes.func.isRequired,
onQuote: PropTypes.func,
onFavourite: PropTypes.func.isRequired,
onEmojiReact: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
@@ -111,8 +114,8 @@ class ActionBar extends PureComponent {
};
handleQuoteClick = () => {
this.props.onQuote(this.props.status, this.props.history);
}
this.props.onQuote(this.props.status);
};
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
@@ -205,6 +208,10 @@ class ActionBar extends PureComponent {
navigator.clipboard.writeText(url);
};
handleEmojiPick = (data) => {
this.props.onEmojiReact(this.props.status, data);
};
render () {
const { status, relationship, intl } = this.props;
const { signedIn, permissions } = this.context.identity;
@@ -324,8 +331,9 @@ class ActionBar extends PureComponent {
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} iconComponent={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' 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='quote' iconComponent={QuoteIcon} 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'><EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /></div>
<div className='detailed-status__action-bar-dropdown'>
<DropdownMenuContainer icon='ellipsis-h' iconComponent={MoreHorizIcon} status={status} items={menu} direction='left' title={intl.formatMessage(messages.more)} />
</div>

View File

@@ -152,9 +152,9 @@ export default class Card extends PureComponent {
};
if (largeImage && card.get('type') === 'video') {
thumbnailStyle.aspectRatio = `16 / 9`;
thumbnailStyle.aspectRatio = `${quote ? 8 : 16} / 9`;
} else if (largeImage) {
thumbnailStyle.aspectRatio = '1.91 / 1';
thumbnailStyle.aspectRatio = `1.91 / ${quote ? 2 : 1}`;
} else {
thumbnailStyle.aspectRatio = 1;
}

View File

@@ -1,11 +1,12 @@
import PropTypes from 'prop-types';
import { FormattedDate, FormattedMessage } from 'react-intl';
import { FormattedDate, FormattedMessage, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusEmojiReactionsBar from '../../../components/status_emoji_reactions_bar';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
@@ -53,6 +54,8 @@ class DetailedStatus extends ImmutablePureComponent {
onToggleMediaVisibility: PropTypes.func,
onQuoteToggleMediaVisibility: PropTypes.func,
...WithRouterPropTypes,
onEmojiReact: PropTypes.func,
onUnEmojiReact: PropTypes.func,
};
state = {
@@ -62,7 +65,8 @@ class DetailedStatus extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.props.history) {
e.preventDefault();
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
const acct = e.currentTarget.getAttribute('data-acct');
this.props.history.push(`/@${acct}`);
}
e.stopPropagation();
@@ -78,17 +82,15 @@ class DetailedStatus extends ImmutablePureComponent {
handleExpandedQuoteToggle = () => {
this.props.onQuoteToggleHidden(this.props.status);
}
};
handleQuoteClick = () => {
if (!this.props) {
return;
if (this.props.history) {
const status = this._properStatus();
this.props.history.push(`/@${status.getIn(['quote', 'account', 'acct'])}/${status.getIn(['quote', 'id'])}`);
}
};
const { status } = this.props;
this.props.history.push(`/statuses/${status.getIn(['quote', 'id'])}`);
}
handleQuoteUserClick = () =>{
if (!this.props) {
return;
@@ -161,7 +163,7 @@ class DetailedStatus extends ImmutablePureComponent {
render () {
const status = this._properStatus();
const outerStyle = { boxSizing: 'border-box' };
const { compact, pictureInPicture, quoteMuted } = this.props;
const { compact, pictureInPicture, quoteMuted } = this.props;
if (!status) {
return null;
@@ -172,6 +174,7 @@ class DetailedStatus extends ImmutablePureComponent {
const reblogIcon = 'retweet';
const reblogIconComponent = RepeatIcon;
let favouriteLink = '';
let emojiReactionsLink = '';
let edited = '';
if (this.props.measureHeight) {
@@ -180,11 +183,19 @@ class DetailedStatus extends ImmutablePureComponent {
const language = status.getIn(['translation', 'language']) || status.get('language');
const identity = (status, _0, _1, quote = false) => (
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={quote ? this.handleQuoteUserClick : this.handleAccountClick} data-acct={status.getIn(['account', 'acct'])} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>
const identity = (status, _, quote) => (
<>
{status.get('visibility') === 'direct' && (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='at' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
</div>
)}
<a href={`/@${status.getIn(['account', 'acct'])}`} data-acct={status.getIn(['account', 'acct'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={quote ? 20 : 48} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>
</>
);
const media = (status, quote = false) => {
@@ -251,11 +262,17 @@ class DetailedStatus extends ImmutablePureComponent {
);
}
} else if (status.get('spoiler_text').length === 0) {
return <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} quote={quote} />;
return (<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia}
card={status.get('card', null)} quote={quote} />);
}
}
let emojiReactionsBar = null;
if (status.get('emoji_reactions')) {
const emojiReactions = status.get('emoji_reactions');
emojiReactionsBar = <StatusEmojiReactionsBar emojiReactions={emojiReactions} status={status} onEmojiReact={this.props.onEmojiReact} onUnEmojiReact={this.props.onUnEmojiReact} />;
}
if (status.get('application')) {
applicationLink = <> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></>;
}
@@ -310,6 +327,22 @@ class DetailedStatus extends ImmutablePureComponent {
);
}
if (this.context.router) {
emojiReactionsLink = (
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/emoji_reactions`} className='detailed-status__link'>
<Icon id='smile-o' />
<FormattedMessage id='status.emoji' defaultMessage='Emoji' />
</Link>
);
} else {
emojiReactionsLink = (
<a href={`/interact/${status.get('id')}?type=emoji_reactions`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id='smile-o' />
<FormattedMessage id='status.emoji' defaultMessage='Emoji' />
</a>
);
}
if (status.get('edited_at')) {
edited = (
<>
@@ -331,10 +364,7 @@ class DetailedStatus extends ImmutablePureComponent {
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
</div>
)}
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>
{identity(status, null, false)}
<StatusContent
status={status}
@@ -350,10 +380,12 @@ class DetailedStatus extends ImmutablePureComponent {
{expanded && hashtagBar}
{emojiReactionsBar}
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
</a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} · {emojiReactionsLink}
</div>
</div>
</div>
@@ -362,4 +394,4 @@ class DetailedStatus extends ImmutablePureComponent {
}
export default withRouter(DetailedStatus);
export default connect(mapStateToProps)(withRouter(injectIntl(DetailedStatus)));

View File

@@ -17,6 +17,8 @@ import {
unfavourite,
pin,
unpin,
emojiReact,
unEmojiReact,
} from '../../../actions/interactions';
import { openModal } from '../../../actions/modal';
import { initMuteModal } from '../../../actions/mutes';
@@ -100,6 +102,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onEmojiReact (status, emoji) {
dispatch(emojiReact(status, emoji));
},
onUnEmojiReact (status, emoji) {
dispatch(unEmojiReact(status, emoji));
},
onPin (status) {
if (status.get('pinned')) {
dispatch(unpin(status));

View File

@@ -41,6 +41,8 @@ import {
import {
favourite,
unfavourite,
emojiReact,
unEmojiReact,
bookmark,
unbookmark,
reblog,
@@ -255,7 +257,7 @@ class Status extends ImmutablePureComponent {
handleToggleQuoteMediaVisibility = () => {
this.setState({ showQuoteMedia: !this.state.showQuoteMedia });
}
};
handleFavouriteClick = (status) => {
const { dispatch } = this.props;
@@ -279,6 +281,16 @@ class Status extends ImmutablePureComponent {
}
};
handleEmojiReact = (status, emoji) => {
const { dispatch } = this.props;
dispatch(emojiReact(status, emoji));
};
handleUnEmojiReact = (status, emoji) => {
const { dispatch } = this.props;
dispatch(unEmojiReact(status, emoji));
};
handlePin = (status) => {
if (status.get('pinned')) {
this.props.dispatch(unpin(status));
@@ -327,7 +339,7 @@ class Status extends ImmutablePureComponent {
} else {
dispatch(quoteCompose(status, this.props.history));
}
}
};
handleModalReblog = (status, privacy) => {
this.props.dispatch(reblog(status, privacy));
@@ -450,7 +462,7 @@ class Status extends ImmutablePureComponent {
} else {
this.props.dispatch(hideQuote(status.get('id')));
}
}
};
handleToggleAll = () => {
const { status, ancestorsIds, descendantsIds } = this.props;
@@ -749,6 +761,8 @@ class Status extends ImmutablePureComponent {
onToggleMediaVisibility={this.handleToggleMediaVisibility}
onToggleQuoteMediaVisibility={this.handleToggleQuoteMediaVisibility}
pictureInPicture={pictureInPicture}
onEmojiReact={this.handleEmojiReact}
onUnEmojiReact={this.handleUnEmojiReact}
/>
<ActionBar
@@ -756,6 +770,7 @@ class Status extends ImmutablePureComponent {
status={status}
onReply={this.handleReplyClick}
onFavourite={this.handleFavouriteClick}
onEmojiReact={this.handleEmojiReact}
onReblog={this.handleReblogClick}
onQuote={this.handleQuoteClick}
onBookmark={this.handleBookmarkClick}

View File

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

View File

@@ -82,6 +82,10 @@ export function Favourites () {
return import(/* webpackChunkName: "features/favourites" */'../../favourites');
}
export function EmojiReactions () {
return import(/* webpackChunkName: "features/favourites" */'../../emoji_reactions');
}
export function FollowRequests () {
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
}

View File

@@ -428,7 +428,8 @@
"not_signed_in_indicator.not_signed_in": "You need to login to access this resource.",
"notification.admin.report": "{name} reported {target}",
"notification.admin.sign_up": "{name} signed up",
"notification.favourite": "{name} favorited your post",
"notification.emoji_reaction": "{name} reacted your post with emoji",
"notification.favourite": "{name} favourited your post",
"notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",
"notification.mention": "{name} mentioned you",

View File

@@ -428,6 +428,7 @@
"not_signed_in_indicator.not_signed_in": "この機能を使うにはログインする必要があります。",
"notification.admin.report": "{name}さんが{target}さんを通報しました",
"notification.admin.sign_up": "{name}さんがサインアップしました",
"notification.emoji_reaction": "{name}さんがあなたの投稿に絵文字をつけました",
"notification.favourite": "{name}さんがあなたのトゥートに╰( ^o^)╮-=ニ=一=三★しました",
"notification.follow": "{name}さんにフォローされました",
"notification.follow_request": "{name}さんがあなたにフォローリクエストしました",
@@ -683,6 +684,7 @@
"status.translate": "翻訳",
"status.translated_from_with": "{provider}を使って{lang}から翻訳",
"status.uncached_media_warning": "プレビューは使用できません",
"status.unlisted_quote": "未収載の引用",
"status.unmute_conversation": "会話のミュートを解除",
"status.unpin": "プロフィールへの固定を解除",
"subscribed_languages.lead": "選択した言語のトゥートだけがホームとリストのタイムラインに表示されます。全ての言語のトゥートを受け取る場合は全てのチェックを外して下さい。",

View File

@@ -7,7 +7,6 @@ import {
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_QUOTE,
COMPOSE_QUOTE_CANCEL,
COMPOSE_DIRECT,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
@@ -397,7 +396,6 @@ export default function compose(state = initialState, action) {
case COMPOSE_UPLOAD_CHANGE_REQUEST:
return state.set('is_changing_upload', true);
case COMPOSE_REPLY_CANCEL:
case COMPOSE_QUOTE_CANCEL:
case COMPOSE_RESET:
case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state);
@@ -530,6 +528,8 @@ export default function compose(state = initialState, action) {
map.set('id', action.status.get('id'));
map.set('text', action.text);
map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('quote_from', action.status.getIn(['quote', 'id']));
map.set('quote_from_url', action.status.getIn(['quote', 'url']));
map.set('privacy', action.status.get('visibility'));
map.set('media_attachments', action.status.get('media_attachments'));
map.set('focusDate', new Date());

View File

@@ -52,6 +52,7 @@ const notificationToMap = notification => ImmutableMap({
id: notification.id,
type: notification.type,
account: notification.account.id,
emoji_reaction: ImmutableMap(notification.emoji_reaction),
created_at: notification.created_at,
status: notification.status ? notification.status.id : null,
report: notification.report ? fromJS(notification.report) : null,

View File

@@ -1,4 +1,4 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
import { normalizeStatusTranslation } from '../actions/importer/normalizer';
@@ -26,6 +26,7 @@ import {
STATUS_TRANSLATE_UNDO,
STATUS_FETCH_REQUEST,
STATUS_FETCH_FAIL,
STATUS_EMOJI_REACTION_UPDATE,
QUOTE_REVEAL,
QUOTE_HIDE,
} from '../actions/statuses';
@@ -65,6 +66,29 @@ const statusTranslateUndo = (state, id) => {
});
};
const updateStatusEmojiReaction = (state, emoji_reaction, myId) => {
emoji_reaction.me = emoji_reaction.account_ids ? emoji_reaction.account_ids.indexOf(myId) >= 0 : false;
const status = state.get(emoji_reaction.status_id);
if (!status) return state;
let emoji_reactions = Array.from(status.get('emoji_reactions') || []);
if (emoji_reaction.count > 0) {
const old_emoji = emoji_reactions.find((er) => er.get('name') === emoji_reaction.name && (!er.get('domain') || er.get('domain') === emoji_reaction.domain));
if (old_emoji) {
const index = emoji_reactions.indexOf(old_emoji);
emoji_reactions[index] = old_emoji.merge({ account_ids: emoji_reaction.account_ids, count: emoji_reaction.count, me: emoji_reaction.me });
} else {
emoji_reactions.push(ImmutableMap(emoji_reaction));
}
} else {
emoji_reactions = emoji_reactions.filter((er) => er.get('name') !== emoji_reaction.name || er.get('domain') !== emoji_reaction.domain);
}
return state.setIn([emoji_reaction.status_id, 'emoji_reactions'], ImmutableList(emoji_reactions));
};
const initialState = ImmutableMap();
export default function statuses(state = initialState, action) {
@@ -137,6 +161,8 @@ export default function statuses(state = initialState, action) {
return statusTranslateSuccess(state, action.id, action.translation);
case STATUS_TRANSLATE_UNDO:
return statusTranslateUndo(state, action.id);
case STATUS_EMOJI_REACTION_UPDATE:
return updateStatusEmojiReaction(state, action.emoji_reaction, action.accountId);
default:
return state;
}

View File

@@ -1,5 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import {
DIRECTORY_FETCH_REQUEST,
DIRECTORY_FETCH_SUCCESS,
@@ -57,6 +56,7 @@ import {
FAVOURITES_EXPAND_REQUEST,
FAVOURITES_EXPAND_SUCCESS,
FAVOURITES_EXPAND_FAIL,
EMOJI_REACTIONS_FETCH_SUCCESS,
} from '../actions/interactions';
import {
MUTES_FETCH_REQUEST,
@@ -79,6 +79,7 @@ const initialState = ImmutableMap({
following: initialListState,
reblogged_by: initialListState,
favourited_by: initialListState,
emoji_reactioned_by: initialListState,
follow_requests: initialListState,
blocks: initialListState,
mutes: initialListState,
@@ -161,6 +162,8 @@ export default function userLists(state = initialState, action) {
return state.setIn(['favourited_by', action.id, 'isLoading'], false);
case notificationsUpdate.type:
return action.payload.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.payload.notification) : state;
case EMOJI_REACTIONS_FETCH_SUCCESS:
return state.setIn(['emoji_reactioned_by', action.id], ImmutableList(action.accounts));
case FOLLOW_REQUESTS_FETCH_SUCCESS:
return normalizeList(state, ['follow_requests'], action.accounts, action.next);
case FOLLOW_REQUESTS_EXPAND_SUCCESS:

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m228-240 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T458-480L320-240h-92Zm360 0 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T818-480L680-240h-92Z"/></svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m228-240 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T458-480L320-240h-92Zm360 0 92-160q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 23-5.5 42.5T818-480L680-240h-92ZM320-500q25 0 42.5-17.5T380-560q0-25-17.5-42.5T320-620q-25 0-42.5 17.5T260-560q0 25 17.5 42.5T320-500Zm360 0q25 0 42.5-17.5T740-560q0-25-17.5-42.5T680-620q-25 0-42.5 17.5T620-560q0 25 17.5 42.5T680-500Zm0-60Zm-360 0Z"/></svg>

After

Width:  |  Height:  |  Size: 538 B

View File

@@ -1105,6 +1105,10 @@ body > [data-popper-placement] {
grid-template-rows: 46px max-content;
gap: 0 10px;
&.quote-indicator {
background: $success-green;
}
.detailed-status__display-name {
margin-bottom: 4px;
}
@@ -1422,17 +1426,6 @@ body > [data-popper-placement] {
appearance: none;
}
.status__avatar,
.detailed-status__display-avatar {
position: absolute;
top: 5px !important;
left: 5px !important;
}
.display-name {
padding-left: 56px;
}
.detailed-status__display-name {
margin-bottom: 0;
line-height: unset;
@@ -1630,7 +1623,8 @@ body > [data-popper-placement] {
color: $dark-text-color;
}
.status__info .status__display-name {
.status__info .status__display-name,
.edit-indicator .status__display-name {
max-width: 100%;
display: flex;
font-size: 15px;
@@ -1731,13 +1725,50 @@ body > [data-popper-placement] {
color: $highlight-text-color;
}
}
.status__emoji-reactions-bar {
display: flex;
flex-wrap: wrap;
// gap: 4px 8px;
margin: 8px 0 2px 0;
.emoji-reactions-bar__button {
background: $white;
border: 1px solid lighten($ui-base-color, 8%);
border-radius: 3px;
padding: 0 6px;
cursor: pointer;
display: flex;
justify-items: center;
align-items: center;
height: 20px;
&.toggled {
background-color: mix($white, $ui-highlight-color, 80%);
border-color: mix(lighten($ui-base-color, 8%), $ui-highlight-color, 80%);
}
.emoji {
display: block;
height: 16px;
img {
margin-top: 0;
margin-bottom: 0;
}
}
.count {
display: block;
margin: 0 2px;
color: $ui-secondary-color;
}
}
}
.status__action-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
margin-top: 16px;
//margin-top: 16px;
}
.detailed-status__action-bar-dropdown {

View File

@@ -37,7 +37,7 @@ class ActivityPub::Activity
ActivityPub::Activity::Delete
when 'Follow'
ActivityPub::Activity::Follow
when 'Like'
when 'Like', 'EmojiReaction', 'EmojiReact'
ActivityPub::Activity::Like
when 'Block'
ActivityPub::Activity::Block

View File

@@ -2,13 +2,102 @@
class ActivityPub::Activity::Like < ActivityPub::Activity
def perform
original_status = status_from_uri(object_uri)
@original_status = status_from_uri(object_uri)
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
return if @original_status.nil? || !@original_status.account.local? || delete_arrived_first?(@json['id'])
favourite = original_status.favourites.create!(account: @account)
LocalNotificationWorker.perform_async(original_status.account_id, favourite.id, 'Favourite', 'favourite')
Trends.statuses.register(original_status)
if shortcode.nil?
process_favourite
else
process_emoji_reaction
end
end
end
private
def process_favourite
return if @account.favourited?(@original_status)
favourite = @original_status.favourites.create!(account: @account)
LocalNotificationWorker.perform_async(@original_status.account_id, favourite.id, 'Favourite', 'favourite')
Trends.statuses.register(@original_status)
end
def process_emoji_reaction
if emoji_tag.present?
return if emoji_tag['id'].blank? || emoji_tag['name'].blank? || emoji_tag['icon'].blank? || emoji_tag['icon']['url'].blank?
image_url = emoji_tag['icon']['url']
uri = emoji_tag['id']
domain = URI.split(uri)[2]
emoji = CustomEmoji.find_or_create_by!(shortcode: shortcode, domain: domain) do |emoji|
emoji.uri = uri
emoji.image_remote_url = image_url
end
end
return if EmojiReaction.where(account: @account, status: @original_status).count >= EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT
reaction = @original_status.emoji_reactions.create!(account: @account, name: shortcode, custom_emoji: emoji, uri: @json['id'])
write_stream(reaction)
if @original_status.account.local?
NotifyService.new.call(@original_status.account, :emoji_reaction, reaction)
forward_for_emoji_reaction
relay_for_emoji_reaction
end
rescue Seahorse::Client::NetworkingError
nil
end
def forward_for_emoji_reaction
return unless @json['signature'].present?
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), @original_status.account.id, [@account.preferred_inbox_url])
end
def relay_for_emoji_reaction
return unless @json['signature'].present? && @original_status.public_visibility?
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
[Oj.dump(@json), @original_status.account.id, inbox_url]
end
end
def shortcode
return @shortcode if defined?(@shortcode)
@shortcode = begin
if @json['_misskey_reaction'] == '⭐'
nil
else
@json['content']&.delete(':')
end
end
end
def misskey_favourite?
misskey_shortcode = @json['_misskey_reaction']&.delete(':')
return misskey_shortcode == shortcode && misskey_shortcode == '⭐'
end
def emoji_tag
return @emoji_tag if defined?(@emoji_tag)
@emoji_tag = @json['tag'].is_a?(Array) ? @json['tag']&.first : @json['tag']
end
def write_stream(emoji_reaction)
emoji_group = @original_status.emoji_reactions_grouped_by_name
.find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) }
emoji_group['status_id'] = @original_status.id.to_s
FeedAnyJsonWorker.perform_async(render_emoji_reaction(emoji_group), @original_status.id, emoji_reaction.account_id)
end
def render_emoji_reaction(emoji_group)
@render_emoji_reaction ||= Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json)
end
end

View File

@@ -100,7 +100,7 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
end
end
def undo_like
def undo_like_original
status = status_from_uri(target_uri)
return if status.nil? || !status.account.local?
@@ -113,6 +113,75 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
end
end
def undo_like
@original_status = status_from_uri(target_uri)
return if @original_status.nil?
if shortcode.present?
emoji_tag = @object['tag'].is_a?(Array) ? @object['tag']&.first : @object['tag']
emoji = nil
if emoji_tag.present? && emoji_tag['id'].present?
domain = URI.split(emoji_tag['id'])[2]
domain = nil if domain == Rails.configuration.x.local_domain || domain == Rails.configuration.x.web_domain
emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain) if emoji_tag.present? && emoji_tag['id'].present?
end
emoji_reaction = @original_status.emoji_reactions.where(account: @account, name: shortcode, custom_emoji: emoji).first
if emoji_reaction
emoji_reaction.destroy
write_stream(emoji_reaction)
end
else
undo_like_original
end
end
def write_stream(emoji_reaction)
emoji_group = @original_status.emoji_reactions_grouped_by_name
.find { |reaction_group| reaction_group['name'] == emoji_reaction.name && (!reaction_group.key?(:domain) || reaction_group['domain'] == emoji_reaction.custom_emoji&.domain) }
if emoji_group
emoji_group['status_id'] = @original_status.id.to_s
else
# name: emoji_reaction.name, count: 0, domain: emoji_reaction.domain
emoji_group = { 'name' => emoji_reaction.name, 'count' => 0, 'account_ids' => [], 'status_id' => @original_status.id.to_s }
emoji_group['domain'] = emoji_reaction.custom_emoji.domain if emoji_reaction.custom_emoji
end
DeliveryEmojiReactionWorker.perform_async(render_emoji_reaction(emoji_group), @original_status.id, emoji_reaction.account_id) if Setting.streaming_emoji_reaction && (@original_status.local? || Setting.streaming_other_servers_emoji_reaction)
end
def render_emoji_reaction(emoji_group)
@render_emoji_reaction ||= Oj.dump(event: :emoji_reaction, payload: emoji_group.to_json)
end
def forward_for_undo_emoji_reaction
return unless @json['signature'].present?
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), @original_status.account.id, [@account.preferred_inbox_url])
end
def relay_for_undo_emoji_reaction
return unless @json['signature'].present? && @original_status.public_visibility?
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
[Oj.dump(@json), @original_status.account.id, inbox_url]
end
end
def shortcode
return @shortcode if defined?(@shortcode)
@shortcode = begin
if @object['_misskey_reaction'] == '⭐'
nil
else
@object['content']&.delete(':')
end
end
end
def undo_block
target_account = account_from_uri(target_uri)

View File

@@ -45,8 +45,12 @@ class ActivityPub::TagManager
account_status_url(target.account, target)
when :emoji
emoji_url(target)
when :flag
target.uri
when :emoji_reaction
emoji_reaction_url(target)
end
end

View File

@@ -14,6 +14,8 @@ class InlineRenderer
preload_associations_for_status
when :notification
serializer = REST::NotificationSerializer
when :emoji_reaction
serializer = REST::EmojiReactionSerializer
when :conversation
serializer = REST::ConversationSerializer
when :announcement

View File

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

View File

@@ -13,6 +13,7 @@ module Account::Associations
# Timelines
has_many :statuses, inverse_of: :account, dependent: :destroy
has_many :favourites, inverse_of: :account, dependent: :destroy
has_many :emoji_reactions, inverse_of: :account, dependent: :destroy
has_many :bookmarks, inverse_of: :account, dependent: :destroy
has_many :mentions, inverse_of: :account, dependent: :destroy
has_many :notifications, inverse_of: :account, dependent: :destroy

View File

@@ -28,15 +28,16 @@ class CustomEmoji < ApplicationRecord
SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
SCAN_RE = /(?<=[^[:alnum:]:]|\n|^)
:(#{SHORTCODE_RE_FRAGMENT}):
(?=[^[:alnum:]:]|$)/x
SCAN_RE = /:(#{SHORTCODE_RE_FRAGMENT}):/x
SHORTCODE_ONLY_RE = /\A#{SHORTCODE_RE_FRAGMENT}\z/
IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze
belongs_to :category, class_name: 'CustomEmojiCategory', optional: true
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
has_many :emoji_reactions, inverse_of: :custom_emoji, dependent: :destroy
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode, inverse_of: false, dependent: nil
has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' } }, validate_media_type: false

View File

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

View File

@@ -25,6 +25,7 @@ class Notification < ApplicationRecord
'Follow' => :follow,
'FollowRequest' => :follow_request,
'Favourite' => :favourite,
'EmojiReaction' => :emoji_reaction,
'Poll' => :poll,
}.freeze
@@ -35,6 +36,7 @@ class Notification < ApplicationRecord
follow
follow_request
favourite
emoji_reaction
poll
update
admin.sign_up
@@ -46,6 +48,7 @@ class Notification < ApplicationRecord
reblog: [status: :reblog],
mention: [mention: :status],
favourite: [favourite: :status],
emoji_reaction: [emoji_reaction: :status],
poll: [poll: :status],
update: :status,
'admin.report': [report: :target_account],
@@ -61,6 +64,8 @@ class Notification < ApplicationRecord
belongs_to :follow, inverse_of: :notification
belongs_to :follow_request, inverse_of: :notification
belongs_to :favourite, inverse_of: :notification
belongs_to :emoji_reaction, inverse_of: :notification
belongs_to :poll, inverse_of: false
belongs_to :report, inverse_of: false
end
@@ -81,6 +86,8 @@ class Notification < ApplicationRecord
status&.reblog
when :favourite
favourite&.status
when :emoji_reaction
emoji_reaction&.status
when :mention
mention&.status
when :poll
@@ -130,6 +137,8 @@ class Notification < ApplicationRecord
notification.status.reblog = cached_status
when :favourite
notification.favourite.status = cached_status
when :emoji_reaction
notification.emoji_reaction.status = cached_status
when :mention
notification.mention.status = cached_status
when :poll
@@ -150,7 +159,7 @@ class Notification < ApplicationRecord
return unless new_record?
case activity_type
when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report'
when 'Status', 'Follow', 'Favourite', 'EmojiReaction', 'FollowRequest', 'Poll', 'Report'
self.from_account_id = activity&.account_id
when 'Mention'
self.from_account_id = activity&.status&.account_id

View File

@@ -30,6 +30,8 @@
# ordered_media_attachment_ids :bigint(8) is an Array
#
require 'ostruct'
class Status < ApplicationRecord
include Cacheable
include Discard::Model
@@ -67,6 +69,7 @@ class Status < ApplicationRecord
end
has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :emoji_reactions, inverse_of: :status, dependent: :destroy
has_many :bookmarks, inverse_of: :status, dependent: :destroy
has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
has_many :reblogged_by_accounts, through: :reblogs, class_name: 'Account', source: :account
@@ -323,6 +326,34 @@ class Status < ApplicationRecord
update_status_stat!(key => [public_send(key) - 1, 0].max)
end
def emoji_reactions_grouped_by_name(account = nil)
(Oj.load(status_stat&.emoji_reactions || '', mode: :strict) || []).tap do |emoji_reactions|
if account.present?
emoji_reactions.each do |emoji_reaction|
emoji_reaction['me'] = emoji_reaction['account_ids'].include?(account.id.to_s)
emoji_reaction['account_ids'] -= account.excluded_from_timeline_account_ids.map(&:to_s)
emoji_reaction['count'] = emoji_reaction['account_ids'].size
end
end
end
end
def generate_emoji_reactions_grouped_by_name
records = emoji_reactions.group(:name).order(Arel.sql('MIN(created_at) ASC')).select('name, min(custom_emoji_id) as custom_emoji_id, count(*) as count, array_agg(account_id::text order by created_at) as account_ids')
Oj.dump(ActiveModelSerializers::SerializableResource.new(records, each_serializer: REST::EmojiReactionsGroupedByNameSerializer, scope: nil, scope_name: :current_user))
end
def refresh_emoji_reactions_grouped_by_name!
generate_emoji_reactions_grouped_by_name.tap do |emoji_reactions|
update_status_stat!(emoji_reactions: emoji_reactions)
end
end
def generate_emoji_reactions_grouped_by_account
# TODO for serializer
EmojiReaction.where(status_id: id).group_by(&:account)
end
def trendable?
if attributes['trendable'].nil?
account.trendable?
@@ -364,6 +395,10 @@ class Status < ApplicationRecord
StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true }
end
def emoji_reactions_map(status_ids, account_id)
EmojiReaction.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |e, h| h[e.status_id] = true }
end
def reload_stale_associations!(cached_items)
account_ids = []

View File

@@ -0,0 +1,14 @@
class StatusEmojiReactionsGroupedByAccountObject
def initialize(account, emoji_reactions)
@account = account
@emoji_reactions = emoji_reactions
end
def read_attribute_for_serialization(_)
REST::EmojiReactedBySlimSerializer
end
def model_name
'EmojiReaction'
end
end

View File

@@ -11,6 +11,7 @@
# favourites_count :bigint(8) default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# emoji_reactions :string
#
class StatusStat < ApplicationRecord
@@ -27,4 +28,14 @@ class StatusStat < ApplicationRecord
def favourites_count
[attributes['favourites_count'], 0].max
end
def emoji_reactions
attributes['emoji_reactions'] || ''
end
private
def reset_parent_cache
Rails.cache.delete("statuses/#{status_id}")
end
end

View File

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

View File

@@ -4,16 +4,17 @@ class StatusRelationshipsPresenter
PINNABLE_VISIBILITIES = %w(public unlisted private).freeze
attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
:bookmarks_map, :filters_map, :attributes_map
:bookmarks_map, :filters_map, :attributes_map, :emoji_reactions_map
def initialize(statuses, current_account_id = nil, **options)
if current_account_id.nil?
@reblogs_map = {}
@favourites_map = {}
@bookmarks_map = {}
@mutes_map = {}
@pins_map = {}
@filters_map = {}
@reblogs_map = {}
@favourites_map = {}
@bookmarks_map = {}
@mutes_map = {}
@pins_map = {}
@filters_map = {}
@emoji_reactions_map = {}
else
statuses = statuses.compact
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
@@ -27,6 +28,7 @@ class StatusRelationshipsPresenter
@mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
@pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
@attributes_map = options[:attributes_map] || {}
@emoji_reactions_map = Status.emoji_reactions_map(status_ids, current_account_id).merge(options[:emoji_reactions_map] || {})
end
end

View File

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

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
class ActivityPub::UndoEmojiReactionSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :content
has_one :object, serializer: ActivityPub::EmojiReactionSerializer
def id
[ActivityPub::TagManager.instance.uri_for(object.account), '#emoji_reactions/', object.id, '/undo'].join
end
def type
'Undo'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def content
object.custom_emoji.nil? ? object.name : ":#{object.name}:"
end
end

View File

@@ -3,11 +3,11 @@
class REST::CustomEmojiSerializer < ActiveModel::Serializer
include RoutingHelper
# Please update `app/javascript/mastodon/api_types/custom_emoji.ts` when making changes to the attributes
attributes :shortcode, :url, :static_url, :visible_in_picker
attribute :category, if: :category_loaded?
attribute :width, if: :width?
attribute :height, if: :height?
def url
full_asset_url(object.image.url)
@@ -24,4 +24,20 @@ class REST::CustomEmojiSerializer < ActiveModel::Serializer
def category_loaded?
object.association(:category).loaded? && object.category.present?
end
def width?
object.respond_to?(:image_width) || object.respond_to?(:width)
end
def height?
object.respond_to?(:image_height) || object.respond_to?(:height)
end
def width
object.respond_to?(:image_width) ? object.image_width : object.width
end
def height
object.respond_to?(:image_height) ? object.image_height : object.height
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
attributes :domain, :title, :version, :source_url, :description,
:usage, :thumbnail, :languages, :configuration,
:registrations, :feature_quote
:registrations, :feature_quote, :fedibird_capabilities
has_one :contact, serializer: ContactSerializer
has_many :rules, serializer: REST::RuleSerializer
@@ -81,6 +81,11 @@ class REST::InstanceSerializer < ActiveModel::Serializer
translation: {
enabled: TranslationService.configured?,
},
emoji_reactions: {
max_reactions: EmojiReaction::EMOJI_REACTION_LIMIT,
max_reactions_per_account: EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT,
},
}
end
@@ -97,6 +102,17 @@ class REST::InstanceSerializer < ActiveModel::Serializer
true
end
# for third party apps
def fedibird_capabilities
capabilities = [
:emoji_reaction,
]
capabilities << :profile_search unless Chewy.enabled?
capabilities
end
private
def registrations_enabled?

View File

@@ -6,16 +6,21 @@ class REST::NotificationSerializer < ActiveModel::Serializer
belongs_to :from_account, key: :account, serializer: REST::AccountSerializer
belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer
belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer
belongs_to :emoji_reaction, if: :emoji_reaction_type?, serializer: REST::NotifyEmojiReactionSerializer
def id
object.id.to_s
end
def status_type?
[:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
[:favourite, :emoji_reaction, :reblog, :status, :mention, :poll, :update].include?(object.type)
end
def report_type?
object.type == :'admin.report'
end
def emoji_reaction_type?
object.type == :emoji_reaction
end
end

View File

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

View File

@@ -6,7 +6,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
:sensitive, :spoiler_text, :visibility, :language,
:uri, :url, :replies_count, :reblogs_count,
:favourites_count, :edited_at
:favourites_count, :emoji_reactions, :edited_at
attribute :favourited, if: :current_user?
attribute :reblogged, if: :current_user?
@@ -103,6 +103,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
def emoji_reactions
object.emoji_reactions_grouped_by_name(current_user&.account)
end
def reblogged
if relationships
relationships.reblogs_map[object.id] || false

View File

@@ -6,7 +6,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
attributes :uri, :title, :short_description, :description, :email,
:version, :urls, :stats, :thumbnail,
:languages, :registrations, :approval_required, :invites_enabled,
:configuration
:configuration, :fedibird_capabilities
has_one :contact_account, serializer: REST::AccountSerializer
@@ -83,6 +83,11 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
min_expiration: PollValidator::MIN_EXPIRATION,
max_expiration: PollValidator::MAX_EXPIRATION,
},
emoji_reactions: {
max_reactions: EmojiReaction::EMOJI_REACTION_LIMIT,
max_reactions_per_account: EmojiReaction::EMOJI_REACTION_PER_ACCOUNT_LIMIT,
},
}
end
@@ -98,6 +103,17 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer
UserRole.everyone.can?(:invite_users)
end
# for third party apps
def fedibird_capabilities
capabilities = [
:emoji_reaction,
]
capabilities << :profile_search unless Chewy.enabled?
capabilities
end
private
def instance_presenter

View File

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

View File

@@ -147,6 +147,7 @@ class DeleteAccountService < BaseService
purge_polls!
purge_generated_notifications!
purge_favourites!
purge_emoji_reactions!
purge_bookmarks!
purge_feeds!
purge_other_associations!
@@ -193,6 +194,16 @@ class DeleteAccountService < BaseService
end
end
def purge_emoji_reactions!
@account.emoji_reactions.in_batches do |reactions|
reactions.each do |reaction|
reaction.status.refresh_emoji_reactions_grouped_by_name
end
Chewy.strategy.current.update(StatusesIndex, reactions.pluck(:status_id)) if Chewy.enabled?
reactions.delete_all
end
end
def purge_bookmarks!
@account.bookmarks.in_batches do |bookmarks|
Chewy.strategy.current.update(StatusesIndex, bookmarks.pluck(:status_id)) if Chewy.enabled?

View File

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

View File

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

View File

@@ -13,7 +13,7 @@
%span.display-name
%bdi
%strong.display-name__html.p-name.emojify= display_name(author, custom_emojify: true, autoplay: prefers_autoplay?)
&nbsp;
%span.display-name__account
= acct(author)
- if !inline && author.locked?

View File

@@ -12,21 +12,8 @@
*
%data.dt-published{ value: status.created_at.to_time.iso8601 }
.p-author.h-card
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener noreferrer' do
.status__avatar
%div
- if prefers_autoplay?
= image_tag status.account.avatar_original_url, alt: '', class: 'u-photo account__avatar'
- else
= image_tag status.account.avatar_static_url, alt: '', class: 'u-photo account__avatar'
%span.display-name
%bdi
%strong.display-name__html.p-name.emojify= display_name(status.account, custom_emojify: true, autoplay: prefers_autoplay?)
&nbsp;
%span.display-name__account
= acct(status.account)
= fa_icon('lock') if status.account.locked?
= render 'statuses/author', author: status.account
.status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
- if status.spoiler_text?
%p<

View File

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

View File

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

View File

@@ -1459,9 +1459,13 @@ en:
sign_up:
subject: "%{name} signed up"
favourite:
body: 'Your post was favorited by %{name}:'
subject: "%{name} favorited your post"
title: New favorite
body: 'Your post was favourited by %{name}:'
subject: "%{name} favourited your post"
title: New favourite
emoji_reaction:
body: 'Your post was reacted with emoji by %{name}:'
subject: "%{name} reacted your post with emoji"
title: New emoji reaction
follow:
body: "%{name} is now following you!"
subject: "%{name} is now following you"

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ namespace :api, format: false do
scope module: :statuses do
resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
resources :favourited_by, controller: :favourited_by_accounts, only: :index
resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index
resources :emoji_reactioned_by_slim, controller: :emoji_reactioned_by_accounts_slim, only: :index
resource :reblog, only: :create
post :unreblog, to: 'reblogs#destroy'
@@ -29,6 +31,9 @@ namespace :api, format: false do
resource :source, only: :show
post :translate, to: 'translations#create'
resources :emoji_reactions, only: [:create, :update, :destroy], constraints: { id: /[^\/]+/ }
post :emoji_unreaction, to: 'emoji_reactions#destroy'
end
member do

View File

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

View File

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

View File

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

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

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