mirror of
https://github.com/mastodon/mastodon.git
synced 2025-05-07 12:16:14 +00:00
Merge branch 'main' into patch-1
This commit is contained in:
commit
838b5ffcfb
|
@ -395,7 +395,7 @@ GEM
|
|||
rexml
|
||||
link_header (0.0.8)
|
||||
lint_roller (1.1.0)
|
||||
linzer (0.6.4)
|
||||
linzer (0.6.5)
|
||||
openssl (~> 3.0, >= 3.0.0)
|
||||
rack (>= 2.2, < 4.0)
|
||||
starry (~> 0.2)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -12,6 +12,7 @@ import {
|
|||
muteAccount,
|
||||
unmuteAccount,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
|
@ -23,7 +24,7 @@ import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
|||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -46,6 +47,14 @@ const messages = defineMessages({
|
|||
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
|
||||
block: { id: 'account.block_short', defaultMessage: 'Block' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
addToLists: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
});
|
||||
|
||||
export const Account: React.FC<{
|
||||
|
@ -60,6 +69,7 @@ export const Account: React.FC<{
|
|||
const account = useAppSelector((state) => state.accounts.get(id));
|
||||
const relationship = useAppSelector((state) => state.relationships.get(id));
|
||||
const dispatch = useAppDispatch();
|
||||
const accountUrl = account?.url;
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (relationship?.blocking) {
|
||||
|
@ -77,13 +87,62 @@ export const Account: React.FC<{
|
|||
}
|
||||
}, [dispatch, id, account, relationship]);
|
||||
|
||||
const handleMuteNotifications = useCallback(() => {
|
||||
dispatch(muteAccount(id, true));
|
||||
}, [dispatch, id]);
|
||||
const menu = useMemo(() => {
|
||||
let arr: MenuItem[] = [];
|
||||
|
||||
const handleUnmuteNotifications = useCallback(() => {
|
||||
dispatch(muteAccount(id, false));
|
||||
}, [dispatch, id]);
|
||||
if (defaultAction === 'mute') {
|
||||
const handleMuteNotifications = () => {
|
||||
dispatch(muteAccount(id, true));
|
||||
};
|
||||
|
||||
const handleUnmuteNotifications = () => {
|
||||
dispatch(muteAccount(id, false));
|
||||
};
|
||||
|
||||
arr = [
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship?.muting_notifications
|
||||
? messages.unmute_notifications
|
||||
: messages.mute_notifications,
|
||||
),
|
||||
action: relationship?.muting_notifications
|
||||
? handleUnmuteNotifications
|
||||
: handleMuteNotifications,
|
||||
},
|
||||
];
|
||||
} else if (defaultAction !== 'block') {
|
||||
const handleAddToLists = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr = [
|
||||
{
|
||||
text: intl.formatMessage(messages.addToLists),
|
||||
action: handleAddToLists,
|
||||
},
|
||||
];
|
||||
|
||||
if (accountUrl) {
|
||||
arr.unshift(
|
||||
{
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: accountUrl,
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [dispatch, intl, id, accountUrl, relationship, defaultAction]);
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
|
@ -94,68 +153,42 @@ export const Account: React.FC<{
|
|||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
let button: React.ReactNode, dropdown: React.ReactNode;
|
||||
|
||||
if (account && account.id !== me && relationship) {
|
||||
const { requested, blocking, muting } = relationship;
|
||||
|
||||
if (requested) {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
} else if (blocking) {
|
||||
buttons = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unblock)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else if (muting) {
|
||||
const menu = [
|
||||
{
|
||||
text: intl.formatMessage(
|
||||
relationship.muting_notifications
|
||||
? messages.unmute_notifications
|
||||
: messages.mute_notifications,
|
||||
),
|
||||
action: relationship.muting_notifications
|
||||
? handleUnmuteNotifications
|
||||
: handleMuteNotifications,
|
||||
},
|
||||
];
|
||||
|
||||
buttons = (
|
||||
<>
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
text={intl.formatMessage(messages.unmute)}
|
||||
onClick={handleMute}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
buttons = (
|
||||
<Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />
|
||||
);
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = (
|
||||
<Button
|
||||
text={intl.formatMessage(messages.block)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
}
|
||||
} else {
|
||||
buttons = <FollowButton accountId={id} />;
|
||||
if (menu.length > 0) {
|
||||
dropdown = (
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let muteTimeRemaining;
|
||||
if (defaultAction === 'block') {
|
||||
button = (
|
||||
<Button
|
||||
text={intl.formatMessage(
|
||||
relationship?.blocking ? messages.unblock : messages.block,
|
||||
)}
|
||||
onClick={handleBlock}
|
||||
/>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
button = (
|
||||
<Button
|
||||
text={intl.formatMessage(
|
||||
relationship?.muting ? messages.unmute : messages.mute,
|
||||
)}
|
||||
onClick={handleMute}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
button = <FollowButton accountId={id} />;
|
||||
}
|
||||
|
||||
let muteTimeRemaining: React.ReactNode;
|
||||
|
||||
if (account?.mute_expires_at) {
|
||||
muteTimeRemaining = (
|
||||
|
@ -165,7 +198,7 @@ export const Account: React.FC<{
|
|||
);
|
||||
}
|
||||
|
||||
let verification;
|
||||
let verification: React.ReactNode;
|
||||
|
||||
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
|
||||
|
||||
|
@ -211,7 +244,12 @@ export const Account: React.FC<{
|
|||
</div>
|
||||
</Link>
|
||||
|
||||
{!minimal && <div className='account__relationship'>{buttons}</div>}
|
||||
{!minimal && (
|
||||
<div className='account__relationship'>
|
||||
{dropdown}
|
||||
{button}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{account &&
|
||||
|
|
|
@ -102,7 +102,7 @@ export interface HashtagProps {
|
|||
description?: React.ReactNode;
|
||||
history?: number[];
|
||||
name: string;
|
||||
people: number;
|
||||
people?: number;
|
||||
to: string;
|
||||
uses?: number;
|
||||
withGraph?: boolean;
|
||||
|
|
|
@ -1,25 +1,6 @@
|
|||
import { Switch, Route } from 'react-router-dom';
|
||||
|
||||
import AccountNavigation from 'mastodon/features/account/navigation';
|
||||
import Trends from 'mastodon/features/getting_started/containers/trends_container';
|
||||
import { showTrends } from 'mastodon/initial_state';
|
||||
|
||||
const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
|
||||
|
||||
export const NavigationPortal: React.FC = () => (
|
||||
<div className='navigation-panel__portal'>
|
||||
<Switch>
|
||||
<Route path='/@:acct' exact component={AccountNavigation} />
|
||||
<Route
|
||||
path='/@:acct/tagged/:tagged?'
|
||||
exact
|
||||
component={AccountNavigation}
|
||||
/>
|
||||
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/followers' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/following' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/media' exact component={AccountNavigation} />
|
||||
<Route component={DefaultNavigation} />
|
||||
</Switch>
|
||||
</div>
|
||||
<div className='navigation-panel__portal'>{showTrends && <Trends />}</div>
|
||||
);
|
||||
|
|
43
app/javascript/mastodon/components/remote_hint.tsx
Normal file
43
app/javascript/mastodon/components/remote_hint.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { TimelineHint } from './timeline_hint';
|
||||
|
||||
interface RemoteHintProps {
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export const RemoteHint: React.FC<RemoteHintProps> = ({ accountId }) => {
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const domain = account?.acct ? account.acct.split('@')[1] : undefined;
|
||||
if (
|
||||
!account ||
|
||||
!account.url ||
|
||||
account.acct !== account.username ||
|
||||
!domain
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineHint
|
||||
url={account.url}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='hints.profiles.posts_may_be_missing'
|
||||
defaultMessage='Some posts from this profile may be missing.'
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='hints.profiles.see_more_posts'
|
||||
defaultMessage='See more posts on {domain}'
|
||||
values={{ domain: <strong>{domain}</strong> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,51 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
|
||||
const messages = defineMessages({
|
||||
lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },
|
||||
empty: { id: 'account.featured_tags.last_status_never', defaultMessage: 'No posts' },
|
||||
});
|
||||
|
||||
class FeaturedTags extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.record,
|
||||
featuredTags: ImmutablePropTypes.list,
|
||||
tagged: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account, featuredTags, intl } = this.props;
|
||||
|
||||
if (!account || account.get('suspended') || featuredTags.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='getting-started__trends'>
|
||||
<h4><FormattedMessage id='account.featured_tags.title' defaultMessage="{name}'s featured hashtags" values={{ name: <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /> }} /></h4>
|
||||
|
||||
{featuredTags.take(3).map(featuredTag => (
|
||||
<Hashtag
|
||||
key={featuredTag.get('name')}
|
||||
name={featuredTag.get('name')}
|
||||
to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`}
|
||||
uses={featuredTag.get('statuses_count') * 1}
|
||||
withGraph={false}
|
||||
description={((featuredTag.get('statuses_count') * 1) > 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(FeaturedTags);
|
|
@ -1,17 +0,0 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
|
||||
import FeaturedTags from '../components/featured_tags';
|
||||
|
||||
const mapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
return (state, { accountId }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
featuredTags: state.getIn(['user_lists', 'featured_tags', accountId, 'items'], ImmutableList()),
|
||||
});
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(FeaturedTags);
|
|
@ -1,52 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import FeaturedTags from 'mastodon/features/account/containers/featured_tags_container';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
|
||||
const mapStateToProps = (state, { match: { params: { acct } } }) => {
|
||||
const accountId = state.getIn(['accounts_map', normalizeForLookup(acct)]);
|
||||
|
||||
if (!accountId) {
|
||||
return {
|
||||
isLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
isLoading: false,
|
||||
};
|
||||
};
|
||||
|
||||
class AccountNavigation extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
acct: PropTypes.string,
|
||||
tagged: PropTypes.string,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
|
||||
accountId: PropTypes.string,
|
||||
isLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { accountId, isLoading, match: { params: { tagged } } } = this.props;
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FeaturedTags accountId={accountId} tagged={tagged} />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(AccountNavigation);
|
|
@ -0,0 +1,50 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
|
||||
|
||||
interface EmptyMessageProps {
|
||||
suspended: boolean;
|
||||
hidden: boolean;
|
||||
blockedBy: boolean;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
||||
accountId,
|
||||
suspended,
|
||||
hidden,
|
||||
blockedBy,
|
||||
}) => {
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let message: React.ReactNode = null;
|
||||
|
||||
if (suspended) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_suspended'
|
||||
defaultMessage='Account suspended'
|
||||
/>
|
||||
);
|
||||
} else if (hidden) {
|
||||
message = <LimitedAccountHint accountId={accountId} />;
|
||||
} else if (blockedBy) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_unavailable'
|
||||
defaultMessage='Profile unavailable'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured'
|
||||
defaultMessage='This list is empty'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className='empty-column-indicator'>{message}</div>;
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
|
||||
export type TagMap = ImmutableMap<
|
||||
'id' | 'name' | 'url' | 'statuses_count' | 'last_status_at' | 'accountId',
|
||||
string | null
|
||||
>;
|
||||
|
||||
interface FeaturedTagProps {
|
||||
tag: TagMap;
|
||||
account: string;
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
lastStatusAt: {
|
||||
id: 'account.featured_tags.last_status_at',
|
||||
defaultMessage: 'Last post on {date}',
|
||||
},
|
||||
empty: {
|
||||
id: 'account.featured_tags.last_status_never',
|
||||
defaultMessage: 'No posts',
|
||||
},
|
||||
});
|
||||
|
||||
export const FeaturedTag: React.FC<FeaturedTagProps> = ({ tag, account }) => {
|
||||
const intl = useIntl();
|
||||
const name = tag.get('name') ?? '';
|
||||
const count = Number.parseInt(tag.get('statuses_count') ?? '');
|
||||
return (
|
||||
<Hashtag
|
||||
key={name}
|
||||
name={name}
|
||||
to={`/@${account}/tagged/${name}`}
|
||||
uses={count}
|
||||
withGraph={false}
|
||||
description={
|
||||
count > 0
|
||||
? intl.formatMessage(messages.lastStatusAt, {
|
||||
date: intl.formatDate(tag.get('last_status_at') ?? '', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
}),
|
||||
})
|
||||
: intl.formatMessage(messages.empty)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
156
app/javascript/mastodon/features/account_featured/index.tsx
Normal file
156
app/javascript/mastodon/features/account_featured/index.tsx
Normal file
|
@ -0,0 +1,156 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
|
||||
import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
import StatusContainer from 'mastodon/containers/status_container';
|
||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { AccountHeader } from '../account_timeline/components/account_header';
|
||||
import Column from '../ui/components/column';
|
||||
|
||||
import { EmptyMessage } from './components/empty_message';
|
||||
import { FeaturedTag } from './components/featured_tag';
|
||||
import type { TagMap } from './components/featured_tag';
|
||||
|
||||
interface Params {
|
||||
acct?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const AccountFeatured = () => {
|
||||
const accountId = useAccountId();
|
||||
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
|
||||
const forceEmptyState = suspended || blockedBy || hidden;
|
||||
const { acct = '' } = useParams<Params>();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
void dispatch(expandAccountFeaturedTimeline(accountId));
|
||||
dispatch(fetchFeaturedTags(accountId));
|
||||
}
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
const isLoading = useAppSelector(
|
||||
(state) =>
|
||||
!accountId ||
|
||||
!!(state.timelines as ImmutableMap<string, unknown>).getIn([
|
||||
`account:${accountId}:pinned`,
|
||||
'isLoading',
|
||||
]) ||
|
||||
!!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']),
|
||||
);
|
||||
const featuredTags = useAppSelector(
|
||||
(state) =>
|
||||
state.user_lists.getIn(
|
||||
['featured_tags', accountId, 'items'],
|
||||
ImmutableList(),
|
||||
) as ImmutableList<TagMap>,
|
||||
);
|
||||
const featuredStatusIds = useAppSelector(
|
||||
(state) =>
|
||||
(state.timelines as ImmutableMap<string, unknown>).getIn(
|
||||
[`account:${accountId}:pinned`, 'items'],
|
||||
ImmutableList(),
|
||||
) as ImmutableList<string>,
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AccountFeaturedWrapper accountId={accountId}>
|
||||
<div className='scrollable__append'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
</AccountFeaturedWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) {
|
||||
return (
|
||||
<AccountFeaturedWrapper accountId={accountId}>
|
||||
<EmptyMessage
|
||||
blockedBy={blockedBy}
|
||||
hidden={hidden}
|
||||
suspended={suspended}
|
||||
accountId={accountId}
|
||||
/>
|
||||
<RemoteHint accountId={accountId} />
|
||||
</AccountFeaturedWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<div className='scrollable scrollable--flex'>
|
||||
{accountId && (
|
||||
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
|
||||
)}
|
||||
{!featuredTags.isEmpty() && (
|
||||
<>
|
||||
<h4 className='column-subheading'>
|
||||
<FormattedMessage
|
||||
id='account.featured.hashtags'
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</h4>
|
||||
{featuredTags.map((tag) => (
|
||||
<FeaturedTag key={tag.get('id')} tag={tag} account={acct} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!featuredStatusIds.isEmpty() && (
|
||||
<>
|
||||
<h4 className='column-subheading'>
|
||||
<FormattedMessage
|
||||
id='account.featured.posts'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
</h4>
|
||||
{featuredStatusIds.map((statusId) => (
|
||||
<StatusContainer
|
||||
key={`f-${statusId}`}
|
||||
// @ts-expect-error inferred props are wrong
|
||||
id={statusId}
|
||||
contextType='account'
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<RemoteHint accountId={accountId} />
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountFeaturedWrapper = ({
|
||||
children,
|
||||
accountId,
|
||||
}: React.PropsWithChildren<{ accountId?: string }>) => {
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
<div className='scrollable scrollable--flex'>
|
||||
{accountId && <AccountHeader accountId={accountId} />}
|
||||
{children}
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default AccountFeatured;
|
|
@ -2,25 +2,22 @@ import { useEffect, useCallback } from 'react';
|
|||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { expandAccountMediaTimeline } from 'mastodon/actions/timelines';
|
||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
||||
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
||||
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
|
@ -56,53 +53,11 @@ const getAccountGallery = createSelector(
|
|||
},
|
||||
);
|
||||
|
||||
interface Params {
|
||||
acct?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const RemoteHint: React.FC<{
|
||||
accountId: string;
|
||||
}> = ({ accountId }) => {
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const acct = account?.acct;
|
||||
const url = account?.url;
|
||||
const domain = acct ? acct.split('@')[1] : undefined;
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineHint
|
||||
url={url}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='hints.profiles.posts_may_be_missing'
|
||||
defaultMessage='Some posts from this profile may be missing.'
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='hints.profiles.see_more_posts'
|
||||
defaultMessage='See more posts on {domain}'
|
||||
values={{ domain: <strong>{domain}</strong> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountGallery: React.FC<{
|
||||
multiColumn: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const { acct, id } = useParams<Params>();
|
||||
const dispatch = useAppDispatch();
|
||||
const accountId = useAppSelector(
|
||||
(state) =>
|
||||
id ??
|
||||
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
|
||||
);
|
||||
const accountId = useAccountId();
|
||||
const attachments = useAppSelector((state) =>
|
||||
accountId
|
||||
? getAccountGallery(state, accountId)
|
||||
|
@ -123,33 +78,15 @@ export const AccountGallery: React.FC<{
|
|||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const blockedBy = useAppSelector(
|
||||
(state) =>
|
||||
state.relationships.getIn([accountId, 'blocked_by'], false) as boolean,
|
||||
);
|
||||
const suspended = useAppSelector(
|
||||
(state) => state.accounts.getIn([accountId, 'suspended'], false) as boolean,
|
||||
);
|
||||
const isAccount = !!account;
|
||||
const remote = account?.acct !== account?.username;
|
||||
const hidden = useAppSelector((state) =>
|
||||
accountId ? getAccountHidden(state, accountId) : false,
|
||||
);
|
||||
|
||||
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
|
||||
|
||||
const maxId = attachments.last()?.getIn(['status', 'id']) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
dispatch(lookupAccount(acct));
|
||||
}
|
||||
}, [dispatch, accountId, acct]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && !isAccount) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
}
|
||||
|
||||
if (accountId && isAccount) {
|
||||
void dispatch(expandAccountMediaTimeline(accountId));
|
||||
}
|
||||
|
@ -233,7 +170,7 @@ export const AccountGallery: React.FC<{
|
|||
defaultMessage='Profile unavailable'
|
||||
/>
|
||||
);
|
||||
} else if (remote && attachments.isEmpty()) {
|
||||
} else if (attachments.isEmpty()) {
|
||||
emptyMessage = <RemoteHint accountId={accountId} />;
|
||||
} else {
|
||||
emptyMessage = (
|
||||
|
@ -259,7 +196,7 @@ export const AccountGallery: React.FC<{
|
|||
)
|
||||
}
|
||||
alwaysPrepend
|
||||
append={remote && accountId && <RemoteHint accountId={accountId} />}
|
||||
append={accountId && <RemoteHint accountId={accountId} />}
|
||||
scrollKey='account_gallery'
|
||||
isLoading={isLoading}
|
||||
hasMore={!forceEmptyState && hasMore}
|
||||
|
|
|
@ -956,6 +956,9 @@ export const AccountHeader: React.FC<{
|
|||
|
||||
{!(hideTabs || hidden) && (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${account.acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}`}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
|
|
|
@ -7,12 +7,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { lookupAccount, fetchAccount } from '../../actions/accounts';
|
||||
import { fetchFeaturedTags } from '../../actions/featured_tags';
|
||||
|
@ -21,6 +19,7 @@ import { ColumnBackButton } from '../../components/column_back_button';
|
|||
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||
import StatusList from '../../components/status_list';
|
||||
import Column from '../ui/components/column';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
|
||||
import { AccountHeader } from './components/account_header';
|
||||
import { LimitedAccountHint } from './components/limited_account_hint';
|
||||
|
@ -47,11 +46,8 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
|
|||
|
||||
return {
|
||||
accountId,
|
||||
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
|
||||
remoteUrl: state.getIn(['accounts', accountId, 'url']),
|
||||
isAccount: !!state.getIn(['accounts', accountId]),
|
||||
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
|
||||
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], emptyList),
|
||||
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
|
||||
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
|
||||
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
||||
|
@ -60,24 +56,6 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
|
|||
};
|
||||
};
|
||||
|
||||
const RemoteHint = ({ accountId, url }) => {
|
||||
const acct = useAppSelector(state => state.accounts.get(accountId)?.acct);
|
||||
const domain = acct ? acct.split('@')[1] : undefined;
|
||||
|
||||
return (
|
||||
<TimelineHint
|
||||
url={url}
|
||||
message={<FormattedMessage id='hints.profiles.posts_may_be_missing' defaultMessage='Some posts from this profile may be missing.' />}
|
||||
label={<FormattedMessage id='hints.profiles.see_more_posts' defaultMessage='See more posts on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
RemoteHint.propTypes = {
|
||||
url: PropTypes.string.isRequired,
|
||||
accountId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
class AccountTimeline extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
@ -89,7 +67,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
accountId: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list,
|
||||
featuredStatusIds: ImmutablePropTypes.list,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
withReplies: PropTypes.bool,
|
||||
|
@ -97,8 +74,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
isAccount: PropTypes.bool,
|
||||
suspended: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
remote: PropTypes.bool,
|
||||
remoteUrl: PropTypes.string,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
|
@ -161,7 +136,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
|
||||
const { accountId, statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
|
||||
|
||||
if (isLoading && statusIds.isEmpty()) {
|
||||
return (
|
||||
|
@ -191,8 +166,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
|
||||
}
|
||||
|
||||
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
@ -200,10 +173,9 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
<StatusList
|
||||
prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
|
||||
alwaysPrepend
|
||||
append={remoteMessage}
|
||||
append={<RemoteHint accountId={accountId} />}
|
||||
scrollKey='account_timeline'
|
||||
statusIds={forceEmptyState ? emptyList : statusIds}
|
||||
featuredStatusIds={featuredStatusIds}
|
||||
isLoading={isLoading}
|
||||
hasMore={!forceEmptyState && hasMore}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
|
|
|
@ -105,11 +105,10 @@ export const NotificationRequest = ({ id, accountId, notificationsCount, checked
|
|||
|
||||
<div className='notification-request__actions'>
|
||||
<IconButton iconComponent={DeleteIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
|
||||
<DropdownMenuContainer
|
||||
<Dropdown
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -73,6 +73,7 @@ import {
|
|||
About,
|
||||
PrivacyPolicy,
|
||||
TermsOfService,
|
||||
AccountFeatured,
|
||||
} from './util/async-components';
|
||||
import { ColumnsContextProvider } from './util/columns_context';
|
||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||
|
@ -236,6 +237,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||
|
||||
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
|
||||
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
|
||||
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
|
||||
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
|
||||
<WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} />
|
||||
|
|
|
@ -66,6 +66,10 @@ export function AccountGallery () {
|
|||
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
|
||||
}
|
||||
|
||||
export function AccountFeatured() {
|
||||
return import(/* webpackChunkName: "features/account_featured" */'../../account_featured');
|
||||
}
|
||||
|
||||
export function Followers () {
|
||||
return import(/* webpackChunkName: "features/followers" */'../../followers');
|
||||
}
|
||||
|
|
37
app/javascript/mastodon/hooks/useAccountId.ts
Normal file
37
app/javascript/mastodon/hooks/useAccountId.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { fetchAccount, lookupAccount } from 'mastodon/actions/accounts';
|
||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
interface Params {
|
||||
acct?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function useAccountId() {
|
||||
const { acct, id } = useParams<Params>();
|
||||
const accountId = useAppSelector(
|
||||
(state) =>
|
||||
id ??
|
||||
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
|
||||
);
|
||||
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const isAccount = !!account;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
dispatch(lookupAccount(acct));
|
||||
} else if (!isAccount) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
}
|
||||
}, [dispatch, accountId, acct, isAccount]);
|
||||
|
||||
return accountId;
|
||||
}
|
20
app/javascript/mastodon/hooks/useAccountVisibility.ts
Normal file
20
app/javascript/mastodon/hooks/useAccountVisibility.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
export function useAccountVisibility(accountId?: string) {
|
||||
const blockedBy = useAppSelector(
|
||||
(state) => !!state.relationships.getIn([accountId, 'blocked_by'], false),
|
||||
);
|
||||
const suspended = useAppSelector(
|
||||
(state) => !!state.accounts.getIn([accountId, 'suspended'], false),
|
||||
);
|
||||
const hidden = useAppSelector((state) =>
|
||||
accountId ? Boolean(getAccountHidden(state, accountId)) : false,
|
||||
);
|
||||
|
||||
return {
|
||||
blockedBy,
|
||||
suspended,
|
||||
hidden,
|
||||
};
|
||||
}
|
|
@ -27,9 +27,11 @@
|
|||
"account.edit_profile": "Edit profile",
|
||||
"account.enable_notifications": "Notify me when @{name} posts",
|
||||
"account.endorse": "Feature on profile",
|
||||
"account.featured": "Featured",
|
||||
"account.featured.hashtags": "Hashtags",
|
||||
"account.featured.posts": "Posts",
|
||||
"account.featured_tags.last_status_at": "Last post on {date}",
|
||||
"account.featured_tags.last_status_never": "No posts",
|
||||
"account.featured_tags.title": "{name}'s featured hashtags",
|
||||
"account.follow": "Follow",
|
||||
"account.follow_back": "Follow back",
|
||||
"account.followers": "Followers",
|
||||
|
@ -294,6 +296,7 @@
|
|||
"emoji_button.search_results": "Search results",
|
||||
"emoji_button.symbols": "Symbols",
|
||||
"emoji_button.travel": "Travel & Places",
|
||||
"empty_column.account_featured": "This list is empty",
|
||||
"empty_column.account_hides_collections": "This user has chosen to not make this information available",
|
||||
"empty_column.account_suspended": "Account suspended",
|
||||
"empty_column.account_timeline": "No posts here!",
|
||||
|
|
|
@ -65,6 +65,7 @@
|
|||
"account.statuses_counter": "{count, plural, one {{counter} فرسته} other {{counter} فرسته}}",
|
||||
"account.unblock": "رفع مسدودیت @{name}",
|
||||
"account.unblock_domain": "رفع مسدودیت دامنهٔ {domain}",
|
||||
"account.unblock_domain_short": "آنبلاک",
|
||||
"account.unblock_short": "رفع مسدودیت",
|
||||
"account.unendorse": "معرّفی نکردن در نمایه",
|
||||
"account.unfollow": "پینگرفتن",
|
||||
|
|
|
@ -65,6 +65,7 @@
|
|||
"account.statuses_counter": "{count, plural, one {{counter} publicação} other {{counter} publicações}}",
|
||||
"account.unblock": "Desbloquear @{name}",
|
||||
"account.unblock_domain": "Desbloquear domínio {domain}",
|
||||
"account.unblock_domain_short": "Desbloquear",
|
||||
"account.unblock_short": "Desbloquear",
|
||||
"account.unendorse": "Remover",
|
||||
"account.unfollow": "Deixar de seguir",
|
||||
|
|
|
@ -45,7 +45,6 @@ class Admin::Metrics::Dimension::SpaceUsageDimension < Admin::Metrics::Dimension
|
|||
PreviewCard.sum(:image_file_size),
|
||||
Account.sum(Arel.sql('COALESCE(avatar_file_size, 0) + COALESCE(header_file_size, 0)')),
|
||||
Backup.sum(:dump_file_size),
|
||||
Import.sum(:data_file_size),
|
||||
SiteUpload.sum(:file_file_size),
|
||||
].sum
|
||||
|
||||
|
|
|
@ -26,12 +26,7 @@ class StatusCacheHydrator
|
|||
|
||||
def hydrate_non_reblog_payload(empty_payload, account_id)
|
||||
empty_payload.tap do |payload|
|
||||
payload[:favourited] = Favourite.exists?(account_id: account_id, status_id: @status.id)
|
||||
payload[:reblogged] = Status.exists?(account_id: account_id, reblog_of_id: @status.id)
|
||||
payload[:muted] = ConversationMute.exists?(account_id: account_id, conversation_id: @status.conversation_id)
|
||||
payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: @status.id)
|
||||
payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: @status.id) if @status.account_id == account_id
|
||||
payload[:filtered] = mapped_applied_custom_filter(account_id, @status)
|
||||
fill_status_payload(payload, @status, account_id)
|
||||
|
||||
if payload[:poll]
|
||||
payload[:poll][:voted] = @status.account_id == account_id
|
||||
|
@ -45,18 +40,12 @@ class StatusCacheHydrator
|
|||
payload[:muted] = false
|
||||
payload[:bookmarked] = false
|
||||
payload[:pinned] = false if @status.account_id == account_id
|
||||
payload[:filtered] = mapped_applied_custom_filter(account_id, @status.reblog)
|
||||
|
||||
# If the reblogged status is being delivered to the author who disabled the display of the application
|
||||
# used to create the status, we need to hydrate it here too
|
||||
payload[:reblog][:application] = payload_reblog_application if payload[:reblog][:application].nil? && @status.reblog.account_id == account_id
|
||||
|
||||
payload[:reblog][:favourited] = Favourite.exists?(account_id: account_id, status_id: @status.reblog_of_id)
|
||||
payload[:reblog][:reblogged] = Status.exists?(account_id: account_id, reblog_of_id: @status.reblog_of_id)
|
||||
payload[:reblog][:muted] = ConversationMute.exists?(account_id: account_id, conversation_id: @status.reblog.conversation_id)
|
||||
payload[:reblog][:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: @status.reblog_of_id)
|
||||
payload[:reblog][:pinned] = StatusPin.exists?(account_id: account_id, status_id: @status.reblog_of_id) if @status.reblog.account_id == account_id
|
||||
payload[:reblog][:filtered] = payload[:filtered]
|
||||
fill_status_payload(payload[:reblog], @status.reblog, account_id)
|
||||
|
||||
if payload[:reblog][:poll]
|
||||
if @status.reblog.account_id == account_id
|
||||
|
@ -69,11 +58,21 @@ class StatusCacheHydrator
|
|||
end
|
||||
end
|
||||
|
||||
payload[:filtered] = payload[:reblog][:filtered]
|
||||
payload[:favourited] = payload[:reblog][:favourited]
|
||||
payload[:reblogged] = payload[:reblog][:reblogged]
|
||||
end
|
||||
end
|
||||
|
||||
def fill_status_payload(payload, status, account_id)
|
||||
payload[:favourited] = Favourite.exists?(account_id: account_id, status_id: status.id)
|
||||
payload[:reblogged] = Status.exists?(account_id: account_id, reblog_of_id: status.id)
|
||||
payload[:muted] = ConversationMute.exists?(account_id: account_id, conversation_id: status.conversation_id)
|
||||
payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: status.id)
|
||||
payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id
|
||||
payload[:filtered] = mapped_applied_custom_filter(account_id, status)
|
||||
end
|
||||
|
||||
def mapped_applied_custom_filter(account_id, status)
|
||||
CustomFilter
|
||||
.apply_cached_filters(CustomFilter.cached_filters_for(account_id), status)
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: imports
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# type :integer not null
|
||||
# approved :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# data_file_name :string
|
||||
# data_content_type :string
|
||||
# data_file_size :integer
|
||||
# data_updated_at :datetime
|
||||
# account_id :bigint(8) not null
|
||||
# overwrite :boolean default(FALSE), not null
|
||||
#
|
||||
|
||||
# NOTE: This is a deprecated model, only kept to not break ongoing imports
|
||||
# on upgrade. See `BulkImport` and `Form::Import` for its replacements.
|
||||
|
||||
class Import < ApplicationRecord
|
||||
FILE_TYPES = %w(text/plain text/csv application/csv).freeze
|
||||
MODES = %i(merge overwrite).freeze
|
||||
|
||||
self.inheritance_column = false
|
||||
|
||||
belongs_to :account
|
||||
|
||||
enum :type, { following: 0, blocking: 1, muting: 2, domain_blocking: 3, bookmarks: 4 }
|
||||
|
||||
validates :type, presence: true
|
||||
|
||||
has_attached_file :data
|
||||
validates_attachment_content_type :data, content_type: FILE_TYPES
|
||||
validates_attachment_presence :data
|
||||
|
||||
def mode
|
||||
overwrite? ? :overwrite : :merge
|
||||
end
|
||||
|
||||
def mode=(str)
|
||||
self.overwrite = str.to_sym == :overwrite
|
||||
end
|
||||
end
|
|
@ -1,144 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
|
||||
# NOTE: This is a deprecated service, only kept to not break ongoing imports
|
||||
# on upgrade. See `BulkImportService` for its replacement.
|
||||
|
||||
class ImportService < BaseService
|
||||
ROWS_PROCESSING_LIMIT = 20_000
|
||||
|
||||
def call(import)
|
||||
@import = import
|
||||
@account = @import.account
|
||||
|
||||
case @import.type
|
||||
when 'following'
|
||||
import_follows!
|
||||
when 'blocking'
|
||||
import_blocks!
|
||||
when 'muting'
|
||||
import_mutes!
|
||||
when 'domain_blocking'
|
||||
import_domain_blocks!
|
||||
when 'bookmarks'
|
||||
import_bookmarks!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def import_follows!
|
||||
parse_import_data!(['Account address'])
|
||||
import_relationships!('follow', 'unfollow', @account.following, ROWS_PROCESSING_LIMIT, reblogs: { header: 'Show boosts', default: true }, notify: { header: 'Notify on new posts', default: false }, languages: { header: 'Languages', default: nil })
|
||||
end
|
||||
|
||||
def import_blocks!
|
||||
parse_import_data!(['Account address'])
|
||||
import_relationships!('block', 'unblock', @account.blocking, ROWS_PROCESSING_LIMIT)
|
||||
end
|
||||
|
||||
def import_mutes!
|
||||
parse_import_data!(['Account address'])
|
||||
import_relationships!('mute', 'unmute', @account.muting, ROWS_PROCESSING_LIMIT, notifications: { header: 'Hide notifications', default: true })
|
||||
end
|
||||
|
||||
def import_domain_blocks!
|
||||
parse_import_data!(['#domain'])
|
||||
items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row['#domain'].strip }
|
||||
|
||||
if @import.overwrite?
|
||||
presence_hash = items.index_with(true)
|
||||
|
||||
@account.domain_blocks.find_each do |domain_block|
|
||||
if presence_hash[domain_block.domain]
|
||||
items.delete(domain_block.domain)
|
||||
else
|
||||
@account.unblock_domain!(domain_block.domain)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
items.each do |domain|
|
||||
@account.block_domain!(domain)
|
||||
end
|
||||
|
||||
AfterAccountDomainBlockWorker.push_bulk(items) do |domain|
|
||||
[@account.id, domain]
|
||||
end
|
||||
end
|
||||
|
||||
def import_relationships!(action, undo_action, overwrite_scope, limit, extra_fields = {})
|
||||
local_domain_suffix = "@#{Rails.configuration.x.local_domain}"
|
||||
items = @data.take(limit).map { |row| [row['Account address']&.strip&.delete_suffix(local_domain_suffix), extra_fields.to_h { |key, field_settings| [key, row[field_settings[:header]]&.strip || field_settings[:default]] }] }.reject { |(id, _)| id.blank? }
|
||||
|
||||
if @import.overwrite?
|
||||
presence_hash = items.each_with_object({}) { |(id, extra), mapping| mapping[id] = [true, extra] }
|
||||
|
||||
overwrite_scope.reorder(nil).find_each do |target_account|
|
||||
if presence_hash[target_account.acct]
|
||||
items.delete(target_account.acct)
|
||||
extra = presence_hash[target_account.acct][1]
|
||||
Import::RelationshipWorker.perform_async(@account.id, target_account.acct, action, extra.stringify_keys)
|
||||
else
|
||||
Import::RelationshipWorker.perform_async(@account.id, target_account.acct, undo_action)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
head_items = items.uniq { |acct, _| acct.split('@')[1] }
|
||||
tail_items = items - head_items
|
||||
|
||||
Import::RelationshipWorker.push_bulk(head_items + tail_items) do |acct, extra|
|
||||
[@account.id, acct, action, extra.stringify_keys]
|
||||
end
|
||||
end
|
||||
|
||||
def import_bookmarks!
|
||||
parse_import_data!(['#uri'])
|
||||
items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row['#uri'].strip }
|
||||
|
||||
if @import.overwrite?
|
||||
presence_hash = items.index_with(true)
|
||||
|
||||
@account.bookmarks.find_each do |bookmark|
|
||||
if presence_hash[bookmark.status.uri]
|
||||
items.delete(bookmark.status.uri)
|
||||
else
|
||||
bookmark.destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
statuses = items.filter_map do |uri|
|
||||
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
|
||||
next if status.nil? && ActivityPub::TagManager.instance.local_uri?(uri)
|
||||
|
||||
status || ActivityPub::FetchRemoteStatusService.new.call(uri)
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS, Mastodon::UnexpectedResponseError
|
||||
nil
|
||||
rescue => e
|
||||
Rails.logger.warn "Unexpected error when importing bookmark: #{e}"
|
||||
nil
|
||||
end
|
||||
|
||||
account_ids = statuses.map(&:account_id)
|
||||
preloaded_relations = @account.relations_map(account_ids, skip_blocking_and_muting: true)
|
||||
|
||||
statuses.keep_if { |status| StatusPolicy.new(@account, status, preloaded_relations).show? }
|
||||
|
||||
statuses.each do |status|
|
||||
@account.bookmarks.find_or_create_by!(account: @account, status: status)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_import_data!(default_headers)
|
||||
data = CSV.parse(import_data, headers: true)
|
||||
data = CSV.parse(import_data, headers: default_headers) unless data.headers&.first&.strip&.include?(' ')
|
||||
@data = data.compact_blank
|
||||
end
|
||||
|
||||
def import_data
|
||||
Paperclip.io_adapters.for(@import.data).read.force_encoding(Encoding::UTF_8)
|
||||
end
|
||||
end
|
|
@ -1,57 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# NOTE: This is a deprecated worker, only kept to not break ongoing imports
|
||||
# on upgrade. See `Import::RowWorker` for its replacement.
|
||||
|
||||
class Import::RelationshipWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
sidekiq_options queue: 'pull', retry: 8, dead: false
|
||||
|
||||
def perform(account_id, target_account_uri, relationship, options)
|
||||
from_account = Account.find(account_id)
|
||||
target_domain = domain(target_account_uri)
|
||||
target_account = stoplight_wrapper(target_domain).run { ResolveAccountService.new.call(target_account_uri, { check_delivery_availability: true }) }
|
||||
options.symbolize_keys!
|
||||
|
||||
return if target_account.nil?
|
||||
|
||||
case relationship
|
||||
when 'follow'
|
||||
begin
|
||||
FollowService.new.call(from_account, target_account, **options)
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
raise if FollowLimitValidator.limit_for_account(from_account) < from_account.following_count
|
||||
end
|
||||
when 'unfollow'
|
||||
UnfollowService.new.call(from_account, target_account)
|
||||
when 'block'
|
||||
BlockService.new.call(from_account, target_account)
|
||||
when 'unblock'
|
||||
UnblockService.new.call(from_account, target_account)
|
||||
when 'mute'
|
||||
MuteService.new.call(from_account, target_account, **options)
|
||||
when 'unmute'
|
||||
UnmuteService.new.call(from_account, target_account)
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
|
||||
def domain(uri)
|
||||
domain = uri.is_a?(Account) ? uri.domain : uri.split('@')[1]
|
||||
TagManager.instance.local_domain?(domain) ? nil : TagManager.instance.normalize_domain(domain)
|
||||
end
|
||||
|
||||
def stoplight_wrapper(domain)
|
||||
if domain.present?
|
||||
Stoplight("source:#{domain}")
|
||||
.with_fallback { nil }
|
||||
.with_threshold(1)
|
||||
.with_cool_off_time(5.minutes.seconds)
|
||||
.with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
|
||||
else
|
||||
Stoplight('domain-blank')
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,17 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# NOTE: This is a deprecated worker, only kept to not break ongoing imports
|
||||
# on upgrade. See `ImportWorker` for its replacement.
|
||||
|
||||
class ImportWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
sidekiq_options queue: 'pull', retry: false
|
||||
|
||||
def perform(import_id)
|
||||
import = Import.find(import_id)
|
||||
ImportService.new.call(import)
|
||||
ensure
|
||||
import&.destroy
|
||||
end
|
||||
end
|
|
@ -169,7 +169,7 @@ else
|
|||
end
|
||||
|
||||
Rails.application.reloader.to_prepare do
|
||||
Paperclip.options[:content_type_mappings] = { csv: Import::FILE_TYPES }
|
||||
Paperclip.options[:content_type_mappings] = { csv: %w(text/plain text/csv application/csv) }
|
||||
end
|
||||
|
||||
# In some places in the code, we rescue this exception, but we don't always
|
||||
|
|
|
@ -479,6 +479,22 @@ fa:
|
|||
new:
|
||||
title: درونریزی انسدادهای دامنه
|
||||
no_file: هیچ پروندهای گزیده نشده
|
||||
fasp:
|
||||
debug:
|
||||
callbacks:
|
||||
delete: حذف
|
||||
providers:
|
||||
active: فعال
|
||||
delete: حذف
|
||||
finish_registration: تکمیل ثبتنام
|
||||
name: نام
|
||||
providers: ارائه دهندگان
|
||||
registrations:
|
||||
confirm: تایید
|
||||
reject: رد کردن
|
||||
save: ذخیره
|
||||
sign_in: ورود
|
||||
status: وضعیت
|
||||
follow_recommendations:
|
||||
description_html: "<strong>پیشنهادات پیگیری به کاربران جدید کک میکند تا سریعتر محتوای جالب را پیدا کنند</strong>. زمانی که کاربری هنوز به اندازه کافی با دیگران تعامل نداشته است تا پیشنهادات پیگیری شخصیسازیشده دریافت کند، این حسابها را به جای آن فهرست مشاهده خواهد کرد. این حسابها به صورت روزانه و در ترکیب با بیشتری تعاملات و بالاترین دنبالکنندگان محلی برای یک زبان مشخص بازمحاسبه میشوند."
|
||||
language: برای زبان
|
||||
|
|
|
@ -259,6 +259,7 @@ lv:
|
|||
create_user_role_html: "%{name} nomainīja %{target} lomu"
|
||||
demote_user_html: "%{name} pazemināja lietotāju %{target}"
|
||||
destroy_announcement_html: "%{name} izdzēsa paziņojumu %{target}"
|
||||
destroy_canonical_email_block_html: "%{name} atcēla e-pasta adreses liegumu ar jaucējvērtību %{target}"
|
||||
destroy_custom_emoji_html: "%{name} izdzēsa emocijzīmi %{target}"
|
||||
destroy_domain_allow_html: "%{name} neatļāva federāciju ar domēnu %{target}"
|
||||
destroy_domain_block_html: "%{name} atbloķēja domēnu %{target}"
|
||||
|
@ -409,7 +410,7 @@ lv:
|
|||
permanent_action: Apturēšanas atsaukšana neatjaunos nekādus datus vai attiecības.
|
||||
preamble_html: Tu gatavojies apturēt domēna <strong>%{domain}</strong> un tā apakšdomēnu darbību.
|
||||
remove_all_data: Tādējādi no tava servera tiks noņemts viss šī domēna kontu saturs, multivide un profila dati.
|
||||
stop_communication: Jūsu serveris pārtrauks sazināties ar šiem serveriem.
|
||||
stop_communication: Tavs serveris pārtrauks sazināties ar šiem serveriem.
|
||||
title: Apstiprināt domēna %{domain} bloķēšanu
|
||||
undo_relationships: Tādējādi tiks atsauktas jebkuras sekošanas attiecības starp šo un tavu serveru kontiem.
|
||||
created_msg: Domēna bloķēšana tagad tiek apstrādāta
|
||||
|
@ -949,11 +950,13 @@ lv:
|
|||
message_html: "<strong>Tava objektu krātuve ir nepareizi konfigurēta. Tavu lietotāju privātums ir apdraudēts.</strong>"
|
||||
tags:
|
||||
moderation:
|
||||
not_trendable: Nav izplatīts
|
||||
not_usable: Nav izmantojams
|
||||
pending_review: Gaida pārskatīšanu
|
||||
review_requested: Pieprasīta pārskatīšana
|
||||
reviewed: Pārskatīts
|
||||
title: Stāvoklis
|
||||
trendable: Izplatīts
|
||||
unreviewed: Nepārskatīts
|
||||
usable: Izmantojams
|
||||
name: Nosaukums
|
||||
|
@ -1312,7 +1315,7 @@ lv:
|
|||
appeal_approved: Šis brīdinājums tika sekmīgi pārsūdzēts un vairs nav spēkā
|
||||
appeal_rejected: Apelācija ir noraidīta
|
||||
appeal_submitted_at: Apelācija iesniegta
|
||||
appealed_msg: Jūsu apelācija ir iesniegta. Ja tā tiks apstiprināta, jums tiks paziņots.
|
||||
appealed_msg: Tava pārsūdzība ir iesniegta. Ja tā tiks apstiprināta, Tev tiks paziņots.
|
||||
appeals:
|
||||
submit: Iesniegt apelāciju
|
||||
approve_appeal: Apstiprināt apelāciju
|
||||
|
@ -1332,9 +1335,9 @@ lv:
|
|||
sensitive: Konta atzīmēšana kā jūtīgu
|
||||
silence: Konta ierobežošana
|
||||
suspend: Konta apturēšana
|
||||
your_appeal_approved: Jūsu apelācija ir apstiprināta
|
||||
your_appeal_approved: Tava pārsūdzība tika apstiprināta
|
||||
your_appeal_pending: Jūs esat iesniedzis apelāciju
|
||||
your_appeal_rejected: Jūsu apelācija ir noraidīta
|
||||
your_appeal_rejected: Tava pārsūdzība tika noraidīta
|
||||
edit_profile:
|
||||
basic_information: Pamata informācija
|
||||
hint_html: "<strong>Pielāgo, ko cilvēki redz Tavā publiskajā profilā un blakus Taviem ierakstiem.</strong> Ir lielāka iespējamība, ka citi clivēki sekos Tev un mijiedarbosies ar Tevi, ja Tev ir aizpildīts profils un profila attēls."
|
||||
|
@ -1929,7 +1932,7 @@ lv:
|
|||
'7889238': 3 mēneši
|
||||
min_age_label: Vecuma slieksnis
|
||||
min_favs: Saglabāt ziņas izlsasē vismaz
|
||||
min_favs_hint: Nedzēš nevienu jūsu ziņu, kas ir saņēmusi vismaz tik daudz izcēlumu. Atstājiet tukšu, lai dzēstu ziņas neatkarīgi no to izcēlumu skaita
|
||||
min_favs_hint: Neizdzēš nevienu no Taviem ierakstiem, kas ir pievienoti šādā daudzumā izlašu. Atstāt tukšu, lai izdzēstu ierakstus neatkarīgi no tā, cik izlasēs tie ir pievienoti
|
||||
min_reblogs: Saglabāt ziņas izceltas vismaz
|
||||
min_reblogs_hint: Neizdzēš nevienu no tavām ziņām, kas ir izceltas vismaz tik reižu. Atstāj tukšu, lai dzēstu ziņas neatkarīgi no to izcēlumu skaita
|
||||
stream_entries:
|
||||
|
@ -1978,12 +1981,13 @@ lv:
|
|||
title: "%{domain} pakalpojuma paziņojums"
|
||||
appeal_approved:
|
||||
action: Konta iestatījumi
|
||||
explanation: Apelācija par brīdinājumu jūsu kontam %{strike_date}, ko iesniedzāt %{appeal_date}, ir apstiprināta. Jūsu konts atkal ir labā stāvoklī.
|
||||
subject: Jūsu %{date} apelācija ir apstiprināta
|
||||
explanation: Pārsūdzība par brīdinājumu Tavam kontam %{strike_date}, ko iesniedzi %{appeal_date}, ir apstiprināta. Tavs konts atkal ir labā stāvoklī.
|
||||
subject: Tava %{date} iesniegtā pārsūdzība tika apstiprināta
|
||||
title: Apelācija apstiprināta
|
||||
appeal_rejected:
|
||||
explanation: Apelācija par brīdinājumu jūsu kontam %{strike_date}, ko iesniedzāt %{appeal_date}, ir noraidīta.
|
||||
subject: Jūsu %{date} apelācija ir noraidīta
|
||||
explanation: Pārsūdzība par brīdinājumu Tavam kontam %{strike_date}, ko iesniedzi %{appeal_date}, tika noraidīta.
|
||||
subject: Tava %{date} iesniegta pārsūdzība tika noraidīta
|
||||
subtitle: Tava pārsūdzība tika noraidīta.
|
||||
title: Apelācija noraidīta
|
||||
backup_ready:
|
||||
explanation: Tu pieprasīji pilnu sava Mastodon konta rezerves kopiju.
|
||||
|
@ -1993,6 +1997,9 @@ lv:
|
|||
failed_2fa:
|
||||
details: 'Šeit ir informācija par pieteikšanās mēģinājumu:'
|
||||
explanation: Kāds mēģināja pieteikties Tavā kontā, bet norādīja nederīgu otro autentificēšanās soli.
|
||||
further_actions_html: Ja tas nebiji Tu, mēs iesakām nekavējoties %{action}, jo var būt noticis drošības pārkāpums.
|
||||
subject: Otrās pakāpes autentificēšanās atteice
|
||||
title: Neizdevās otrās pakāpes autentificēšanās
|
||||
suspicious_sign_in:
|
||||
change_password: mainīt paroli
|
||||
details: 'Šeit ir pieteikšanās izvērsums:'
|
||||
|
|
|
@ -320,6 +320,7 @@ pt-BR:
|
|||
title: Novo anúncio
|
||||
preview:
|
||||
explanation_html: 'Esse e-mail será enviado a <strong>%{display_count} usuários</strong>. O texto a seguir será incluído ao e-mail:'
|
||||
title: Visualizar anúncio
|
||||
publish: Publicar
|
||||
published_msg: Anúncio publicado!
|
||||
scheduled_for: Agendado para %{time}
|
||||
|
@ -484,19 +485,30 @@ pt-BR:
|
|||
created_at: Criado em
|
||||
delete: Apagar
|
||||
ip: Endereço de IP
|
||||
request_body: Corpo da solicitação
|
||||
title: Depurar Callbacks
|
||||
providers:
|
||||
active: Ativo
|
||||
base_url: URL Base
|
||||
callback: Callback
|
||||
delete: Apagar
|
||||
edit: Editar provedor
|
||||
finish_registration: Finalizar o cadastro
|
||||
name: Nome
|
||||
providers: Provedores
|
||||
public_key_fingerprint: Impressão digital de chave pública
|
||||
registration_requested: Cadastro solicitado
|
||||
registrations:
|
||||
confirm: Confirmar
|
||||
description: Você recebeu um registro de um FASP. Rejeite se você não tiver iniciado isso. Se você iniciou isso, compare cuidadosamente o nome e a impressão digital da chave antes de confirmar o registro.
|
||||
reject: Rejeitar
|
||||
title: Confirmar o registro FASP
|
||||
save: Salvar
|
||||
select_capabilities: Selecionar recursos
|
||||
sign_in: Entrar
|
||||
status: Estado
|
||||
title: Provedores de serviços auxiliares do Fediverso
|
||||
title: FASP
|
||||
follow_recommendations:
|
||||
description_html: "<strong>A recomendação de contas ajuda os novos usuários a encontrar rapidamente conteúdo interessante</strong>. Quando um usuário ainda não tiver interagido o suficiente para gerar recomendações de contas, essas contas serão recomendadas. Essas recomendações são recalculadas diariamente a partir de uma lista de contas com alto engajamento e maior número de seguidores locais em uma dada língua."
|
||||
language: Na língua
|
||||
|
@ -1927,6 +1939,10 @@ pt-BR:
|
|||
recovery_instructions_html: Se você perder acesso ao seu celular, você pode usar um dos códigos de recuperação abaixo para acessar a sua conta. <strong>Mantenha os códigos de recuperação em um local seguro</strong>. Por exemplo, você pode imprimi-los e guardá-los junto a outros documentos importantes.
|
||||
webauthn: Chaves de segurança
|
||||
user_mailer:
|
||||
announcement_published:
|
||||
description: 'Os administradores do %{domain} estão fazendo um anúncio:'
|
||||
subject: Anúncio de serviço
|
||||
title: Anúncio de serviço de %{domain}
|
||||
appeal_approved:
|
||||
action: Configurações da conta
|
||||
explanation: A revisão da punição na sua conta em %{strike_date} que você enviou em %{appeal_date} foi aprovada. Sua conta está novamente em situação regular.
|
||||
|
|
|
@ -349,6 +349,9 @@ fa:
|
|||
jurisdiction: صلاحیت قانونی
|
||||
min_age: کمینهٔ زمان
|
||||
user:
|
||||
date_of_birth_1i: روز
|
||||
date_of_birth_2i: ماه
|
||||
date_of_birth_3i: سال
|
||||
role: نقش
|
||||
time_zone: منطقهٔ زمانی
|
||||
user_role:
|
||||
|
|
|
@ -129,6 +129,7 @@ Rails.application.routes.draw do
|
|||
constraints(username: %r{[^@/.]+}) do
|
||||
with_options to: 'accounts#show' do
|
||||
get '/@:username', as: :short_account
|
||||
get '/@:username/featured'
|
||||
get '/@:username/with_replies', as: :short_account_with_replies
|
||||
get '/@:username/media', as: :short_account_media
|
||||
get '/@:username/tagged/:tag', as: :short_account_tag
|
||||
|
|
11
db/migrate/20250410144908_drop_imports.rb
Normal file
11
db/migrate/20250410144908_drop_imports.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class DropImports < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
drop_table :imports
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
16
db/schema.rb
16
db/schema.rb
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
||||
|
@ -555,19 +555,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
|||
t.index ["user_id"], name: "index_identities_on_user_id"
|
||||
end
|
||||
|
||||
create_table "imports", force: :cascade do |t|
|
||||
t.integer "type", null: false
|
||||
t.boolean "approved", default: false, null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.string "data_file_name"
|
||||
t.string "data_content_type"
|
||||
t.integer "data_file_size"
|
||||
t.datetime "data_updated_at", precision: nil
|
||||
t.bigint "account_id", null: false
|
||||
t.boolean "overwrite", default: false, null: false
|
||||
end
|
||||
|
||||
create_table "invites", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.string "code", default: "", null: false
|
||||
|
@ -1329,7 +1316,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
|||
add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
|
||||
add_foreign_key "generated_annual_reports", "accounts"
|
||||
add_foreign_key "identities", "users", name: "fk_bea040f377", on_delete: :cascade
|
||||
add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade
|
||||
add_foreign_key "invites", "users", on_delete: :cascade
|
||||
add_foreign_key "list_accounts", "accounts", on_delete: :cascade
|
||||
add_foreign_key "list_accounts", "follow_requests", on_delete: :cascade
|
||||
|
|
|
@ -293,7 +293,6 @@ module Mastodon::CLI
|
|||
Account
|
||||
Backup
|
||||
CustomEmoji
|
||||
Import
|
||||
MediaAttachment
|
||||
PreviewCard
|
||||
SiteUpload
|
||||
|
@ -309,7 +308,6 @@ module Mastodon::CLI
|
|||
[:headers, Account.sum(:header_file_size), Account.local.sum(:header_file_size)],
|
||||
[:preview_cards, PreviewCard.sum(:image_file_size), nil],
|
||||
[:backups, Backup.sum(:dump_file_size), nil],
|
||||
[:imports, Import.sum(:data_file_size), nil],
|
||||
[:settings, SiteUpload.sum(:file_file_size), nil],
|
||||
].map { |label, total, local| [label.to_s.titleize, number_to_human_size(total), local.present? ? number_to_human_size(local) : nil] }
|
||||
end
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:import) do
|
||||
account
|
||||
type :following
|
||||
data { attachment_fixture('imports.txt') }
|
||||
end
|
|
@ -1,10 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Import do
|
||||
describe 'Validations' do
|
||||
it { is_expected.to validate_presence_of(:type) }
|
||||
it { is_expected.to validate_presence_of(:data) }
|
||||
end
|
||||
end
|
|
@ -1,242 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ImportService, :inline_jobs do
|
||||
include RoutingHelper
|
||||
|
||||
let!(:account) { Fabricate(:account, locked: false) }
|
||||
let!(:bob) { Fabricate(:account, username: 'bob', locked: false) }
|
||||
let!(:eve) { Fabricate(:account, username: 'eve', domain: 'example.com', locked: false, protocol: :activitypub, inbox_url: 'https://example.com/inbox') }
|
||||
|
||||
before do
|
||||
stub_request(:post, 'https://example.com/inbox').to_return(status: 200)
|
||||
end
|
||||
|
||||
context 'when importing old-style list of muted users' do
|
||||
subject { described_class.new }
|
||||
|
||||
let(:csv) { attachment_fixture('mute-imports.txt') }
|
||||
|
||||
describe 'when no accounts are muted' do
|
||||
let(:import) { Import.create(account: account, type: 'muting', data: csv) }
|
||||
|
||||
it 'mutes the listed accounts, including notifications' do
|
||||
subject.call(import)
|
||||
expect(account.muting.count).to eq 2
|
||||
expect(Mute.find_by(account: account, target_account: bob).hide_notifications).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when some accounts are muted and overwrite is not set' do
|
||||
let(:import) { Import.create(account: account, type: 'muting', data: csv) }
|
||||
|
||||
it 'mutes the listed accounts, including notifications' do
|
||||
account.mute!(bob, notifications: false)
|
||||
subject.call(import)
|
||||
expect(account.muting.count).to eq 2
|
||||
expect(Mute.find_by(account: account, target_account: bob).hide_notifications).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when some accounts are muted and overwrite is set' do
|
||||
let(:import) { Import.create(account: account, type: 'muting', data: csv, overwrite: true) }
|
||||
|
||||
it 'mutes the listed accounts, including notifications' do
|
||||
account.mute!(bob, notifications: false)
|
||||
subject.call(import)
|
||||
expect(account.muting.count).to eq 2
|
||||
expect(Mute.find_by(account: account, target_account: bob).hide_notifications).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when importing new-style list of muted users' do
|
||||
subject { described_class.new }
|
||||
|
||||
let(:csv) { attachment_fixture('new-mute-imports.txt') }
|
||||
|
||||
describe 'when no accounts are muted' do
|
||||
let(:import) { Import.create(account: account, type: 'muting', data: csv) }
|
||||
|
||||
it 'mutes the listed accounts, respecting notifications' do
|
||||
subject.call(import)
|
||||
expect(account.muting.count).to eq 2
|
||||
expect(Mute.find_by(account: account, target_account: bob).hide_notifications).to be true
|
||||
expect(Mute.find_by(account: account, target_account: eve).hide_notifications).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when some accounts are muted and overwrite is not set' do
|
||||
let(:import) { Import.create(account: account, type: 'muting', data: csv) }
|
||||
|
||||
it 'mutes the listed accounts, respecting notifications' do
|
||||
account.mute!(bob, notifications: true)
|
||||
subject.call(import)
|
||||
expect(account.muting.count).to eq 2
|
||||
expect(Mute.find_by(account: account, target_account: bob).hide_notifications).to be true
|
||||
expect(Mute.find_by(account: account, target_account: eve).hide_notifications).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when some accounts are muted and overwrite is set' do
|
||||
let(:import) { Import.create(account: account, type: 'muting', data: csv, overwrite: true) }
|
||||
|
||||
it 'mutes the listed accounts, respecting notifications' do
|
||||
account.mute!(bob, notifications: true)
|
||||
subject.call(import)
|
||||
expect(account.muting.count).to eq 2
|
||||
expect(Mute.find_by(account: account, target_account: bob).hide_notifications).to be true
|
||||
expect(Mute.find_by(account: account, target_account: eve).hide_notifications).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when importing old-style list of followed users' do
|
||||
subject { described_class.new }
|
||||
|
||||
let(:csv) { attachment_fixture('mute-imports.txt') }
|
||||
|
||||
describe 'when no accounts are followed' do
|
||||
let(:import) { Import.create(account: account, type: 'following', data: csv) }
|
||||
|
||||
it 'follows the listed accounts, including boosts' do
|
||||
subject.call(import)
|
||||
|
||||
expect(account.following.count).to eq 1
|
||||
expect(account.follow_requests.count).to eq 1
|
||||
expect(Follow.find_by(account: account, target_account: bob).show_reblogs).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when some accounts are already followed and overwrite is not set' do
|
||||
let(:import) { Import.create(account: account, type: 'following', data: csv) }
|
||||
|
||||
it 'follows the listed accounts, including notifications' do
|
||||
account.follow!(bob, reblogs: false)
|
||||
subject.call(import)
|
||||
expect(account.following.count).to eq 1
|
||||
expect(account.follow_requests.count).to eq 1
|
||||
expect(Follow.find_by(account: account, target_account: bob).show_reblogs).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when some accounts are already followed and overwrite is set' do
|
||||
let(:import) { Import.create(account: account, type: 'following', data: csv, overwrite: true) }
|
||||
|
||||
it 'mutes the listed accounts, including notifications' do
|
||||
account.follow!(bob, reblogs: false)
|
||||
subject.call(import)
|
||||
expect(account.following.count).to eq 1
|
||||
expect(account.follow_requests.count).to eq 1
|
||||
expect(Follow.find_by(account: account, target_account: bob).show_reblogs).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when importing new-style list of followed users' do
|
||||
subject { described_class.new }
|
||||
|
||||
let(:csv) { attachment_fixture('new-following-imports.txt') }
|
||||
|
||||
describe 'when no accounts are followed' do
|
||||
let(:import) { Import.create(account: account, type: 'following', data: csv) }
|
||||
|
||||
it 'follows the listed accounts, respecting boosts' do
|
||||
subject.call(import)
|
||||
expect(account.following.count).to eq 1
|
||||
expect(account.follow_requests.count).to eq 1
|
||||
expect(Follow.find_by(account: account, target_account: bob).show_reblogs).to be true
|
||||
expect(FollowRequest.find_by(account: account, target_account: eve).show_reblogs).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when some accounts are already followed and overwrite is not set' do
|
||||
let(:import) { Import.create(account: account, type: 'following', data: csv) }
|
||||
|
||||
it 'mutes the listed accounts, respecting notifications' do
|
||||
account.follow!(bob, reblogs: true)
|
||||
subject.call(import)
|
||||
expect(account.following.count).to eq 1
|
||||
expect(account.follow_requests.count).to eq 1
|
||||
expect(Follow.find_by(account: account, target_account: bob).show_reblogs).to be true
|
||||
expect(FollowRequest.find_by(account: account, target_account: eve).show_reblogs).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when some accounts are already followed and overwrite is set' do
|
||||
let(:import) { Import.create(account: account, type: 'following', data: csv, overwrite: true) }
|
||||
|
||||
it 'mutes the listed accounts, respecting notifications' do
|
||||
account.follow!(bob, reblogs: true)
|
||||
subject.call(import)
|
||||
expect(account.following.count).to eq 1
|
||||
expect(account.follow_requests.count).to eq 1
|
||||
expect(Follow.find_by(account: account, target_account: bob).show_reblogs).to be true
|
||||
expect(FollowRequest.find_by(account: account, target_account: eve).show_reblogs).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Based on the bug report 20571 where UTF-8 encoded domains were rejecting import of their users
|
||||
#
|
||||
# https://github.com/mastodon/mastodon/issues/20571
|
||||
context 'with a utf-8 encoded domains' do
|
||||
subject { described_class.new }
|
||||
|
||||
let!(:nare) { Fabricate(:account, username: 'nare', domain: 'թութ.հայ', locked: false, protocol: :activitypub, inbox_url: 'https://թութ.հայ/inbox') }
|
||||
let(:csv) { attachment_fixture('utf8-followers.txt') }
|
||||
let(:import) { Import.create(account: account, type: 'following', data: csv) }
|
||||
|
||||
# Make sure to not actually go to the remote server
|
||||
before do
|
||||
stub_request(:post, nare.inbox_url).to_return(status: 200)
|
||||
end
|
||||
|
||||
it 'follows the listed account' do
|
||||
expect(account.follow_requests.count).to eq 0
|
||||
subject.call(import)
|
||||
expect(account.follow_requests.count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when importing bookmarks' do
|
||||
subject { described_class.new }
|
||||
|
||||
let(:csv) { attachment_fixture('bookmark-imports.txt') }
|
||||
let(:local_account) { Fabricate(:account, username: 'foo', domain: nil) }
|
||||
let!(:remote_status) { Fabricate(:status, uri: 'https://example.com/statuses/1312') }
|
||||
let!(:direct_status) { Fabricate(:status, uri: 'https://example.com/statuses/direct', visibility: :direct) }
|
||||
|
||||
around do |example|
|
||||
local_before = Rails.configuration.x.local_domain
|
||||
web_before = Rails.configuration.x.web_domain
|
||||
Rails.configuration.x.local_domain = 'local.com'
|
||||
Rails.configuration.x.web_domain = 'local.com'
|
||||
example.run
|
||||
Rails.configuration.x.web_domain = web_before
|
||||
Rails.configuration.x.local_domain = local_before
|
||||
end
|
||||
|
||||
before do
|
||||
service = instance_double(ActivityPub::FetchRemoteStatusService)
|
||||
allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:call).with('https://unknown-remote.com/users/bar/statuses/1') do
|
||||
Fabricate(:status, uri: 'https://unknown-remote.com/users/bar/statuses/1')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when no bookmarks are set' do
|
||||
let(:import) { Import.create(account: account, type: 'bookmarks', data: csv) }
|
||||
|
||||
it 'adds the toots the user has access to to bookmarks' do
|
||||
local_status = Fabricate(:status, account: local_account, uri: 'https://local.com/users/foo/statuses/42', id: 42, local: true)
|
||||
subject.call(import)
|
||||
expect(account.bookmarks.map { |bookmark| bookmark.status.id }).to include(local_status.id)
|
||||
expect(account.bookmarks.map { |bookmark| bookmark.status.id }).to include(remote_status.id)
|
||||
expect(account.bookmarks.map { |bookmark| bookmark.status.id }).to_not include(direct_status.id)
|
||||
expect(account.bookmarks.count).to eq 3
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module SystemHelpers
|
||||
FRONTEND_TRANSLATIONS = JSON.parse Rails.root.join('app', 'javascript', 'mastodon', 'locales', 'en.json').read
|
||||
|
||||
def submit_button
|
||||
I18n.t('generic.save_changes')
|
||||
end
|
||||
|
@ -16,4 +18,8 @@ module SystemHelpers
|
|||
def css_id(record)
|
||||
"##{dom_id(record)}"
|
||||
end
|
||||
|
||||
def frontend_translations(key)
|
||||
FRONTEND_TRANSLATIONS[key]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,7 +18,7 @@ RSpec.describe 'Account notes', :inline_jobs, :js, :streaming do
|
|||
visit_profile(other_account)
|
||||
|
||||
note_text = 'This is a personal note'
|
||||
fill_in 'Click to add note', with: note_text
|
||||
fill_in frontend_translations('account_note.placeholder'), with: note_text
|
||||
|
||||
# This is a bit awkward since there is no button to save the change
|
||||
# The easiest way is to send ctrl+enter ourselves
|
||||
|
|
|
@ -17,8 +17,9 @@ RSpec.describe 'Log out' do
|
|||
click_on 'Logout'
|
||||
end
|
||||
|
||||
expect(page).to have_title(I18n.t('auth.login'))
|
||||
expect(page).to have_current_path('/auth/sign_in')
|
||||
expect(page)
|
||||
.to have_title(I18n.t('auth.login'))
|
||||
.and have_current_path('/auth/sign_in')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -28,6 +29,8 @@ RSpec.describe 'Log out' do
|
|||
ignore_js_error(/Failed to load resource: the server responded with a status of 422/)
|
||||
|
||||
visit root_path
|
||||
expect(page)
|
||||
.to have_css('body', class: 'app-body')
|
||||
|
||||
within '.navigation-bar' do
|
||||
click_on 'Menu'
|
||||
|
@ -39,8 +42,9 @@ RSpec.describe 'Log out' do
|
|||
|
||||
click_on 'Log out'
|
||||
|
||||
expect(page).to have_title(I18n.t('auth.login'))
|
||||
expect(page).to have_current_path('/auth/sign_in')
|
||||
expect(page)
|
||||
.to have_title(I18n.t('auth.login'))
|
||||
.and have_current_path('/auth/sign_in')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -17,20 +17,7 @@ RSpec.describe 'NewStatuses', :inline_jobs, :js, :streaming do
|
|||
status_text = 'This is a new status!'
|
||||
|
||||
within('.compose-form') do
|
||||
fill_in "What's on your mind?", with: status_text
|
||||
click_on 'Post'
|
||||
end
|
||||
|
||||
expect(page)
|
||||
.to have_css('.status__content__text', text: status_text)
|
||||
end
|
||||
|
||||
it 'can be posted again' do
|
||||
visit_homepage
|
||||
status_text = 'This is a second status!'
|
||||
|
||||
within('.compose-form') do
|
||||
fill_in "What's on your mind?", with: status_text
|
||||
fill_in frontend_translations('compose_form.placeholder'), with: status_text
|
||||
click_on 'Post'
|
||||
end
|
||||
|
||||
|
|
|
@ -23,24 +23,14 @@ RSpec.describe 'Share page', :js, :streaming do
|
|||
fill_in_form
|
||||
|
||||
expect(page)
|
||||
.to have_css('.notification-bar-message', text: translations['compose.published.body'])
|
||||
.to have_css('.notification-bar-message', text: frontend_translations('compose.published.body'))
|
||||
end
|
||||
|
||||
def fill_in_form
|
||||
within('.compose-form') do
|
||||
fill_in translations['compose_form.placeholder'],
|
||||
fill_in frontend_translations('compose_form.placeholder'),
|
||||
with: 'This is a new status!'
|
||||
click_on translations['compose_form.publish']
|
||||
click_on frontend_translations('compose_form.publish')
|
||||
end
|
||||
end
|
||||
|
||||
def translations
|
||||
# TODO: Extract to system spec helper for re-use?
|
||||
JSON.parse(
|
||||
Rails
|
||||
.root
|
||||
.join('app', 'javascript', 'mastodon', 'locales', 'en.json')
|
||||
.read
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ImportWorker do
|
||||
let(:worker) { described_class.new }
|
||||
let(:service) { instance_double(ImportService, call: true) }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(ImportService).to receive(:new).and_return(service)
|
||||
end
|
||||
|
||||
let(:import) { Fabricate(:import) }
|
||||
|
||||
it 'sends the import to the service' do
|
||||
worker.perform(import.id)
|
||||
|
||||
expect(service).to have_received(:call).with(import)
|
||||
expect { import.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user