Compare commits

...

9 commits

Author SHA1 Message Date
Essem
001d5e24b8
Fix i18n 2024-03-26 21:02:20 -05:00
Essem
da10e39d33
Allow filtering by emoji on API 2024-03-26 20:55:36 -05:00
Essem
e1e4b734f6
Update i18n 2024-03-26 20:55:36 -05:00
Essem
68aea0d2f5
Hydrate reactions on streaming API 2024-03-26 20:55:36 -05:00
Essem
2ab563e06c
Expose reaction ids 2024-03-26 20:55:36 -05:00
Essem
0a6eaa16dc
Fix order of reaction list 2024-03-26 20:55:36 -05:00
Essem
31406cb0f2
Add emoji overlay to avatars in reaction list
I swear there has to be a better way to do this
2024-03-26 20:55:35 -05:00
Essem
a4bfa67151
Add list of users who reacted 2024-03-26 20:55:04 -05:00
Essem
9862e1591e
Add list of reactions to API 2024-03-26 20:43:00 -05:00
21 changed files with 518 additions and 34 deletions

View file

@ -1,8 +1,18 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Statuses::ReactionsController < Api::V1::Statuses::BaseController class Api::V1::Statuses::ReactionsController < Api::V1::Statuses::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:favourites' } REACTIONS_LIMIT = 30
before_action :require_user!
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 def create
ReactService.new.call(current_account, @status, params[:id]) ReactService.new.call(current_account, @status, params[:id])
@ -16,4 +26,83 @@ class Api::V1::Statuses::ReactionsController < Api::V1::Statuses::BaseController
rescue Mastodon::NotPermittedError rescue Mastodon::NotPermittedError
not_found not_found
end 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 end

View file

@ -7,6 +7,10 @@ export const REBLOG_REQUEST = 'REBLOG_REQUEST';
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
export const REBLOG_FAIL = 'REBLOG_FAIL'; 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_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL'; 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_SUCCESS = 'UNFAVOURITE_SUCCESS';
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; 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_REQUEST = 'REBLOGS_FETCH_REQUEST';
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
@ -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) { export function fetchReblogs(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(fetchReblogsRequest(id)); dispatch(fetchReblogsRequest(id));

View file

@ -0,0 +1,5 @@
export interface ApiStatusReactionJSON {
name: string;
static_url?: string | undefined;
url?: string | undefined;
}

View file

@ -14,6 +14,7 @@ import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
import { me } from '../initial_state'; import { me } from '../initial_state';
import { Avatar } from './avatar'; import { Avatar } from './avatar';
import { AvatarOverlay } from './avatar_overlay';
import { Button } from './button'; import { Button } from './button';
import { FollowersCounter } from './counters'; import { FollowersCounter } from './counters';
import { DisplayName } from './display_name'; import { DisplayName } from './display_name';
@ -42,6 +43,7 @@ class Account extends ImmutablePureComponent {
onMute: PropTypes.func, onMute: PropTypes.func,
onMuteNotifications: PropTypes.func, onMuteNotifications: PropTypes.func,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
overlayEmoji: PropTypes.object,
hidden: PropTypes.bool, hidden: PropTypes.bool,
minimal: PropTypes.bool, minimal: PropTypes.bool,
defaultAction: PropTypes.string, defaultAction: PropTypes.string,
@ -50,6 +52,7 @@ class Account extends ImmutablePureComponent {
static defaultProps = { static defaultProps = {
size: 46, size: 46,
overlayEmoji: { name: null }
}; };
handleFollow = () => { handleFollow = () => {
@ -73,7 +76,7 @@ class Account extends ImmutablePureComponent {
}; };
render () { render () {
const { account, intl, hidden, withBio, defaultAction, size, minimal } = this.props; const { account, intl, hidden, withBio, defaultAction, overlayEmoji, size, minimal } = this.props;
if (!account) { if (!account) {
return <EmptyAccount size={size} minimal={minimal} />; return <EmptyAccount size={size} minimal={minimal} />;
@ -138,12 +141,19 @@ class Account extends ImmutablePureComponent {
verification = <VerifiedBadge link={firstVerifiedField.get('value')} />; 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 ( return (
<div className={classNames('account', { 'account--minimal': minimal })}> <div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'> <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')}`}> <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'> <div className='account__avatar-wrapper'>
<Avatar account={account} size={size} /> {statusAvatar}
</div> </div>
<div className='account__contents'> <div className='account__contents'>

View file

@ -1,11 +1,15 @@
import type { Account } from 'flavours/glitch/models/account'; import type { Account } from 'flavours/glitch/models/account';
import type { StatusReaction } from 'flavours/glitch/models/reaction';
import { useHovering } from '../hooks/useHovering'; import { useHovering } from '../hooks/useHovering';
import { autoPlayGif } from '../initial_state'; import { autoPlayGif } from '../initial_state';
import { Emoji } from './status_reactions';
interface Props { interface Props {
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there 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; size?: number;
baseSize?: number; baseSize?: number;
overlaySize?: number; overlaySize?: number;
@ -14,6 +18,7 @@ interface Props {
export const AvatarOverlay: React.FC<Props> = ({ export const AvatarOverlay: React.FC<Props> = ({
account, account,
friend, friend,
emoji,
size = 46, size = 46,
baseSize = 36, baseSize = 36,
overlaySize = 24, overlaySize = 24,
@ -27,6 +32,32 @@ export const AvatarOverlay: React.FC<Props> = ({
? friend?.get('avatar') ? friend?.get('avatar')
: friend?.get('avatar_static'); : 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 ( return (
<div <div
className='account__avatar-overlay' className='account__avatar-overlay'
@ -43,15 +74,7 @@ export const AvatarOverlay: React.FC<Props> = ({
{accountSrc && <img src={accountSrc} alt={account?.get('acct')} />} {accountSrc && <img src={accountSrc} alt={account?.get('acct')} />}
</div> </div>
</div> </div>
<div className='account__avatar-overlay-overlay'> <div className='account__avatar-overlay-overlay'>{overlayElement}</div>
<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> </div>
); );
}; };

View file

@ -32,7 +32,7 @@ import StatusContent from './status_content';
import StatusHeader from './status_header'; import StatusHeader from './status_header';
import StatusIcons from './status_icons'; import StatusIcons from './status_icons';
import StatusPrepend from './status_prepend'; import StatusPrepend from './status_prepend';
import StatusReactions from './status_reactions'; import { StatusReactions } from './status_reactions';
const domParser = new DOMParser(); const domParser = new DOMParser();

View file

@ -15,7 +15,7 @@ import { assetHost } from '../utils/config';
import { AnimatedNumber } from './animated_number'; import { AnimatedNumber } from './animated_number';
export default class StatusReactions extends ImmutablePureComponent { export class StatusReactions extends ImmutablePureComponent {
static propTypes = { static propTypes = {
statusId: PropTypes.string.isRequired, statusId: PropTypes.string.isRequired,
@ -107,6 +107,7 @@ class Reaction extends ImmutablePureComponent {
return ( return (
<button <button
type='button'
className={classNames('reactions-bar__item', { active: reaction.get('me') })} className={classNames('reactions-bar__item', { active: reaction.get('me') })}
onClick={this.handleClick} onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter} onMouseEnter={this.handleMouseEnter}
@ -131,7 +132,7 @@ class Reaction extends ImmutablePureComponent {
} }
class Emoji extends React.PureComponent { export class Emoji extends React.PureComponent {
static propTypes = { static propTypes = {
emoji: PropTypes.string.isRequired, emoji: PropTypes.string.isRequired,

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

View file

@ -21,7 +21,7 @@ import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name'; import { DisplayName } from '../../../components/display_name';
import MediaGallery from '../../../components/media_gallery'; import MediaGallery from '../../../components/media_gallery';
import StatusContent from '../../../components/status_content'; import StatusContent from '../../../components/status_content';
import StatusReactions from '../../../components/status_reactions'; import { StatusReactions } from '../../../components/status_reactions';
import Audio from '../../audio'; import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task'; import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import Video from '../../video'; import Video from '../../video';
@ -137,6 +137,7 @@ class DetailedStatus extends ImmutablePureComponent {
let applicationLink = ''; let applicationLink = '';
let reblogLink = ''; let reblogLink = '';
let favouriteLink = ''; let favouriteLink = '';
let reactionLink = '';
// Depending on user settings, some media are considered as parts of the // Depending on user settings, some media are considered as parts of the
// contents (affected by CW) while other will be displayed outside 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') }} /> <FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
</Link> </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 { } else {
favouriteLink = ( favouriteLink = (
<a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> <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') }} /> <FormattedMessage id='status.favourites' defaultMessage='{count, plural, one {favorite} other {favorites}}' values={{ count: status.get('favourites_count') }} />
</a> </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); const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
@ -339,6 +356,8 @@ class DetailedStatus extends ImmutablePureComponent {
{reblogLink} {reblogLink}
{reblogLink && <>·</>} {reblogLink && <>·</>}
{favouriteLink} {favouriteLink}
·
{reactionLink}
</div> </div>
</div> </div>
</div> </div>

View file

@ -45,6 +45,7 @@ import {
HomeTimeline, HomeTimeline,
Followers, Followers,
Following, Following,
Reactions,
Reblogs, Reblogs,
Favourites, Favourites,
DirectTimeline, 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={['/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/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
<WrappedRoute path='/@:acct/:statusId' exact component={Status} 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/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} /> <WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />

View file

@ -90,6 +90,10 @@ export function Favourites () {
return import(/* webpackChunkName: "flavours/glitch/async/favourites" */'../../favourites'); return import(/* webpackChunkName: "flavours/glitch/async/favourites" */'../../favourites');
} }
export function Reactions () {
return import(/* webpackChunkName: "flavours/glitch/async/reactions" */'../../reactions');
}
export function FollowRequests () { export function FollowRequests () {
return import(/* webpackChunkName: "flavours/glitch/async/follow_requests" */'../../follow_requests'); return import(/* webpackChunkName: "flavours/glitch/async/follow_requests" */'../../follow_requests');
} }

View file

@ -8,6 +8,7 @@
"boost_modal.missing_description": "This toot contains some media without description", "boost_modal.missing_description": "This toot contains some media without description",
"column.favourited_by": "Favourited by", "column.favourited_by": "Favourited by",
"column.heading": "Misc", "column.heading": "Misc",
"column.reacted_by": "Reacted by",
"column.reblogged_by": "Boosted by", "column.reblogged_by": "Boosted by",
"column.subheading": "Miscellaneous options", "column.subheading": "Miscellaneous options",
"column_header.profile": "Profile", "column_header.profile": "Profile",
@ -162,6 +163,8 @@
"status.is_poll": "This toot is a poll", "status.is_poll": "This toot is a poll",
"status.local_only": "Only visible from your instance", "status.local_only": "Only visible from your instance",
"status.react": "React", "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", "status.uncollapse": "Uncollapse",
"suggestions.dismiss": "Dismiss suggestion", "suggestions.dismiss": "Dismiss suggestion",
"tenor.error": "Oops! Something went wrong. Please, try again.", "tenor.error": "Oops! Something went wrong. Please, try again.",

View 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: '',
});

View file

@ -38,6 +38,7 @@ import search from './search';
import server from './server'; import server from './server';
import settings from './settings'; import settings from './settings';
import status_lists from './status_lists'; import status_lists from './status_lists';
import status_reactions from './status_reactions';
import statuses from './statuses'; import statuses from './statuses';
import suggestions from './suggestions'; import suggestions from './suggestions';
import tags from './tags'; import tags from './tags';
@ -86,6 +87,7 @@ const reducers = {
history, history,
tags, tags,
followed_tags, followed_tags,
status_reactions,
notificationPolicy: notificationPolicyReducer, notificationPolicy: notificationPolicyReducer,
notificationRequests: notificationRequestsReducer, notificationRequests: notificationRequestsReducer,
}; };

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

View file

@ -94,7 +94,7 @@ class StatusCacheHydrator
reactions = @status.reactions(account_id) reactions = @status.reactions(account_id)
ActiveModelSerializers::SerializableResource.new( ActiveModelSerializers::SerializableResource.new(
reactions, reactions,
each_serializer: REST::ReactionSerializer, each_serializer: REST::StatusReactionSerializer,
scope: account_id, # terrible scope: account_id, # terrible
scope_name: :current_user scope_name: :current_user
).as_json ).as_json

View file

@ -13,6 +13,8 @@
# updated_at :datetime not null # updated_at :datetime not null
# #
class StatusReaction < ApplicationRecord class StatusReaction < ApplicationRecord
include Paginable
belongs_to :account belongs_to :account
belongs_to :status, inverse_of: :status_reactions belongs_to :status, inverse_of: :status_reactions
belongs_to :custom_emoji, optional: true belongs_to :custom_emoji, optional: true

View file

@ -21,14 +21,6 @@ class REST::ReactionSerializer < ActiveModel::Serializer
object.custom_emoji.present? object.custom_emoji.present?
end end
def name
if extern?
[object.name, '@', object.custom_emoji.domain].join
else
object.name
end
end
def url def url
full_asset_url(object.custom_emoji.image.url) full_asset_url(object.custom_emoji.image.url)
end end
@ -36,10 +28,4 @@ class REST::ReactionSerializer < ActiveModel::Serializer
def static_url def static_url
full_asset_url(object.custom_emoji.image.url(:static)) full_asset_url(object.custom_emoji.image.url(:static))
end end
private
def extern?
custom_emoji? && object.custom_emoji.domain.present?
end
end end

View 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

View file

@ -28,7 +28,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :ordered_mentions, key: :mentions has_many :ordered_mentions, key: :mentions
has_many :tags has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer 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 :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer

View file

@ -10,6 +10,7 @@ namespace :api, format: false do
scope module: :statuses do scope module: :statuses do
resources :reblogged_by, controller: :reblogged_by_accounts, only: :index resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
resources :favourited_by, controller: :favourited_by_accounts, only: :index resources :favourited_by, controller: :favourited_by_accounts, only: :index
resources :reactions, controller: :reactions, only: :index
resource :reblog, only: :create resource :reblog, only: :create
post :unreblog, to: 'reblogs#destroy' post :unreblog, to: 'reblogs#destroy'