Compare commits
9 commits
main
...
wip/reacti
Author | SHA1 | Date | |
---|---|---|---|
|
001d5e24b8 | ||
|
da10e39d33 | ||
|
e1e4b734f6 | ||
|
68aea0d2f5 | ||
|
2ab563e06c | ||
|
0a6eaa16dc | ||
|
31406cb0f2 | ||
|
a4bfa67151 | ||
|
9862e1591e |
21 changed files with 518 additions and 34 deletions
|
@ -1,8 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Statuses::ReactionsController < Api::V1::Statuses::BaseController
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
|
||||
before_action :require_user!
|
||||
REACTIONS_LIMIT = 30
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:favourites' }, only: [:create, :destroy]
|
||||
before_action -> { authorize_if_got_token! :read, :'read:accounts' }, only: [:index]
|
||||
before_action :require_user!, only: [:create, :destroy]
|
||||
before_action :set_reactions, only: [:index]
|
||||
after_action :insert_pagination_headers, only: [:index]
|
||||
|
||||
def index
|
||||
cache_if_unauthenticated!
|
||||
render json: @reactions, each_serializer: REST::StatusReactionSerializer
|
||||
end
|
||||
|
||||
def create
|
||||
ReactService.new.call(current_account, @status, params[:id])
|
||||
|
@ -16,4 +26,83 @@ class Api::V1::Statuses::ReactionsController < Api::V1::Statuses::BaseController
|
|||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_reactions
|
||||
@reactions = ordered_reactions.select(
|
||||
[:id, :account_id, :name, :custom_emoji_id].tap do |values|
|
||||
values << value_for_reaction_me_column(current_account)
|
||||
end
|
||||
).to_a_paginated_by_id(
|
||||
limit_param(REACTIONS_LIMIT),
|
||||
params_slice(:max_id, :since_id, :min_id)
|
||||
)
|
||||
end
|
||||
|
||||
def ordered_reactions
|
||||
filtered_reactions.group(:status_id, :id, :account_id, :name, :custom_emoji_id)
|
||||
end
|
||||
|
||||
def filtered_reactions
|
||||
initial_reactions = StatusReaction.where(status: @status)
|
||||
if filtered?
|
||||
initial_reactions.where(name: params[:emoji])
|
||||
else
|
||||
initial_reactions
|
||||
end
|
||||
end
|
||||
|
||||
def filtered?
|
||||
params[:emoji].present?
|
||||
end
|
||||
|
||||
def value_for_reaction_me_column(account)
|
||||
if account.nil?
|
||||
'FALSE AS me'
|
||||
else
|
||||
<<~SQL.squish
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM status_reactions inner_reactions
|
||||
WHERE inner_reactions.account_id = #{account.id}
|
||||
AND inner_reactions.status_id = status_reactions.status_id
|
||||
AND inner_reactions.name = status_reactions.name
|
||||
AND (
|
||||
inner_reactions.custom_emoji_id = status_reactions.custom_emoji_id
|
||||
OR inner_reactions.custom_emoji_id IS NULL
|
||||
AND status_reactions.custom_emoji_id IS NULL
|
||||
)
|
||||
) AS me
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
def insert_pagination_headers
|
||||
set_pagination_headers(next_path, prev_path)
|
||||
end
|
||||
|
||||
def next_path
|
||||
api_v1_status_reactions_url pagination_params(max_id: pagination_max_id) if records_continue?
|
||||
end
|
||||
|
||||
def prev_path
|
||||
api_v1_status_reactions_url pagination_params(since_id: pagination_since_id) unless @reactions.empty?
|
||||
end
|
||||
|
||||
def pagination_max_id
|
||||
@reactions.last.id
|
||||
end
|
||||
|
||||
def pagination_since_id
|
||||
@reactions.first.id
|
||||
end
|
||||
|
||||
def records_continue?
|
||||
@reactions.size == limit_param(REACTIONS_LIMIT)
|
||||
end
|
||||
|
||||
def pagination_params(core_params)
|
||||
params_slice(:limit, :emoji).merge(core_params)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,6 +7,10 @@ export const REBLOG_REQUEST = 'REBLOG_REQUEST';
|
|||
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
|
||||
export const REBLOG_FAIL = 'REBLOG_FAIL';
|
||||
|
||||
export const REACTIONS_EXPAND_REQUEST = 'REACTIONS_EXPAND_REQUEST';
|
||||
export const REACTIONS_EXPAND_SUCCESS = 'REACTIONS_EXPAND_SUCCESS';
|
||||
export const REACTIONS_EXPAND_FAIL = 'REACTIONS_EXPAND_FAIL';
|
||||
|
||||
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
|
||||
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
|
||||
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_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 REACTIONS_FETCH_REQUEST = 'REACTIONS_FETCH_REQUEST';
|
||||
export const REACTIONS_FETCH_SUCCESS = 'REACTIONS_FETCH_SUCCESS';
|
||||
export const REACTIONS_FETCH_FAIL = 'REACTIONS_FETCH_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';
|
||||
|
@ -287,6 +295,90 @@ export function unbookmarkFail(status, error) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchReactions(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchReactionsRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/reactions`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
const accounts = response.data.map(item => item.account);
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(fetchReactionsSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(accounts.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchReactionsFail(id, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReactionsRequest(id) {
|
||||
return {
|
||||
type: REACTIONS_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReactionsSuccess(id, reactions, next) {
|
||||
return {
|
||||
type: REACTIONS_FETCH_SUCCESS,
|
||||
id,
|
||||
reactions,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReactionsFail(id, error) {
|
||||
return {
|
||||
type: REACTIONS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReactions(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'reactions', id, 'next']);
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandReactionsRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
const accounts = response.data.map(item => item.account);
|
||||
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(expandReactionsSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(accounts.map(item => item.id)));
|
||||
}).catch(error => dispatch(expandReactionsFail(id, error)));
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReactionsRequest(id) {
|
||||
return {
|
||||
type: REACTIONS_EXPAND_REQUEST,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReactionsSuccess(id, reactions, next) {
|
||||
return {
|
||||
type: REACTIONS_EXPAND_SUCCESS,
|
||||
id,
|
||||
reactions,
|
||||
next,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandReactionsFail(id, error) {
|
||||
return {
|
||||
type: REACTIONS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchReblogs(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchReblogsRequest(id));
|
||||
|
|
5
app/javascript/flavours/glitch/api_types/reaction.ts
Normal file
5
app/javascript/flavours/glitch/api_types/reaction.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface ApiStatusReactionJSON {
|
||||
name: string;
|
||||
static_url?: string | undefined;
|
||||
url?: string | undefined;
|
||||
}
|
|
@ -14,6 +14,7 @@ import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
|
|||
import { me } from '../initial_state';
|
||||
|
||||
import { Avatar } from './avatar';
|
||||
import { AvatarOverlay } from './avatar_overlay';
|
||||
import { Button } from './button';
|
||||
import { FollowersCounter } from './counters';
|
||||
import { DisplayName } from './display_name';
|
||||
|
@ -42,6 +43,7 @@ class Account extends ImmutablePureComponent {
|
|||
onMute: PropTypes.func,
|
||||
onMuteNotifications: PropTypes.func,
|
||||
intl: PropTypes.object.isRequired,
|
||||
overlayEmoji: PropTypes.object,
|
||||
hidden: PropTypes.bool,
|
||||
minimal: PropTypes.bool,
|
||||
defaultAction: PropTypes.string,
|
||||
|
@ -50,6 +52,7 @@ class Account extends ImmutablePureComponent {
|
|||
|
||||
static defaultProps = {
|
||||
size: 46,
|
||||
overlayEmoji: { name: null }
|
||||
};
|
||||
|
||||
handleFollow = () => {
|
||||
|
@ -73,7 +76,7 @@ class Account extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { account, intl, hidden, withBio, defaultAction, size, minimal } = this.props;
|
||||
const { account, intl, hidden, withBio, defaultAction, overlayEmoji, size, minimal } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <EmptyAccount size={size} minimal={minimal} />;
|
||||
|
@ -138,12 +141,19 @@ class Account extends ImmutablePureComponent {
|
|||
verification = <VerifiedBadge link={firstVerifiedField.get('value')} />;
|
||||
}
|
||||
|
||||
let statusAvatar;
|
||||
if (!overlayEmoji.name) {
|
||||
statusAvatar = <Avatar account={account} size={size} />;
|
||||
} else {
|
||||
statusAvatar = <AvatarOverlay account={account} emoji={overlayEmoji} baseSize={size} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Avatar account={account} size={size} />
|
||||
{statusAvatar}
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
import type { Account } from 'flavours/glitch/models/account';
|
||||
import type { StatusReaction } from 'flavours/glitch/models/reaction';
|
||||
|
||||
import { useHovering } from '../hooks/useHovering';
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
import { Emoji } from './status_reactions';
|
||||
|
||||
interface Props {
|
||||
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||
friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||
friend?: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
|
||||
emoji?: StatusReaction | undefined;
|
||||
size?: number;
|
||||
baseSize?: number;
|
||||
overlaySize?: number;
|
||||
|
@ -14,6 +18,7 @@ interface Props {
|
|||
export const AvatarOverlay: React.FC<Props> = ({
|
||||
account,
|
||||
friend,
|
||||
emoji,
|
||||
size = 46,
|
||||
baseSize = 36,
|
||||
overlaySize = 24,
|
||||
|
@ -27,6 +32,32 @@ export const AvatarOverlay: React.FC<Props> = ({
|
|||
? friend?.get('avatar')
|
||||
: friend?.get('avatar_static');
|
||||
|
||||
let overlayElement;
|
||||
if (friendSrc) {
|
||||
overlayElement = (
|
||||
<div
|
||||
className='account__avatar'
|
||||
style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }}
|
||||
data-avatar-of={`@${friend?.get('acct')}`}
|
||||
>
|
||||
{friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
overlayElement = (
|
||||
<div className='account__emoji' data-emoji-name={emoji?.name}>
|
||||
{emoji && (
|
||||
<Emoji
|
||||
emoji={emoji.name}
|
||||
hovered={hovering}
|
||||
url={emoji.url}
|
||||
staticUrl={emoji.static_url}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='account__avatar-overlay'
|
||||
|
@ -43,15 +74,7 @@ export const AvatarOverlay: React.FC<Props> = ({
|
|||
{accountSrc && <img src={accountSrc} alt={account?.get('acct')} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className='account__avatar-overlay-overlay'>
|
||||
<div
|
||||
className='account__avatar'
|
||||
style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }}
|
||||
data-avatar-of={`@${friend?.get('acct')}`}
|
||||
>
|
||||
{friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className='account__avatar-overlay-overlay'>{overlayElement}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -32,7 +32,7 @@ import StatusContent from './status_content';
|
|||
import StatusHeader from './status_header';
|
||||
import StatusIcons from './status_icons';
|
||||
import StatusPrepend from './status_prepend';
|
||||
import StatusReactions from './status_reactions';
|
||||
import { StatusReactions } from './status_reactions';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ import { assetHost } from '../utils/config';
|
|||
|
||||
import { AnimatedNumber } from './animated_number';
|
||||
|
||||
export default class StatusReactions extends ImmutablePureComponent {
|
||||
export class StatusReactions extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
statusId: PropTypes.string.isRequired,
|
||||
|
@ -107,6 +107,7 @@ class Reaction extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
|
||||
onClick={this.handleClick}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
|
@ -131,7 +132,7 @@ class Reaction extends ImmutablePureComponent {
|
|||
|
||||
}
|
||||
|
||||
class Emoji extends React.PureComponent {
|
||||
export class Emoji extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
emoji: PropTypes.string.isRequired,
|
||||
|
|
120
app/javascript/flavours/glitch/features/reactions/index.jsx
Normal file
120
app/javascript/flavours/glitch/features/reactions/index.jsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
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 { debounce } from 'lodash';
|
||||
|
||||
import MoodIcon from '@/material-icons/400-24px/mood.svg?react';
|
||||
import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react';
|
||||
import { Icon } from 'flavours/glitch/components/icon';
|
||||
|
||||
import { fetchReactions, expandReactions } from '../../actions/interactions';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.reacted_by', defaultMessage: 'Reacted by' },
|
||||
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
reactions: state.getIn(['status_reactions', 'reactions', props.params.statusId, 'items']),
|
||||
hasMore: !!state.getIn(['status_reactions', 'reactions', props.params.statusId, 'next']),
|
||||
isLoading: state.getIn(['status_reactions', 'reactions', props.params.statusId, 'isLoading'], true),
|
||||
});
|
||||
|
||||
class Reactions extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
reactions: ImmutablePropTypes.orderedSet,
|
||||
hasMore: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
if (!this.props.accountIds) {
|
||||
this.props.dispatch(fetchReactions(this.props.params.statusId));
|
||||
}
|
||||
}
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleRefresh = () => {
|
||||
this.props.dispatch(fetchReactions(this.props.params.statusId));
|
||||
};
|
||||
|
||||
handleLoadMore = debounce(() => {
|
||||
this.props.dispatch(expandReactions(this.props.params.statusId));
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { intl, reactions, hasMore, isLoading, multiColumn } = this.props;
|
||||
|
||||
if (!reactions) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const accountIds = reactions.map(v => v.account);
|
||||
const reactionsByAccount = new Map(reactions.map(v => [v.account, v]));
|
||||
|
||||
const emptyMessage = <FormattedMessage id='status.reactions.empty' defaultMessage='No one has reacted to this post yet. When someone does, they will show up here.' />;
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef}>
|
||||
<ColumnHeader
|
||||
icon='mood'
|
||||
iconComponent={MoodIcon}
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
onClick={this.handleHeaderClick}
|
||||
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='reactions'
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} withNote={false} overlayEmoji={reactionsByAccount.get(id)} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Reactions));
|
|
@ -21,7 +21,7 @@ import { Avatar } from '../../../components/avatar';
|
|||
import { DisplayName } from '../../../components/display_name';
|
||||
import MediaGallery from '../../../components/media_gallery';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
import StatusReactions from '../../../components/status_reactions';
|
||||
import { StatusReactions } from '../../../components/status_reactions';
|
||||
import Audio from '../../audio';
|
||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||
import Video from '../../video';
|
||||
|
@ -137,6 +137,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
let applicationLink = '';
|
||||
let reblogLink = '';
|
||||
let favouriteLink = '';
|
||||
let reactionLink = '';
|
||||
|
||||
// Depending on user settings, some media are considered as parts of the
|
||||
// contents (affected by CW) while other will be displayed outside of the
|
||||
|
@ -275,6 +276,14 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
|
||||
</Link>
|
||||
);
|
||||
reactionLink = (
|
||||
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reactions`} className='detailed-status__link'>
|
||||
<span className='detailed-status__reactions'>
|
||||
<AnimatedNumber value={status.get('reactions').reduce((total, obj) => total + obj.get('count'), 0)} />
|
||||
</span>
|
||||
<FormattedMessage id='status.reactions' defaultMessage='{count, plural, one {reaction} other {reactions}}' values={{ count: status.get('reactions').reduce((total, obj) => total + obj.get('count'), 0) }} />
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
favouriteLink = (
|
||||
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
|
@ -284,6 +293,14 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
<FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
|
||||
</a>
|
||||
);
|
||||
reactionLink = (
|
||||
<a href={`/interact/${status.get('id')}?type=reaction`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
<span className='detailed-status__reactions'>
|
||||
<AnimatedNumber value={status.get('reactions').reduce((total, obj) => total + obj.get('count'), 0)} />
|
||||
</span>
|
||||
<FormattedMessage id='status.reactions' defaultMessage='{count, plural, one {reaction} other {reactions}}' values={{ count: status.get('reactions').reduce((total, obj) => total + obj.get('count'), 0) }} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
|
@ -339,6 +356,8 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
{reblogLink}
|
||||
{reblogLink && <>·</>}
|
||||
{favouriteLink}
|
||||
·
|
||||
{reactionLink}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -45,6 +45,7 @@ import {
|
|||
HomeTimeline,
|
||||
Followers,
|
||||
Following,
|
||||
Reactions,
|
||||
Reblogs,
|
||||
Favourites,
|
||||
DirectTimeline,
|
||||
|
@ -234,6 +235,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
<WrappedRoute path={['/accounts/:id/following', '/users/:acct/following', '/@:acct/following']} component={Following} content={children} />
|
||||
<WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
|
||||
<WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
|
||||
<WrappedRoute path='/@:acct/:statusId/reactions' component={Reactions} content={children} />
|
||||
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
|
||||
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
|
||||
|
||||
|
|
|
@ -90,6 +90,10 @@ export function Favourites () {
|
|||
return import(/* webpackChunkName: "flavours/glitch/async/favourites" */'../../favourites');
|
||||
}
|
||||
|
||||
export function Reactions () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/reactions" */'../../reactions');
|
||||
}
|
||||
|
||||
export function FollowRequests () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/follow_requests" */'../../follow_requests');
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
"boost_modal.missing_description": "This toot contains some media without description",
|
||||
"column.favourited_by": "Favourited by",
|
||||
"column.heading": "Misc",
|
||||
"column.reacted_by": "Reacted by",
|
||||
"column.reblogged_by": "Boosted by",
|
||||
"column.subheading": "Miscellaneous options",
|
||||
"column_header.profile": "Profile",
|
||||
|
@ -162,6 +163,8 @@
|
|||
"status.is_poll": "This toot is a poll",
|
||||
"status.local_only": "Only visible from your instance",
|
||||
"status.react": "React",
|
||||
"status.reactions": "{count, plural, one {reaction} other {reactions}}",
|
||||
"status.reactions.empty": "No one has reacted to this post yet. When someone does, they will show up here.",
|
||||
"status.uncollapse": "Uncollapse",
|
||||
"suggestions.dismiss": "Dismiss suggestion",
|
||||
"tenor.error": "Oops! Something went wrong. Please, try again.",
|
||||
|
|
13
app/javascript/flavours/glitch/models/reaction.ts
Normal file
13
app/javascript/flavours/glitch/models/reaction.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import type { RecordOf } from 'immutable';
|
||||
import { Record } from 'immutable';
|
||||
|
||||
import type { ApiStatusReactionJSON } from 'flavours/glitch/api_types/reaction';
|
||||
|
||||
type StatusReactionShape = Required<ApiStatusReactionJSON>;
|
||||
export type StatusReaction = RecordOf<StatusReactionShape>;
|
||||
|
||||
export const CustomEmojiFactory = Record<StatusReactionShape>({
|
||||
name: '',
|
||||
static_url: '',
|
||||
url: '',
|
||||
});
|
|
@ -38,6 +38,7 @@ import search from './search';
|
|||
import server from './server';
|
||||
import settings from './settings';
|
||||
import status_lists from './status_lists';
|
||||
import status_reactions from './status_reactions';
|
||||
import statuses from './statuses';
|
||||
import suggestions from './suggestions';
|
||||
import tags from './tags';
|
||||
|
@ -86,6 +87,7 @@ const reducers = {
|
|||
history,
|
||||
tags,
|
||||
followed_tags,
|
||||
status_reactions,
|
||||
notificationPolicy: notificationPolicyReducer,
|
||||
notificationRequests: notificationRequestsReducer,
|
||||
};
|
||||
|
|
57
app/javascript/flavours/glitch/reducers/status_reactions.js
Normal file
57
app/javascript/flavours/glitch/reducers/status_reactions.js
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
|
||||
import {
|
||||
REACTIONS_FETCH_SUCCESS,
|
||||
REACTIONS_EXPAND_SUCCESS,
|
||||
REACTIONS_FETCH_REQUEST,
|
||||
REACTIONS_EXPAND_REQUEST,
|
||||
REACTIONS_FETCH_FAIL,
|
||||
REACTIONS_EXPAND_FAIL,
|
||||
} from '../actions/interactions';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
reactions: ImmutableMap({
|
||||
next: null,
|
||||
isLoading: false,
|
||||
items: ImmutableOrderedSet(),
|
||||
}),
|
||||
});
|
||||
|
||||
const normalizeList = (state, path, reactions, next) => {
|
||||
const filteredReactions = reactions.map(v => {
|
||||
v.account = v.account.id;
|
||||
return v;
|
||||
});
|
||||
return state.setIn(path, ImmutableMap({
|
||||
next,
|
||||
items: ImmutableOrderedSet(filteredReactions),
|
||||
isLoading: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const appendToList = (state, path, reactions, next) => {
|
||||
const filteredReactions = reactions.map(v => {
|
||||
v.account = v.account.id;
|
||||
return v;
|
||||
});
|
||||
return state.updateIn(path, map => {
|
||||
return map.set('next', next).set('isLoading', false).update('items', list => list.concat(filteredReactions));
|
||||
});
|
||||
};
|
||||
|
||||
export default function statusReactions(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case REACTIONS_FETCH_SUCCESS:
|
||||
return normalizeList(state, ['reactions', action.id], action.reactions, action.next);
|
||||
case REACTIONS_EXPAND_SUCCESS:
|
||||
return appendToList(state, ['reactions', action.id], action.reactions, action.next);
|
||||
case REACTIONS_FETCH_REQUEST:
|
||||
case REACTIONS_EXPAND_REQUEST:
|
||||
return state.setIn(['reactions', action.id, 'isLoading'], true);
|
||||
case REACTIONS_FETCH_FAIL:
|
||||
case REACTIONS_EXPAND_FAIL:
|
||||
return state.setIn(['reactions', action.id, 'isLoading'], false);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -94,7 +94,7 @@ class StatusCacheHydrator
|
|||
reactions = @status.reactions(account_id)
|
||||
ActiveModelSerializers::SerializableResource.new(
|
||||
reactions,
|
||||
each_serializer: REST::ReactionSerializer,
|
||||
each_serializer: REST::StatusReactionSerializer,
|
||||
scope: account_id, # terrible
|
||||
scope_name: :current_user
|
||||
).as_json
|
||||
|
|
|
@ -13,6 +13,8 @@
|
|||
# updated_at :datetime not null
|
||||
#
|
||||
class StatusReaction < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :status, inverse_of: :status_reactions
|
||||
belongs_to :custom_emoji, optional: true
|
||||
|
|
|
@ -21,14 +21,6 @@ class REST::ReactionSerializer < ActiveModel::Serializer
|
|||
object.custom_emoji.present?
|
||||
end
|
||||
|
||||
def name
|
||||
if extern?
|
||||
[object.name, '@', object.custom_emoji.domain].join
|
||||
else
|
||||
object.name
|
||||
end
|
||||
end
|
||||
|
||||
def url
|
||||
full_asset_url(object.custom_emoji.image.url)
|
||||
end
|
||||
|
@ -36,10 +28,4 @@ class REST::ReactionSerializer < ActiveModel::Serializer
|
|||
def static_url
|
||||
full_asset_url(object.custom_emoji.image.url(:static))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extern?
|
||||
custom_emoji? && object.custom_emoji.domain.present?
|
||||
end
|
||||
end
|
||||
|
|
55
app/serializers/rest/status_reaction_serializer.rb
Normal file
55
app/serializers/rest/status_reaction_serializer.rb
Normal file
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::StatusReactionSerializer < ActiveModel::Serializer
|
||||
include RoutingHelper
|
||||
|
||||
attributes :name
|
||||
|
||||
attribute :id, unless: :no_id?
|
||||
attribute :me, if: :current_user? # are this and count worth keeping?
|
||||
attribute :url, if: :custom_emoji?
|
||||
attribute :static_url, if: :custom_emoji?
|
||||
attribute :count, if: :respond_to_count?
|
||||
|
||||
belongs_to :account, serializer: REST::AccountSerializer, unless: :respond_to_count?
|
||||
|
||||
delegate :count, to: :object
|
||||
|
||||
def respond_to_count?
|
||||
object.respond_to?(:count)
|
||||
end
|
||||
|
||||
def no_id?
|
||||
object.id.nil?
|
||||
end
|
||||
|
||||
def current_user?
|
||||
!current_user.nil?
|
||||
end
|
||||
|
||||
def custom_emoji?
|
||||
object.custom_emoji.present?
|
||||
end
|
||||
|
||||
def name
|
||||
if extern?
|
||||
[object.name, '@', object.custom_emoji.domain].join
|
||||
else
|
||||
object.name
|
||||
end
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def extern?
|
||||
custom_emoji? && object.custom_emoji.domain.present?
|
||||
end
|
||||
end
|
|
@ -28,7 +28,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
has_many :ordered_mentions, key: :mentions
|
||||
has_many :tags
|
||||
has_many :emojis, serializer: REST::CustomEmojiSerializer
|
||||
has_many :reactions, serializer: REST::ReactionSerializer
|
||||
has_many :reactions, serializer: REST::StatusReactionSerializer
|
||||
|
||||
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
|
||||
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
|
||||
|
|
|
@ -10,6 +10,7 @@ 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 :reactions, controller: :reactions, only: :index
|
||||
resource :reblog, only: :create
|
||||
post :unreblog, to: 'reblogs#destroy'
|
||||
|
||||
|
|
Loading…
Reference in a new issue