Merge branch 'main' into patch-1

This commit is contained in:
Trivikram Kamat 2025-04-10 23:31:16 -07:00 committed by GitHub
commit 838b5ffcfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 592 additions and 937 deletions

View File

@ -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)

View File

@ -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 &&

View File

@ -102,7 +102,7 @@ export interface HashtagProps {
description?: React.ReactNode;
history?: number[];
name: string;
people: number;
people?: number;
to: string;
uses?: number;
withGraph?: boolean;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}
/>
);
};

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

View File

@ -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}

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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} />

View File

@ -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');
}

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

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

View File

@ -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!",

View File

@ -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": "پی‌نگرفتن",

View File

@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: برای زبان

View File

@ -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:'

View File

@ -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.

View File

@ -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:

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -1,7 +0,0 @@
# frozen_string_literal: true
Fabricator(:import) do
account
type :following
data { attachment_fixture('imports.txt') }
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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