Merge commit '04f0468016b450ace8e0ce707b4c21aa18b51262' into glitch-soc/merge-upstream
This commit is contained in:
commit
94d8cdc494
13
.github/workflows/test-ruby.yml
vendored
13
.github/workflows/test-ruby.yml
vendored
@ -150,6 +150,19 @@ jobs:
|
||||
bin/rails db:setup
|
||||
bin/flatware fan bin/rails db:test:prepare
|
||||
|
||||
- name: Cache RSpec persistence file
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
tmp/rspec/examples.txt
|
||||
key: rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}-${{ matrix.ruby-version }}
|
||||
rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
|
||||
rspec-persistence-${{ github.head_ref || github.ref_name }}
|
||||
rspec-persistence-main
|
||||
rspec-persistence
|
||||
|
||||
- run: bin/flatware rspec -r ./spec/flatware_helper.rb
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
|
@ -608,7 +608,7 @@ GEM
|
||||
public_suffix (6.0.1)
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.2)
|
||||
pundit (2.4.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
|
@ -2,6 +2,7 @@ import { debounce } from 'lodash';
|
||||
|
||||
import type { MarkerJSON } from 'mastodon/api_types/markers';
|
||||
import { getAccessToken } from 'mastodon/initial_state';
|
||||
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
|
||||
import type { AppDispatch, RootState } from 'mastodon/store';
|
||||
import { createAppAsyncThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
@ -75,13 +76,8 @@ interface MarkerParam {
|
||||
}
|
||||
|
||||
function getLastNotificationId(state: RootState): string | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
const enableBeta = state.settings.getIn(
|
||||
['notifications', 'groupingBeta'],
|
||||
false,
|
||||
) as boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return enableBeta
|
||||
return selectUseGroupedNotifications(state)
|
||||
? state.notificationGroups.lastReadId
|
||||
: // @ts-expect-error state.notifications is not yet typed
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
|
||||
import { createAppAsyncThunk } from 'mastodon/store';
|
||||
|
||||
import { fetchNotifications } from './notification_groups';
|
||||
@ -6,13 +7,8 @@ import { expandNotifications } from './notifications';
|
||||
export const initializeNotifications = createAppAsyncThunk(
|
||||
'notifications/initialize',
|
||||
(_, { dispatch, getState }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
const enableBeta = getState().settings.getIn(
|
||||
['notifications', 'groupingBeta'],
|
||||
false,
|
||||
) as boolean;
|
||||
|
||||
if (enableBeta) void dispatch(fetchNotifications());
|
||||
if (selectUseGroupedNotifications(getState()))
|
||||
void dispatch(fetchNotifications());
|
||||
else void dispatch(expandNotifications({}));
|
||||
},
|
||||
);
|
||||
|
@ -1,5 +1,7 @@
|
||||
// @ts-check
|
||||
|
||||
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
|
||||
|
||||
import { getLocale } from '../locales';
|
||||
import { connectStream } from '../stream';
|
||||
|
||||
@ -103,7 +105,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
const notificationJSON = JSON.parse(data.payload);
|
||||
dispatch(updateNotifications(notificationJSON, messages, locale));
|
||||
// TODO: remove this once the groups feature replaces the previous one
|
||||
if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) {
|
||||
if(selectUseGroupedNotifications(getState())) {
|
||||
dispatch(processNewNotificationForGroups(notificationJSON));
|
||||
}
|
||||
break;
|
||||
@ -112,7 +114,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
const state = getState();
|
||||
if (state.notifications.top || !state.notifications.mounted)
|
||||
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
|
||||
if(state.settings.getIn(['notifications', 'groupingBeta'], false)) {
|
||||
if (selectUseGroupedNotifications(state)) {
|
||||
dispatch(refreshStaleNotificationGroups());
|
||||
}
|
||||
break;
|
||||
@ -145,7 +147,7 @@ async function refreshHomeTimelineAndNotification(dispatch, getState) {
|
||||
await dispatch(expandHomeTimeline({ maxId: undefined }));
|
||||
|
||||
// TODO: remove this once the groups feature replaces the previous one
|
||||
if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) {
|
||||
if(selectUseGroupedNotifications(getState())) {
|
||||
// TODO: polling for merged notifications
|
||||
try {
|
||||
await dispatch(pollRecentGroupNotifications());
|
||||
|
@ -12,9 +12,11 @@ import { connect } from 'react-redux';
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react';
|
||||
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
@ -25,6 +27,7 @@ import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import LinkFooter from 'mastodon/features/ui/components/link_footer';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
|
||||
|
||||
import { me, showTrends } from '../../initial_state';
|
||||
import { NavigationBar } from '../compose/components/navigation_bar';
|
||||
@ -43,6 +46,8 @@ const messages = defineMessages({
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' },
|
||||
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
@ -99,7 +104,7 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
|
||||
render () {
|
||||
const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
const { signedIn, permissions } = this.props.identity;
|
||||
|
||||
const navItems = [];
|
||||
|
||||
@ -136,6 +141,13 @@ class GettingStarted extends ImmutablePureComponent {
|
||||
<ColumnSubheading key='header-settings' text={intl.formatMessage(messages.settings_subheading)} />,
|
||||
<ColumnLink key='preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />,
|
||||
);
|
||||
|
||||
if (canManageReports(permissions)) {
|
||||
navItems.push(<ColumnLink key='moderation' href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />);
|
||||
}
|
||||
if (canViewAdminDashboard(permissions)) {
|
||||
navItems.push(<ColumnLink key='administration' href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { forceGroupedNotifications } from 'mastodon/initial_state';
|
||||
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions';
|
||||
|
||||
import ClearColumnButton from './clear_column_button';
|
||||
@ -67,15 +68,17 @@ class ColumnSettings extends PureComponent {
|
||||
|
||||
<PolicyControls />
|
||||
|
||||
<section role='group' aria-labelledby='notifications-beta'>
|
||||
<h3 id='notifications-beta'>
|
||||
<FormattedMessage id='notifications.column_settings.beta.category' defaultMessage='Experimental features' />
|
||||
</h3>
|
||||
{!forceGroupedNotifications && (
|
||||
<section role='group' aria-labelledby='notifications-beta'>
|
||||
<h3 id='notifications-beta'>
|
||||
<FormattedMessage id='notifications.column_settings.beta.category' defaultMessage='Experimental features' />
|
||||
</h3>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['groupingBeta']} onChange={onChange} label={groupingShowStr} />
|
||||
</div>
|
||||
</section>
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['groupingBeta']} onChange={onChange} label={groupingShowStr} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section role='group' aria-labelledby='notifications-unread-markers'>
|
||||
<h3 id='notifications-unread-markers'>
|
||||
|
@ -1,9 +1,10 @@
|
||||
import Notifications from 'mastodon/features/notifications';
|
||||
import Notifications_v2 from 'mastodon/features/notifications_v2';
|
||||
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
export const NotificationsWrapper = (props) => {
|
||||
const optedInGroupedNotifications = useAppSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false));
|
||||
const optedInGroupedNotifications = useAppSelector(selectUseGroupedNotifications);
|
||||
|
||||
return (
|
||||
optedInGroupedNotifications ? <Notifications_v2 {...props} /> : <Notifications {...props} />
|
||||
|
@ -1,28 +1,17 @@
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
import type Immutable from 'immutable';
|
||||
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import AttachmentList from 'mastodon/components/attachment_list';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
||||
import PrivacyDropdown from 'mastodon/features/compose/components/privacy_dropdown';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
import { EmbeddedStatus } from 'mastodon/features/notifications_v2/components/embedded_status';
|
||||
import type { Status, StatusVisibility } from 'mastodon/models/status';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { Avatar } from '../../../components/avatar';
|
||||
import { Button } from '../../../components/button';
|
||||
import { DisplayName } from '../../../components/display_name';
|
||||
import { RelativeTimestamp } from '../../../components/relative_timestamp';
|
||||
import StatusContent from '../../../components/status_content';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel_reblog: {
|
||||
id: 'status.cancel_reblog_private',
|
||||
@ -37,18 +26,17 @@ export const BoostModal: React.FC<{
|
||||
onReblog: (status: Status, privacy: StatusVisibility) => void;
|
||||
}> = ({ status, onReblog, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
const default_privacy = useAppSelector(
|
||||
const defaultPrivacy = useAppSelector(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
(state) => state.compose.get('default_privacy') as StatusVisibility,
|
||||
);
|
||||
|
||||
const account = status.get('account') as Account;
|
||||
const statusId = status.get('id') as string;
|
||||
const statusVisibility = status.get('visibility') as StatusVisibility;
|
||||
|
||||
const [privacy, setPrivacy] = useState<StatusVisibility>(
|
||||
statusVisibility === 'private' ? 'private' : default_privacy,
|
||||
statusVisibility === 'private' ? 'private' : defaultPrivacy,
|
||||
);
|
||||
|
||||
const onPrivacyChange = useCallback((value: StatusVisibility) => {
|
||||
@ -60,20 +48,9 @@ export const BoostModal: React.FC<{
|
||||
onClose();
|
||||
}, [onClose, onReblog, status, privacy]);
|
||||
|
||||
const handleAccountClick = useCallback<MouseEventHandler>(
|
||||
(e) => {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
history.push(`/@${account.acct}`);
|
||||
}
|
||||
},
|
||||
[history, onClose, account],
|
||||
);
|
||||
|
||||
const buttonText = status.get('reblogged')
|
||||
? messages.cancel_reblog
|
||||
: messages.reblog;
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const findContainer = useCallback(
|
||||
() => document.getElementsByClassName('modal-root__container')[0],
|
||||
@ -81,81 +58,78 @@ export const BoostModal: React.FC<{
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal boost-modal'>
|
||||
<div className='boost-modal__container'>
|
||||
<div
|
||||
className={classNames(
|
||||
'status',
|
||||
`status-${statusVisibility}`,
|
||||
'light',
|
||||
)}
|
||||
>
|
||||
<div className='status__info'>
|
||||
<a
|
||||
href={`/@${account.acct}/${status.get('id') as string}`}
|
||||
className='status__relative-time'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<span className='status__visibility-icon'>
|
||||
<VisibilityIcon visibility={statusVisibility} />
|
||||
</span>
|
||||
<RelativeTimestamp
|
||||
timestamp={status.get('created_at') as string}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a
|
||||
onClick={handleAccountClick}
|
||||
href={`/@${account.acct}`}
|
||||
className='status__display-name'
|
||||
>
|
||||
<div className='status__avatar'>
|
||||
<Avatar account={account} size={48} />
|
||||
</div>
|
||||
|
||||
<DisplayName account={account} />
|
||||
</a>
|
||||
<div className='modal-root__modal safety-action-modal'>
|
||||
<div className='safety-action-modal__top'>
|
||||
<div className='safety-action-modal__header'>
|
||||
<div className='safety-action-modal__header__icon'>
|
||||
<Icon icon={RepeatIcon} id='retweet' />
|
||||
</div>
|
||||
|
||||
{/* @ts-expect-error Expected until StatusContent is typed */}
|
||||
<StatusContent status={status} />
|
||||
<div>
|
||||
<h1>
|
||||
{status.get('reblogged') ? (
|
||||
<FormattedMessage
|
||||
id='boost_modal.undo_reblog'
|
||||
defaultMessage='Unboost post?'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='boost_modal.reblog'
|
||||
defaultMessage='Boost post?'
|
||||
/>
|
||||
)}
|
||||
</h1>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='boost_modal.combo'
|
||||
defaultMessage='You can press {combo} to skip this next time'
|
||||
values={{
|
||||
combo: (
|
||||
<span className='hotkey-combination'>
|
||||
<kbd>Shift</kbd>+<Icon id='retweet' icon={RepeatIcon} />
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(status.get('media_attachments') as Immutable.List<unknown>).size >
|
||||
0 && (
|
||||
<AttachmentList compact media={status.get('media_attachments')} />
|
||||
)}
|
||||
<div className='safety-action-modal__status'>
|
||||
<EmbeddedStatus statusId={statusId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='boost-modal__action-bar'>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id='boost_modal.combo'
|
||||
defaultMessage='You can press {combo} to skip this next time'
|
||||
values={{
|
||||
combo: (
|
||||
<span>
|
||||
Shift + <Icon id='retweet' icon={RepeatIcon} />
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
<div className={classNames('safety-action-modal__bottom')}>
|
||||
<div className='safety-action-modal__actions'>
|
||||
{!status.get('reblogged') && (
|
||||
<PrivacyDropdown
|
||||
noDirect
|
||||
value={privacy}
|
||||
container={findContainer}
|
||||
onChange={onPrivacyChange}
|
||||
disabled={statusVisibility === 'private'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='spacer' />
|
||||
|
||||
<button onClick={handleCancel} className='link-button'>
|
||||
<FormattedMessage
|
||||
id='confirmation_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
onClick={handleReblog}
|
||||
text={intl.formatMessage(
|
||||
status.get('reblogged')
|
||||
? messages.cancel_reblog
|
||||
: messages.reblog,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{statusVisibility !== 'private' && !status.get('reblogged') && (
|
||||
<PrivacyDropdown
|
||||
noDirect
|
||||
value={privacy}
|
||||
container={findContainer}
|
||||
onChange={onPrivacyChange}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
text={intl.formatMessage(buttonText)}
|
||||
onClick={handleReblog}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -7,16 +7,17 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
|
||||
import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react';
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
|
||||
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
|
||||
import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
@ -34,7 +35,9 @@ import { NavigationPortal } from 'mastodon/components/navigation_portal';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { timelinePreview, trendsEnabled } from 'mastodon/initial_state';
|
||||
import { transientSingleColumn } from 'mastodon/is_mobile';
|
||||
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
|
||||
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
||||
import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
|
||||
|
||||
import ColumnLink from './column_link';
|
||||
import DisabledAccountBanner from './disabled_account_banner';
|
||||
@ -51,6 +54,8 @@ const messages = defineMessages({
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' },
|
||||
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
|
||||
followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
|
||||
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
|
||||
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
|
||||
@ -60,7 +65,7 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
const NotificationsLink = () => {
|
||||
const optedInGroupedNotifications = useSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false));
|
||||
const optedInGroupedNotifications = useSelector(selectUseGroupedNotifications);
|
||||
const count = useSelector(state => state.getIn(['notifications', 'unread']));
|
||||
const intl = useIntl();
|
||||
|
||||
@ -114,7 +119,7 @@ class NavigationPanel extends Component {
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
const { signedIn, disabledAccountId } = this.props.identity;
|
||||
const { signedIn, disabledAccountId, permissions } = this.props.identity;
|
||||
|
||||
let banner = undefined;
|
||||
|
||||
@ -176,6 +181,9 @@ class NavigationPanel extends Component {
|
||||
<hr />
|
||||
|
||||
<ColumnLink transparent href='/settings/preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} />
|
||||
|
||||
{canManageReports(permissions) && <ColumnLink transparent href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />}
|
||||
{canViewAdminDashboard(permissions) && <ColumnLink transparent href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
@ -43,6 +43,7 @@
|
||||
* @property {boolean=} use_pending_items
|
||||
* @property {string} version
|
||||
* @property {string} sso_redirect
|
||||
* @property {boolean} force_grouped_notifications
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -118,6 +119,7 @@ export const criticalUpdatesPending = initialState?.critical_updates_pending;
|
||||
// @ts-expect-error
|
||||
export const statusPageUrl = getMeta('status_page_url');
|
||||
export const sso_redirect = getMeta('sso_redirect');
|
||||
export const forceGroupedNotifications = getMeta('force_grouped_notifications');
|
||||
|
||||
/**
|
||||
* @returns {string | undefined}
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "Bloquem l'usuari?",
|
||||
"block_modal.you_wont_see_mentions": "No veureu publicacions que l'esmentin.",
|
||||
"boost_modal.combo": "Pots prémer {combo} per a evitar-ho el pròxim cop",
|
||||
"boost_modal.reblog": "Voleu impulsar la publicació?",
|
||||
"boost_modal.undo_reblog": "Voleu retirar l'impuls a la publicació?",
|
||||
"bundle_column_error.copy_stacktrace": "Copia l'informe d'error",
|
||||
"bundle_column_error.error.body": "No s'ha pogut renderitzar la pàgina sol·licitada. Podria ser per un error en el nostre codi o per un problema de compatibilitat del navegador.",
|
||||
"bundle_column_error.error.title": "Oh, no!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "No veureu publicacions que els esmentin.",
|
||||
"mute_modal.you_wont_see_posts": "Encara poden veure les vostres publicacions, però no veureu les seves.",
|
||||
"navigation_bar.about": "Quant a",
|
||||
"navigation_bar.administration": "Administració",
|
||||
"navigation_bar.advanced_interface": "Obre en la interfície web avançada",
|
||||
"navigation_bar.blocks": "Usuaris blocats",
|
||||
"navigation_bar.bookmarks": "Marcadors",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "Seguint i seguidors",
|
||||
"navigation_bar.lists": "Llistes",
|
||||
"navigation_bar.logout": "Tanca la sessió",
|
||||
"navigation_bar.moderation": "Moderació",
|
||||
"navigation_bar.mutes": "Usuaris silenciats",
|
||||
"navigation_bar.opened_in_classic_interface": "Els tuts, comptes i altres pàgines especifiques s'obren per defecte en la interfície web clàssica.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "Profil blockieren?",
|
||||
"block_modal.you_wont_see_mentions": "Du wirst keine Beiträge sehen, die dieses Profil erwähnen.",
|
||||
"boost_modal.combo": "Mit {combo} erscheint dieses Fenster beim nächsten Mal nicht mehr",
|
||||
"boost_modal.reblog": "Beitrag teilen?",
|
||||
"boost_modal.undo_reblog": "Beitrag nicht mehr teilen?",
|
||||
"bundle_column_error.copy_stacktrace": "Fehlerbericht kopieren",
|
||||
"bundle_column_error.error.body": "Die angeforderte Seite konnte nicht dargestellt werden. Dies könnte auf einen Fehler in unserem Code oder auf ein Browser-Kompatibilitätsproblem zurückzuführen sein.",
|
||||
"bundle_column_error.error.title": "Oh nein!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "Du wirst keine Beiträge sehen, die dieses Profil erwähnen.",
|
||||
"mute_modal.you_wont_see_posts": "Deine Beiträge können weiterhin angesehen werden, aber du wirst deren Beiträge nicht mehr sehen.",
|
||||
"navigation_bar.about": "Über",
|
||||
"navigation_bar.administration": "Administration",
|
||||
"navigation_bar.advanced_interface": "Im erweiterten Webinterface öffnen",
|
||||
"navigation_bar.blocks": "Blockierte Profile",
|
||||
"navigation_bar.bookmarks": "Lesezeichen",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "Follower und Folge ich",
|
||||
"navigation_bar.lists": "Listen",
|
||||
"navigation_bar.logout": "Abmelden",
|
||||
"navigation_bar.moderation": "Moderation",
|
||||
"navigation_bar.mutes": "Stummgeschaltete Profile",
|
||||
"navigation_bar.opened_in_classic_interface": "Beiträge, Konten und andere bestimmte Seiten werden standardmäßig im klassischen Webinterface geöffnet.",
|
||||
"navigation_bar.personal": "Persönlich",
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "Block user?",
|
||||
"block_modal.you_wont_see_mentions": "You won't see posts that mention them.",
|
||||
"boost_modal.combo": "You can press {combo} to skip this next time",
|
||||
"boost_modal.reblog": "Boost post?",
|
||||
"boost_modal.undo_reblog": "Unboost post?",
|
||||
"bundle_column_error.copy_stacktrace": "Copy error report",
|
||||
"bundle_column_error.error.body": "The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.",
|
||||
"bundle_column_error.error.title": "Oh, no!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "You won't see posts that mention them.",
|
||||
"mute_modal.you_wont_see_posts": "They can still see your posts, but you won't see theirs.",
|
||||
"navigation_bar.about": "About",
|
||||
"navigation_bar.administration": "Administration",
|
||||
"navigation_bar.advanced_interface": "Open in advanced web interface",
|
||||
"navigation_bar.blocks": "Blocked users",
|
||||
"navigation_bar.bookmarks": "Bookmarks",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "Follows and followers",
|
||||
"navigation_bar.lists": "Lists",
|
||||
"navigation_bar.logout": "Logout",
|
||||
"navigation_bar.moderation": "Moderation",
|
||||
"navigation_bar.mutes": "Muted users",
|
||||
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "¿Bloquear usuario?",
|
||||
"block_modal.you_wont_see_mentions": "No verás mensajes que los mencionen.",
|
||||
"boost_modal.combo": "Podés hacer clic en {combo} para saltar esto la próxima vez",
|
||||
"boost_modal.reblog": "¿Adherir al mensaje?",
|
||||
"boost_modal.undo_reblog": "¿Dejar de adherir al mensaje?",
|
||||
"bundle_column_error.copy_stacktrace": "Copiar informe de error",
|
||||
"bundle_column_error.error.body": "La página solicitada no pudo ser cargada. Podría deberse a un error de programación en nuestro código o a un problema de compatibilidad con el navegador web.",
|
||||
"bundle_column_error.error.title": "¡Epa!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "No verás mensajes que los mencionen.",
|
||||
"mute_modal.you_wont_see_posts": "Todavía pueden ver tus mensajes, pero vos no verás los suyos.",
|
||||
"navigation_bar.about": "Información",
|
||||
"navigation_bar.administration": "Administración",
|
||||
"navigation_bar.advanced_interface": "Abrir en interface web avanzada",
|
||||
"navigation_bar.blocks": "Usuarios bloqueados",
|
||||
"navigation_bar.bookmarks": "Marcadores",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "Cuentas seguidas y seguidores",
|
||||
"navigation_bar.lists": "Listas",
|
||||
"navigation_bar.logout": "Cerrar sesión",
|
||||
"navigation_bar.moderation": "Moderación",
|
||||
"navigation_bar.mutes": "Usuarios silenciados",
|
||||
"navigation_bar.opened_in_classic_interface": "Los mensajes, las cuentas y otras páginas específicas se abren predeterminadamente en la interface web clásica.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
|
@ -33,7 +33,9 @@
|
||||
"account.follow_back": "Jälgi vastu",
|
||||
"account.followers": "Jälgijad",
|
||||
"account.followers.empty": "Keegi ei jälgi veel seda kasutajat.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} jälgija} other {{counter} jälgijat}}",
|
||||
"account.following": "Jälgib",
|
||||
"account.following_counter": "{count, plural, one {{counter} jälgib} other {{counter} jälgib}}",
|
||||
"account.follows.empty": "See kasutaja ei jälgi veel kedagi.",
|
||||
"account.go_to_profile": "Mine profiilile",
|
||||
"account.hide_reblogs": "Peida @{name} jagamised",
|
||||
@ -59,6 +61,7 @@
|
||||
"account.requested_follow": "{name} on taodelnud sinu jälgimist",
|
||||
"account.share": "Jaga @{name} profiili",
|
||||
"account.show_reblogs": "Näita @{name} jagamisi",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}",
|
||||
"account.unblock": "Eemalda blokeering @{name}",
|
||||
"account.unblock_domain": "Tee {domain} nähtavaks",
|
||||
"account.unblock_short": "Eemalda blokeering",
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "Estetäänkö käyttäjä?",
|
||||
"block_modal.you_wont_see_mentions": "Et näe enää julkaisuja, joissa hänet mainitaan.",
|
||||
"boost_modal.combo": "Ensi kerralla voit ohittaa tämän painamalla {combo}",
|
||||
"boost_modal.reblog": "Tehostetaanko julkaisua?",
|
||||
"boost_modal.undo_reblog": "Perutaanko julkaisun tehostus?",
|
||||
"bundle_column_error.copy_stacktrace": "Kopioi virheraportti",
|
||||
"bundle_column_error.error.body": "Pyydettyä sivua ei voitu hahmontaa. Se voi johtua virheestä koodissamme tai selaimen yhteensopivuudessa.",
|
||||
"bundle_column_error.error.title": "Voi ei!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "Et näe enää julkaisuja, joissa hänet mainitaan.",
|
||||
"mute_modal.you_wont_see_posts": "Hän voi yhä nähdä julkaisusi, mutta sinä et näe hänen.",
|
||||
"navigation_bar.about": "Tietoja",
|
||||
"navigation_bar.administration": "Ylläpito",
|
||||
"navigation_bar.advanced_interface": "Avaa edistyneessä selainkäyttöliittymässä",
|
||||
"navigation_bar.blocks": "Estetyt käyttäjät",
|
||||
"navigation_bar.bookmarks": "Kirjanmerkit",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "Seuratut ja seuraajat",
|
||||
"navigation_bar.lists": "Listat",
|
||||
"navigation_bar.logout": "Kirjaudu ulos",
|
||||
"navigation_bar.moderation": "Moderointi",
|
||||
"navigation_bar.mutes": "Mykistetyt käyttäjät",
|
||||
"navigation_bar.opened_in_classic_interface": "Julkaisut, profiilit ja tietyt muut sivut avautuvat oletuksena perinteiseen selainkäyttöliittymään.",
|
||||
"navigation_bar.personal": "Henkilökohtaiset",
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "Bloquear usuaria?",
|
||||
"block_modal.you_wont_see_mentions": "Non verás publicacións que a mencionen.",
|
||||
"boost_modal.combo": "Preme {combo} para ignorar isto na seguinte vez",
|
||||
"boost_modal.reblog": "Promover publicación?",
|
||||
"boost_modal.undo_reblog": "Retirar promoción?",
|
||||
"bundle_column_error.copy_stacktrace": "Copiar informe do erro",
|
||||
"bundle_column_error.error.body": "Non se puido mostrar a páxina solicitada. Podería deberse a un problema no código, ou incompatiblidade co navegador.",
|
||||
"bundle_column_error.error.title": "Vaites!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "Non verás as publicacións que a mencionen.",
|
||||
"mute_modal.you_wont_see_posts": "Seguirá podendo ler as túas publicacións, pero non verás as súas.",
|
||||
"navigation_bar.about": "Sobre",
|
||||
"navigation_bar.administration": "Administración",
|
||||
"navigation_bar.advanced_interface": "Abrir coa interface web avanzada",
|
||||
"navigation_bar.blocks": "Usuarias bloqueadas",
|
||||
"navigation_bar.bookmarks": "Marcadores",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "Seguindo e seguidoras",
|
||||
"navigation_bar.lists": "Listaxes",
|
||||
"navigation_bar.logout": "Pechar sesión",
|
||||
"navigation_bar.moderation": "Moderación",
|
||||
"navigation_bar.mutes": "Usuarias silenciadas",
|
||||
"navigation_bar.opened_in_classic_interface": "Publicacións, contas e outras páxinas dedicadas ábrense por defecto na interface web clásica.",
|
||||
"navigation_bar.personal": "Persoal",
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "Útiloka notanda?",
|
||||
"block_modal.you_wont_see_mentions": "Þú munt ekki sjá færslur sem minnast á viðkomandi aðila.",
|
||||
"boost_modal.combo": "Þú getur ýtt á {combo} til að sleppa þessu næst",
|
||||
"boost_modal.reblog": "Endurbirta færslu?",
|
||||
"boost_modal.undo_reblog": "Taka færslu úr endurbirtingu?",
|
||||
"bundle_column_error.copy_stacktrace": "Afrita villuskýrslu",
|
||||
"bundle_column_error.error.body": "Umbeðna síðau var ekki hægt að myndgera. Það gæti verið vegna villu í kóðanum okkar eða vandamáls með samhæfni vafra.",
|
||||
"bundle_column_error.error.title": "Ó-nei!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "Þú munt ekki sjá færslur sem minnast á viðkomandi aðila.",
|
||||
"mute_modal.you_wont_see_posts": "Viðkomandi geta áfram séð færslurnar þínar en þú munt ekki sjá færslurnar þeirra.",
|
||||
"navigation_bar.about": "Um hugbúnaðinn",
|
||||
"navigation_bar.administration": "Stjórnun",
|
||||
"navigation_bar.advanced_interface": "Opna í ítarlegu vefviðmóti",
|
||||
"navigation_bar.blocks": "Útilokaðir notendur",
|
||||
"navigation_bar.bookmarks": "Bókamerki",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "Fylgist með og fylgjendur",
|
||||
"navigation_bar.lists": "Listar",
|
||||
"navigation_bar.logout": "Útskráning",
|
||||
"navigation_bar.moderation": "Umsjón",
|
||||
"navigation_bar.mutes": "Þaggaðir notendur",
|
||||
"navigation_bar.opened_in_classic_interface": "Færslur, notendaaðgangar og aðrar sérhæfðar síður eru sjálfgefið opnaðar í klassíska vefviðmótinu.",
|
||||
"navigation_bar.personal": "Einka",
|
||||
|
@ -502,7 +502,17 @@
|
||||
"notification.status": "{name}さんが投稿しました",
|
||||
"notification.update": "{name}さんが投稿を編集しました",
|
||||
"notification_requests.accept": "受け入れる",
|
||||
"notification_requests.accept_multiple": "{count, plural, other {選択中の#件を受け入れる}}",
|
||||
"notification_requests.confirm_accept_multiple.button": "{count, plural, other {#件のアカウントを受け入れる}}",
|
||||
"notification_requests.confirm_accept_multiple.message": "{count, plural, other {#件のアカウント}}に対して今後通知を受け入れるようにします。よろしいですか?",
|
||||
"notification_requests.confirm_accept_multiple.title": "保留中のアカウントの受け入れ",
|
||||
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, other {#件のアカウントを無視する}}",
|
||||
"notification_requests.confirm_dismiss_multiple.message": "{count, plural, other {#件のアカウント}}からの通知を今後無視するようにします。一度この操作を行った{count, plural, other {アカウント}}とふたたび出会うことは容易ではありません。よろしいですか?",
|
||||
"notification_requests.confirm_dismiss_multiple.title": "保留中のアカウントを無視しようとしています",
|
||||
"notification_requests.dismiss": "無視",
|
||||
"notification_requests.dismiss_multiple": "{count, plural, other {選択中の#件を無視する}}",
|
||||
"notification_requests.edit_selection": "選択",
|
||||
"notification_requests.exit_selection": "選択の終了",
|
||||
"notification_requests.explainer_for_limited_account": "このアカウントはモデレーターにより制限が課されているため、このアカウントによる通知は保留されています",
|
||||
"notification_requests.explainer_for_limited_remote_account": "このアカウントが所属するサーバーはモデレーターにより制限が課されているため、このアカウントによる通知は保留されています",
|
||||
"notification_requests.minimize_banner": "「保留中の通知」のバナーを最小化する",
|
||||
|
@ -498,9 +498,13 @@
|
||||
"notification.admin.report_statuses": "{name} 님이 {target}을 {category}로 신고했습니다",
|
||||
"notification.admin.report_statuses_other": "{name} 님이 {target}을 신고했습니다",
|
||||
"notification.admin.sign_up": "{name} 님이 가입했습니다",
|
||||
"notification.admin.sign_up.name_and_others": "{name} 외 {count, plural, other {# 명}}이 가입했습니다",
|
||||
"notification.favourite": "{name} 님이 내 게시물을 좋아합니다",
|
||||
"notification.favourite.name_and_others_with_link": "{name} 외 <a>{count, plural, other {# 명}}</a>이 내 게시물을 좋아합니다",
|
||||
"notification.follow": "{name} 님이 나를 팔로우했습니다",
|
||||
"notification.follow.name_and_others": "{name} 외 {count, plural, other {# 명}}이 날 팔로우 했습니다",
|
||||
"notification.follow_request": "{name} 님이 팔로우 요청을 보냈습니다",
|
||||
"notification.follow_request.name_and_others": "{name} 외 {count, plural, other {# 명}}이 나에게 팔로우 요청을 보냈습니다",
|
||||
"notification.label.mention": "멘션",
|
||||
"notification.label.private_mention": "개인 멘션",
|
||||
"notification.label.private_reply": "개인 답글",
|
||||
@ -518,6 +522,7 @@
|
||||
"notification.own_poll": "설문을 마침",
|
||||
"notification.poll": "참여한 투표가 끝났습니다",
|
||||
"notification.reblog": "{name} 님이 부스트했습니다",
|
||||
"notification.reblog.name_and_others_with_link": "{name} 외 <a>{count, plural, other {# 명}}</a>이 내 게시물을 부스트했습니다",
|
||||
"notification.relationships_severance_event": "{name} 님과의 연결이 끊어졌습니다",
|
||||
"notification.relationships_severance_event.account_suspension": "{from}의 관리자가 {target}를 정지시켰기 때문에 그들과 더이상 상호작용 할 수 없고 정보를 받아볼 수 없습니다.",
|
||||
"notification.relationships_severance_event.domain_block": "{from}의 관리자가 {target}를 차단하였고 여기에는 나의 {followersCount} 명의 팔로워와 {followingCount, plural, other {#}} 명의 팔로우가 포함되었습니다.",
|
||||
@ -851,7 +856,7 @@
|
||||
"upload_modal.description_placeholder": "다람쥐 헌 쳇바퀴 타고파",
|
||||
"upload_modal.detect_text": "사진에서 문자 탐색",
|
||||
"upload_modal.edit_media": "미디어 수정",
|
||||
"upload_modal.hint": "미리보기를 클릭하거나 드래그 해서 포컬 포인트를 맞추세요. 이 점은 썸네일에 항상 보여질 부분을 나타냅니다.",
|
||||
"upload_modal.hint": "미리보기를 클릭하거나 드래그 해서 초점을 맞추세요. 이 점은 썸네일에서 항상 보여질 부분을 나타냅니다.",
|
||||
"upload_modal.preparing_ocr": "OCR 준비 중…",
|
||||
"upload_modal.preview_label": "미리보기 ({ratio})",
|
||||
"upload_progress.label": "업로드 중...",
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "Blokuoti naudotoją?",
|
||||
"block_modal.you_wont_see_mentions": "Nematysi įrašus, kuriuose jie paminimi.",
|
||||
"boost_modal.combo": "Galima paspausti {combo}, kad praleisti tai kitą kartą",
|
||||
"boost_modal.reblog": "Pasidalinti įrašą?",
|
||||
"boost_modal.undo_reblog": "Panaikinti pasidalintą įrašą?",
|
||||
"bundle_column_error.copy_stacktrace": "Kopijuoti klaidos ataskaitą",
|
||||
"bundle_column_error.error.body": "Paprašytos puslapio nepavyko atvaizduoti. Tai gali būti dėl mūsų kodo klaidos arba naršyklės suderinamumo problemos.",
|
||||
"bundle_column_error.error.title": "O, ne!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "Nematysi įrašus, kuriuose jie paminimi.",
|
||||
"mute_modal.you_wont_see_posts": "Jie vis tiek gali matyti tavo įrašus, bet tu nematysi jų.",
|
||||
"navigation_bar.about": "Apie",
|
||||
"navigation_bar.administration": "Administravimas",
|
||||
"navigation_bar.advanced_interface": "Atidaryti išplėstinę žiniatinklio sąsają",
|
||||
"navigation_bar.blocks": "Užblokuoti naudotojai",
|
||||
"navigation_bar.bookmarks": "Žymės",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "Sekimai ir sekėjai",
|
||||
"navigation_bar.lists": "Sąrašai",
|
||||
"navigation_bar.logout": "Atsijungti",
|
||||
"navigation_bar.moderation": "Prižiūrėjimas",
|
||||
"navigation_bar.mutes": "Nutildyti naudotojai",
|
||||
"navigation_bar.opened_in_classic_interface": "Įrašai, paskyros ir kiti konkretūs puslapiai pagal numatytuosius nustatymus atidaromi klasikinėje žiniatinklio sąsajoje.",
|
||||
"navigation_bar.personal": "Asmeninis",
|
||||
|
@ -97,6 +97,7 @@
|
||||
"block_modal.title": "Gebruiker blokkeren?",
|
||||
"block_modal.you_wont_see_mentions": "Je ziet geen berichten meer die dit account vermelden.",
|
||||
"boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan",
|
||||
"boost_modal.reblog": "Bericht boosten?",
|
||||
"bundle_column_error.copy_stacktrace": "Foutrapportage kopiëren",
|
||||
"bundle_column_error.error.body": "De opgevraagde pagina kon niet worden weergegeven. Dit kan het gevolg zijn van een fout in onze broncode, of van een compatibiliteitsprobleem met je webbrowser.",
|
||||
"bundle_column_error.error.title": "O nee!",
|
||||
@ -467,6 +468,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "Je ziet geen berichten meer die dit account vermelden.",
|
||||
"mute_modal.you_wont_see_posts": "De persoon kan nog steeds jouw berichten zien, maar diens berichten zie je niet meer.",
|
||||
"navigation_bar.about": "Over",
|
||||
"navigation_bar.administration": "Beheer",
|
||||
"navigation_bar.advanced_interface": "In geavanceerde webinterface openen",
|
||||
"navigation_bar.blocks": "Geblokkeerde gebruikers",
|
||||
"navigation_bar.bookmarks": "Bladwijzers",
|
||||
@ -483,6 +485,7 @@
|
||||
"navigation_bar.follows_and_followers": "Volgers en gevolgde accounts",
|
||||
"navigation_bar.lists": "Lijsten",
|
||||
"navigation_bar.logout": "Uitloggen",
|
||||
"navigation_bar.moderation": "Moderatie",
|
||||
"navigation_bar.mutes": "Genegeerde gebruikers",
|
||||
"navigation_bar.opened_in_classic_interface": "Berichten, accounts en andere specifieke pagina’s, worden standaard geopend in de klassieke webinterface.",
|
||||
"navigation_bar.personal": "Persoonlijk",
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "Zablokować użytkownika?",
|
||||
"block_modal.you_wont_see_mentions": "Nie zobaczysz wpisów, które wspominają tego użytkownika.",
|
||||
"boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
|
||||
"boost_modal.reblog": "Podbić wpis?",
|
||||
"boost_modal.undo_reblog": "Cofnąć podbicie?",
|
||||
"bundle_column_error.copy_stacktrace": "Skopiuj raport o błędzie",
|
||||
"bundle_column_error.error.body": "Nie można zrenderować żądanej strony. Może to być spowodowane błędem w naszym kodzie lub problemami z kompatybilnością przeglądarki.",
|
||||
"bundle_column_error.error.title": "O nie!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "Nie zobaczysz wpisów, które wspominają tego użytkownika.",
|
||||
"mute_modal.you_wont_see_posts": "Użytkownik dalej będzie widzieć Twoje posty, ale Ty nie będziesz widzieć jego.",
|
||||
"navigation_bar.about": "O serwerze",
|
||||
"navigation_bar.administration": "Administracja",
|
||||
"navigation_bar.advanced_interface": "Otwórz w zaawansowanym interfejsie użytkownika",
|
||||
"navigation_bar.blocks": "Zablokowani użytkownicy",
|
||||
"navigation_bar.bookmarks": "Zakładki",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "Obserwowani i obserwujący",
|
||||
"navigation_bar.lists": "Listy",
|
||||
"navigation_bar.logout": "Wyloguj",
|
||||
"navigation_bar.moderation": "Moderacja",
|
||||
"navigation_bar.mutes": "Wyciszeni użytkownicy",
|
||||
"navigation_bar.opened_in_classic_interface": "Posty, konta i inne konkretne strony są otwierane domyślnie w klasycznym interfejsie sieciowym.",
|
||||
"navigation_bar.personal": "Osobiste",
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "Të bllokohet përdoruesi?",
|
||||
"block_modal.you_wont_see_mentions": "S’do të shihni postimet ku përmenden.",
|
||||
"boost_modal.combo": "Që kjo të anashkalohet herës tjetër, mund të shtypni {combo}",
|
||||
"boost_modal.reblog": "Përforcim postimi?",
|
||||
"boost_modal.undo_reblog": "Të hiqet përforcim për postimin?",
|
||||
"bundle_column_error.copy_stacktrace": "Kopjo raportim gabimi",
|
||||
"bundle_column_error.error.body": "Faqja e kërkuar s’u vizatua dot. Kjo mund të vijë nga një e metë në kodin tonë, ose nga një problem përputhshmërie i shfletuesit.",
|
||||
"bundle_column_error.error.title": "Oh, mos!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "S’do të shihni postime ku përmenden.",
|
||||
"mute_modal.you_wont_see_posts": "Ata munden ende të shohin postimet tuaja, por ju s’do të shihni të tyret.",
|
||||
"navigation_bar.about": "Mbi",
|
||||
"navigation_bar.administration": "Administrim",
|
||||
"navigation_bar.advanced_interface": "Hape në ndërfaqe web të thelluar",
|
||||
"navigation_bar.blocks": "Përdorues të bllokuar",
|
||||
"navigation_bar.bookmarks": "Faqerojtës",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "Ndjekje dhe ndjekës",
|
||||
"navigation_bar.lists": "Lista",
|
||||
"navigation_bar.logout": "Dalje",
|
||||
"navigation_bar.moderation": "Moderim",
|
||||
"navigation_bar.mutes": "Përdorues të heshtuar",
|
||||
"navigation_bar.opened_in_classic_interface": "Postime, llogari dhe të tjera faqe specifike, si parazgjedhje, hapen në ndërfaqe klasike web.",
|
||||
"navigation_bar.personal": "Personale",
|
||||
|
@ -97,6 +97,8 @@
|
||||
"block_modal.title": "是否封鎖該使用者?",
|
||||
"block_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。",
|
||||
"boost_modal.combo": "下次您可以按 {combo} 跳過",
|
||||
"boost_modal.reblog": "是否要轉嘟?",
|
||||
"boost_modal.undo_reblog": "是否要取消轉嘟?",
|
||||
"bundle_column_error.copy_stacktrace": "複製錯誤報告",
|
||||
"bundle_column_error.error.body": "無法繪製請求的頁面。這可能是因為我們程式碼中的臭蟲或是瀏覽器的相容問題。",
|
||||
"bundle_column_error.error.title": "糟糕!",
|
||||
@ -467,6 +469,7 @@
|
||||
"mute_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。",
|
||||
"mute_modal.you_wont_see_posts": "他們仍可讀取您的嘟文,但您不會見到他們的。",
|
||||
"navigation_bar.about": "關於",
|
||||
"navigation_bar.administration": "管理介面",
|
||||
"navigation_bar.advanced_interface": "以進階網頁介面開啟",
|
||||
"navigation_bar.blocks": "已封鎖的使用者",
|
||||
"navigation_bar.bookmarks": "書籤",
|
||||
@ -483,6 +486,7 @@
|
||||
"navigation_bar.follows_and_followers": "跟隨中與跟隨者",
|
||||
"navigation_bar.lists": "列表",
|
||||
"navigation_bar.logout": "登出",
|
||||
"navigation_bar.moderation": "站務",
|
||||
"navigation_bar.mutes": "已靜音的使用者",
|
||||
"navigation_bar.opened_in_classic_interface": "預設於經典網頁介面中開啟嘟文、帳號與其他特定頁面。",
|
||||
"navigation_bar.personal": "個人",
|
||||
|
@ -1,4 +1,23 @@
|
||||
export const PERMISSION_INVITE_USERS = 0x0000000000010000;
|
||||
export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
|
||||
export const PERMISSION_MANAGE_FEDERATION = 0x0000000000000020;
|
||||
|
||||
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
|
||||
export const PERMISSION_VIEW_DASHBOARD = 0x0000000000000008;
|
||||
|
||||
// These helpers don't quite align with the names/categories in UserRole,
|
||||
// but are likely "good enough" for the use cases at present.
|
||||
//
|
||||
// See: https://docs.joinmastodon.org/entities/Role/#permission-flags
|
||||
|
||||
export function canViewAdminDashboard(permissions: number) {
|
||||
return (
|
||||
(permissions & PERMISSION_VIEW_DASHBOARD) === PERMISSION_VIEW_DASHBOARD
|
||||
);
|
||||
}
|
||||
|
||||
export function canManageReports(permissions: number) {
|
||||
return (
|
||||
(permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS
|
||||
);
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { forceGroupedNotifications } from 'mastodon/initial_state';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
@ -25,6 +26,10 @@ export const selectSettingsNotificationsQuickFilterAdvanced = (
|
||||
) =>
|
||||
state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean;
|
||||
|
||||
export const selectUseGroupedNotifications = (state: RootState) =>
|
||||
forceGroupedNotifications ||
|
||||
(state.settings.getIn(['notifications', 'groupingBeta']) as boolean);
|
||||
|
||||
export const selectSettingsNotificationsShowUnread = (state: RootState) =>
|
||||
state.settings.getIn(['notifications', 'showUnread']) as boolean;
|
||||
|
||||
|
@ -6142,6 +6142,48 @@ a.status-card {
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
border: 1px solid var(--modal-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&__account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
color: $dark-text-color;
|
||||
|
||||
bdi {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: -webkit-box;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
color: $dark-text-color;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
max-height: 4 * 22px;
|
||||
overflow: hidden;
|
||||
|
||||
p,
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-indicator__attachments {
|
||||
margin-top: 0;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
color: $dark-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__bullet-points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -6219,6 +6261,12 @@ a.status-card {
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
|
||||
&__hint {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: $dark-text-color;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
@ -6226,6 +6274,18 @@ a.status-card {
|
||||
}
|
||||
}
|
||||
|
||||
.hotkey-combination {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
kbd {
|
||||
padding: 3px 5px;
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.boost-modal,
|
||||
.report-modal,
|
||||
.actions-modal,
|
||||
@ -10579,6 +10639,7 @@ noscript {
|
||||
}
|
||||
|
||||
.reply-indicator__attachments {
|
||||
margin-top: 0;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
color: $dark-text-color;
|
||||
|
@ -126,6 +126,7 @@ class InitialStateSerializer < ActiveModel::Serializer
|
||||
trends_as_landing_page: Setting.trends_as_landing_page,
|
||||
trends_enabled: Setting.trends,
|
||||
version: instance_presenter.version,
|
||||
force_grouped_notifications: ENV['FORCE_GROUPED_NOTIFICATIONS'] == 'true',
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -886,6 +886,7 @@ ko:
|
||||
name: 이름
|
||||
newest: 최신
|
||||
oldest: 오래된 순
|
||||
open: 공개시점으로 보기
|
||||
reset: 초기화
|
||||
review: 심사 상태
|
||||
search: 검색
|
||||
|
@ -211,6 +211,7 @@ et:
|
||||
setting_default_privacy: Postituse nähtavus
|
||||
setting_default_sensitive: Alati märgista meedia tundlikuks
|
||||
setting_delete_modal: Näita kinnitusdialoogi enne postituse kustutamist
|
||||
setting_disable_hover_cards: Keela profiili eelvaade kui hõljutada
|
||||
setting_disable_swiping: Keela pühkimisliigutused
|
||||
setting_display_media: Meedia kuvarežiim
|
||||
setting_display_media_default: Vaikimisi
|
||||
@ -242,11 +243,13 @@ et:
|
||||
warn: Peida hoiatusega
|
||||
form_admin_settings:
|
||||
activity_api_enabled: Avalda agregeeritud statistika kasutajaaktiivsuse kohta API-s
|
||||
app_icon: Äpi ikoon
|
||||
backups_retention_period: Kasutajate arhiivi talletusperiood
|
||||
bootstrap_timeline_accounts: Alati soovita neid kontosid uutele kasutajatele
|
||||
closed_registrations_message: Kohandatud teade, kui liitumine pole võimalik
|
||||
content_cache_retention_period: Kaugsisu säilitamise aeg
|
||||
custom_css: Kohandatud CSS
|
||||
favicon: Favicon
|
||||
mascot: Kohandatud maskott (kunagine)
|
||||
media_cache_retention_period: Meediapuhvri talletusperiood
|
||||
peers_api_enabled: Avalda avastatud serverite loetelu API kaudu
|
||||
|
@ -57,7 +57,8 @@ services:
|
||||
# - '127.0.0.1:9200:9200'
|
||||
|
||||
web:
|
||||
build: .
|
||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||
# build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.3.0-beta.1
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
@ -78,7 +79,10 @@ services:
|
||||
- ./public/system:/mastodon/public/system
|
||||
|
||||
streaming:
|
||||
build: .
|
||||
# You can uncomment the following lines if you want to not use the prebuilt image, for example if you have local code changes
|
||||
# build:
|
||||
# dockerfile: ./streaming/Dockerfile
|
||||
# context: .
|
||||
image: ghcr.io/mastodon/mastodon-streaming:v4.3.0-beta.1
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
@ -88,7 +92,7 @@ services:
|
||||
- internal_network
|
||||
healthcheck:
|
||||
# prettier-ignore
|
||||
test: ['CMD-SHELL', "curl -s --noproxy localhost localhost:4000/api/v1/streaming/health | grep -q 'OK' || exit 1'"]
|
||||
test: ['CMD-SHELL', "curl -s --noproxy localhost localhost:4000/api/v1/streaming/health | grep -q 'OK' || exit 1"]
|
||||
ports:
|
||||
- '127.0.0.1:4000:4000'
|
||||
depends_on:
|
||||
|
@ -6,19 +6,30 @@ describe Admin::DashboardController do
|
||||
render_views
|
||||
|
||||
describe 'GET #index' do
|
||||
let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Owner')) }
|
||||
|
||||
before do
|
||||
allow(Admin::SystemCheck).to receive(:perform).and_return([
|
||||
Admin::SystemCheck::Message.new(:database_schema_check),
|
||||
Admin::SystemCheck::Message.new(:rules_check, nil, admin_rules_path),
|
||||
Admin::SystemCheck::Message.new(:sidekiq_process_check, 'foo, bar'),
|
||||
])
|
||||
sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin'))
|
||||
stub_system_checks
|
||||
Fabricate :software_update
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'returns 200' do
|
||||
it 'returns http success and body with system check messages' do
|
||||
get :index
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
.and have_attributes(
|
||||
body: include(I18n.t('admin.system_checks.software_version_patch_check.message_html'))
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stub_system_checks
|
||||
stub_const 'Admin::SystemCheck::ACTIVE_CHECKS', [
|
||||
Admin::SystemCheck::SoftwareVersionCheck,
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -1,22 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::OEmbedController do
|
||||
render_views
|
||||
|
||||
let(:alice) { Fabricate(:account, username: 'alice') }
|
||||
let(:status) { Fabricate(:status, text: 'Hello world', account: alice) }
|
||||
|
||||
describe 'GET #show' do
|
||||
before do
|
||||
request.host = Rails.configuration.x.local_domain
|
||||
get :show, params: { url: short_account_status_url(alice, status) }, format: :json
|
||||
end
|
||||
|
||||
it 'returns private cache control headers', :aggregate_failures do
|
||||
expect(response).to have_http_status(200)
|
||||
expect(response.headers['Cache-Control']).to include('private, no-store')
|
||||
end
|
||||
end
|
||||
end
|
@ -1,24 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Api::Web::SettingsController do
|
||||
render_views
|
||||
|
||||
let!(:user) { Fabricate(:user) }
|
||||
|
||||
describe 'PATCH #update' do
|
||||
it 'redirects to about page' do
|
||||
sign_in(user)
|
||||
patch :update, format: :json, params: { data: { 'onboarded' => true } }
|
||||
|
||||
user.reload
|
||||
expect(response).to have_http_status(200)
|
||||
expect(user_web_setting.data['onboarded']).to eq('true')
|
||||
end
|
||||
|
||||
def user_web_setting
|
||||
Web::Setting.where(user: user).first
|
||||
end
|
||||
end
|
||||
end
|
@ -1,42 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe MediaProxyController do
|
||||
render_views
|
||||
|
||||
before do
|
||||
stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
|
||||
end
|
||||
|
||||
describe '#show' do
|
||||
it 'redirects when attached to a status' do
|
||||
status = Fabricate(:status)
|
||||
media_attachment = Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png')
|
||||
get :show, params: { id: media_attachment.id }
|
||||
|
||||
expect(response).to have_http_status(302)
|
||||
end
|
||||
|
||||
it 'responds with missing when there is not an attached status' do
|
||||
media_attachment = Fabricate(:media_attachment, status: nil, remote_url: 'http://example.com/attachment.png')
|
||||
get :show, params: { id: media_attachment.id }
|
||||
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
|
||||
it 'raises when id cant be found' do
|
||||
get :show, params: { id: 'missing' }
|
||||
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
|
||||
it 'raises when not permitted to view' do
|
||||
status = Fabricate(:status, visibility: :direct)
|
||||
media_attachment = Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png')
|
||||
get :show, params: { id: media_attachment.id }
|
||||
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
@ -1,19 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Settings::Exports::BlockedAccountsController do
|
||||
render_views
|
||||
|
||||
describe 'GET #index' do
|
||||
it 'returns a csv of the blocking accounts' do
|
||||
user = Fabricate(:user)
|
||||
user.account.block!(Fabricate(:account, username: 'username', domain: 'domain'))
|
||||
|
||||
sign_in user, scope: :user
|
||||
get :index, format: :csv
|
||||
|
||||
expect(response.body).to eq "username@domain\n"
|
||||
end
|
||||
end
|
||||
end
|
@ -1,20 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Settings::Exports::BlockedDomainsController do
|
||||
render_views
|
||||
|
||||
describe 'GET #index' do
|
||||
it 'returns a csv of the domains' do
|
||||
account = Fabricate(:account, domain: 'example.com')
|
||||
user = Fabricate(:user, account: account)
|
||||
Fabricate(:account_domain_block, domain: 'example.com', account: account)
|
||||
|
||||
sign_in user, scope: :user
|
||||
get :index, format: :csv
|
||||
|
||||
expect(response.body).to eq "example.com\n"
|
||||
end
|
||||
end
|
||||
end
|
@ -1,24 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Settings::Exports::BookmarksController do
|
||||
render_views
|
||||
|
||||
let(:user) { Fabricate(:user) }
|
||||
let(:account) { Fabricate(:account, domain: 'foo.bar') }
|
||||
let(:status) { Fabricate(:status, account: account, uri: 'https://foo.bar/statuses/1312') }
|
||||
|
||||
describe 'GET #index' do
|
||||
before do
|
||||
user.account.bookmarks.create!(status: status)
|
||||
end
|
||||
|
||||
it 'returns a csv of the bookmarked toots' do
|
||||
sign_in user, scope: :user
|
||||
get :index, format: :csv
|
||||
|
||||
expect(response.body).to eq "https://foo.bar/statuses/1312\n"
|
||||
end
|
||||
end
|
||||
end
|
@ -1,19 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Settings::Exports::FollowingAccountsController do
|
||||
render_views
|
||||
|
||||
describe 'GET #index' do
|
||||
it 'returns a csv of the following accounts' do
|
||||
user = Fabricate(:user)
|
||||
user.account.follow!(Fabricate(:account, username: 'username', domain: 'domain'))
|
||||
|
||||
sign_in user, scope: :user
|
||||
get :index, format: :csv
|
||||
|
||||
expect(response.body).to eq "Account address,Show boosts,Notify on new posts,Languages\nusername@domain,true,false,\n"
|
||||
end
|
||||
end
|
||||
end
|
@ -1,21 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Settings::Exports::ListsController do
|
||||
render_views
|
||||
|
||||
describe 'GET #index' do
|
||||
it 'returns a csv of the domains' do
|
||||
account = Fabricate(:account)
|
||||
user = Fabricate(:user, account: account)
|
||||
list = Fabricate(:list, account: account, title: 'The List')
|
||||
Fabricate(:list_account, list: list, account: account)
|
||||
|
||||
sign_in user, scope: :user
|
||||
get :index, format: :csv
|
||||
|
||||
expect(response.body).to match 'The List'
|
||||
end
|
||||
end
|
||||
end
|
@ -1,19 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe Settings::Exports::MutedAccountsController do
|
||||
render_views
|
||||
|
||||
describe 'GET #index' do
|
||||
it 'returns a csv of the muting accounts' do
|
||||
user = Fabricate(:user)
|
||||
user.account.mute!(Fabricate(:account, username: 'username', domain: 'domain'))
|
||||
|
||||
sign_in user, scope: :user
|
||||
get :index, format: :csv
|
||||
|
||||
expect(response.body).to eq "Account address,Hide notifications\nusername@domain,true\n"
|
||||
end
|
||||
end
|
||||
end
|
@ -2,7 +2,7 @@
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
context 'when visited anonymously' do
|
||||
describe 'Anonymous visits' do
|
||||
around do |example|
|
||||
old = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = true
|
||||
|
33
spec/requests/api/oembed_spec.rb
Normal file
33
spec/requests/api/oembed_spec.rb
Normal file
@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'API OEmbed' do
|
||||
describe 'GET /api/oembed' do
|
||||
before { host! Rails.configuration.x.local_domain }
|
||||
|
||||
context 'when status is public' do
|
||||
let(:status) { Fabricate(:status, visibility: :public) }
|
||||
|
||||
it 'returns success with private cache control headers' do
|
||||
get '/api/oembed', params: { url: short_account_status_url(status.account, status) }
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
expect(response.headers['Cache-Control'])
|
||||
.to include('private, no-store')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status is not public' do
|
||||
let(:status) { Fabricate(:status, visibility: :direct) }
|
||||
|
||||
it 'returns not found' do
|
||||
get '/api/oembed', params: { url: short_account_status_url(status.account, status) }
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
41
spec/requests/api/web/settings_spec.rb
Normal file
41
spec/requests/api/web/settings_spec.rb
Normal file
@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe '/api/web/settings' do
|
||||
describe 'PATCH /api/web/settings' do
|
||||
let(:user) { Fabricate :user }
|
||||
|
||||
context 'when signed in' do
|
||||
before { sign_in(user) }
|
||||
|
||||
it 'updates setting and responds with success' do
|
||||
patch '/api/web/settings', params: { data: { 'onboarded' => true } }
|
||||
|
||||
expect(user_web_setting.data)
|
||||
.to include('onboarded' => 'true')
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when not signed in' do
|
||||
it 'responds with unprocessable and does not modify setting' do
|
||||
patch '/api/web/settings', params: { data: { 'onboarded' => true } }
|
||||
|
||||
expect(user_web_setting)
|
||||
.to be_nil
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(422)
|
||||
end
|
||||
end
|
||||
|
||||
def user_web_setting
|
||||
Web::Setting
|
||||
.where(user: user)
|
||||
.first
|
||||
end
|
||||
end
|
||||
end
|
67
spec/requests/media_proxy_spec.rb
Normal file
67
spec/requests/media_proxy_spec.rb
Normal file
@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Media Proxy' do
|
||||
describe 'GET /media_proxy/:id' do
|
||||
before do
|
||||
integration_session.https! # TODO: Move to global rails_helper for all request specs?
|
||||
host! Rails.configuration.x.local_domain # TODO: Move to global rails_helper for all request specs?
|
||||
|
||||
stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
|
||||
end
|
||||
|
||||
context 'when attached to a status' do
|
||||
let(:status) { Fabricate(:status) }
|
||||
let(:media_attachment) { Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') }
|
||||
|
||||
it 'redirects to correct original url' do
|
||||
get "/media_proxy/#{media_attachment.id}"
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(302)
|
||||
.and redirect_to media_attachment.file.url(:original)
|
||||
end
|
||||
|
||||
it 'redirects to small style url' do
|
||||
get "/media_proxy/#{media_attachment.id}/small"
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(302)
|
||||
.and redirect_to media_attachment.file.url(:small)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there is not an attached status' do
|
||||
let(:media_attachment) { Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') }
|
||||
|
||||
it 'responds with missing' do
|
||||
get "/media_proxy/#{media_attachment.id}"
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(404)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when id cannot be found' do
|
||||
it 'responds with missing' do
|
||||
get '/media_proxy/missing'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(404)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when not permitted to view' do
|
||||
let(:status) { Fabricate(:status, visibility: :direct) }
|
||||
let(:media_attachment) { Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') }
|
||||
|
||||
it 'responds with missing' do
|
||||
get "/media_proxy/#{media_attachment.id}"
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
42
spec/requests/settings/exports/blocked_accounts_spec.rb
Normal file
42
spec/requests/settings/exports/blocked_accounts_spec.rb
Normal file
@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Settings / Exports / Blocked Accounts' do
|
||||
describe 'GET /settings/exports/blocks' do
|
||||
context 'with a signed in user who has blocked accounts' do
|
||||
let(:user) { Fabricate :user }
|
||||
|
||||
before do
|
||||
Fabricate(
|
||||
:block,
|
||||
account: user.account,
|
||||
target_account: Fabricate(:account, username: 'username', domain: 'domain')
|
||||
)
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it 'returns a CSV with the blocking accounts' do
|
||||
get '/settings/exports/blocks.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
expect(response.content_type)
|
||||
.to eq('text/csv')
|
||||
expect(response.body)
|
||||
.to eq(<<~CSV)
|
||||
username@domain
|
||||
CSV
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when signed out' do
|
||||
it 'returns unauthorized' do
|
||||
get '/settings/exports/blocks.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(401)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
39
spec/requests/settings/exports/blocked_domains_spec.rb
Normal file
39
spec/requests/settings/exports/blocked_domains_spec.rb
Normal file
@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Settings / Exports / Blocked Domains' do
|
||||
describe 'GET /settings/exports/domain_blocks' do
|
||||
context 'with a signed in user who has blocked domains' do
|
||||
let(:account) { Fabricate :account, domain: 'example.com' }
|
||||
let(:user) { Fabricate :user, account: account }
|
||||
|
||||
before do
|
||||
Fabricate(:account_domain_block, domain: 'example.com', account: account)
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it 'returns a CSV with the domains' do
|
||||
get '/settings/exports/domain_blocks.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
expect(response.content_type)
|
||||
.to eq('text/csv')
|
||||
expect(response.body)
|
||||
.to eq(<<~CSV)
|
||||
example.com
|
||||
CSV
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when signed out' do
|
||||
it 'returns unauthorized' do
|
||||
get '/settings/exports/domain_blocks.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(401)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
44
spec/requests/settings/exports/bookmarks_spec.rb
Normal file
44
spec/requests/settings/exports/bookmarks_spec.rb
Normal file
@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Settings / Exports / Bookmarks' do
|
||||
describe 'GET /settings/exports/bookmarks' do
|
||||
context 'with a signed in user who has bookmarks' do
|
||||
let(:account) { Fabricate(:account, domain: 'foo.bar') }
|
||||
let(:status) { Fabricate(:status, account: account, uri: 'https://foo.bar/statuses/1312') }
|
||||
let(:user) { Fabricate(:user) }
|
||||
|
||||
before do
|
||||
Fabricate(
|
||||
:bookmark,
|
||||
account: user.account,
|
||||
status: status
|
||||
)
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it 'returns a CSV with the bookmarked statuses' do
|
||||
get '/settings/exports/bookmarks.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
expect(response.content_type)
|
||||
.to eq('text/csv')
|
||||
expect(response.body)
|
||||
.to eq(<<~CSV)
|
||||
https://foo.bar/statuses/1312
|
||||
CSV
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when signed out' do
|
||||
it 'returns unauthorized' do
|
||||
get '/settings/exports/bookmarks.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(401)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
44
spec/requests/settings/exports/following_accounts_spec.rb
Normal file
44
spec/requests/settings/exports/following_accounts_spec.rb
Normal file
@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Settings / Exports / Following Accounts' do
|
||||
describe 'GET /settings/exports/follows' do
|
||||
context 'with a signed in user who is following accounts' do
|
||||
let(:user) { Fabricate :user }
|
||||
|
||||
before do
|
||||
Fabricate(
|
||||
:follow,
|
||||
account: user.account,
|
||||
target_account: Fabricate(:account, username: 'username', domain: 'domain'),
|
||||
languages: ['en']
|
||||
)
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it 'returns a CSV with the accounts' do
|
||||
get '/settings/exports/follows.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
expect(response.content_type)
|
||||
.to eq('text/csv')
|
||||
expect(response.body)
|
||||
.to eq(<<~CSV)
|
||||
Account address,Show boosts,Notify on new posts,Languages
|
||||
username@domain,true,false,en
|
||||
CSV
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when signed out' do
|
||||
it 'returns unauthorized' do
|
||||
get '/settings/exports/follows.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(401)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
40
spec/requests/settings/exports/lists_spec.rb
Normal file
40
spec/requests/settings/exports/lists_spec.rb
Normal file
@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Settings / Exports / Lists' do
|
||||
describe 'GET /settings/exports/lists' do
|
||||
context 'with a signed in user who has lists' do
|
||||
let(:account) { Fabricate(:account, username: 'test', domain: 'example.com') }
|
||||
let(:list) { Fabricate :list, account: account, title: 'The List' }
|
||||
let(:user) { Fabricate(:user, account: account) }
|
||||
|
||||
before do
|
||||
Fabricate(:list_account, list: list, account: account)
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it 'returns a CSV with the list' do
|
||||
get '/settings/exports/lists.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
expect(response.content_type)
|
||||
.to eq('text/csv')
|
||||
expect(response.body)
|
||||
.to eq(<<~CSV)
|
||||
The List,test@example.com
|
||||
CSV
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when signed out' do
|
||||
it 'returns unauthorized' do
|
||||
get '/settings/exports/lists.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(401)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
43
spec/requests/settings/exports/muted_accounts_spec.rb
Normal file
43
spec/requests/settings/exports/muted_accounts_spec.rb
Normal file
@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'Settings / Exports / Muted Accounts' do
|
||||
describe 'GET /settings/exports/mutes' do
|
||||
context 'with a signed in user who has muted accounts' do
|
||||
let(:user) { Fabricate :user }
|
||||
|
||||
before do
|
||||
Fabricate(
|
||||
:mute,
|
||||
account: user.account,
|
||||
target_account: Fabricate(:account, username: 'username', domain: 'domain')
|
||||
)
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it 'returns a CSV with the muted accounts' do
|
||||
get '/settings/exports/mutes.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(200)
|
||||
expect(response.content_type)
|
||||
.to eq('text/csv')
|
||||
expect(response.body)
|
||||
.to eq(<<~CSV)
|
||||
Account address,Hide notifications
|
||||
username@domain,true
|
||||
CSV
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when signed out' do
|
||||
it 'returns unauthorized' do
|
||||
get '/settings/exports/mutes.csv'
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(401)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
128
streaming/database.js
Normal file
128
streaming/database.js
Normal file
@ -0,0 +1,128 @@
|
||||
import pg from 'pg';
|
||||
import pgConnectionString from 'pg-connection-string';
|
||||
|
||||
import { parseIntFromEnvValue } from './utils.js';
|
||||
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from
|
||||
* @param {string} environment
|
||||
* @returns {pg.PoolConfig} the configuration for the PostgreSQL connection
|
||||
*/
|
||||
export function configFromEnv(env, environment) {
|
||||
/** @type {Record<string, pg.PoolConfig>} */
|
||||
const pgConfigs = {
|
||||
development: {
|
||||
user: env.DB_USER || pg.defaults.user,
|
||||
password: env.DB_PASS || pg.defaults.password,
|
||||
database: env.DB_NAME || 'mastodon_development',
|
||||
host: env.DB_HOST || pg.defaults.host,
|
||||
port: parseIntFromEnvValue(env.DB_PORT, pg.defaults.port ?? 5432, 'DB_PORT')
|
||||
},
|
||||
|
||||
production: {
|
||||
user: env.DB_USER || 'mastodon',
|
||||
password: env.DB_PASS || '',
|
||||
database: env.DB_NAME || 'mastodon_production',
|
||||
host: env.DB_HOST || 'localhost',
|
||||
port: parseIntFromEnvValue(env.DB_PORT, 5432, 'DB_PORT')
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {pg.PoolConfig}
|
||||
*/
|
||||
let baseConfig = {};
|
||||
|
||||
if (env.DATABASE_URL) {
|
||||
const parsedUrl = pgConnectionString.parse(env.DATABASE_URL);
|
||||
|
||||
// The result of dbUrlToConfig from pg-connection-string is not type
|
||||
// compatible with pg.PoolConfig, since parts of the connection URL may be
|
||||
// `null` when pg.PoolConfig expects `undefined`, as such we have to
|
||||
// manually create the baseConfig object from the properties of the
|
||||
// parsedUrl.
|
||||
//
|
||||
// For more information see:
|
||||
// https://github.com/brianc/node-postgres/issues/2280
|
||||
//
|
||||
// FIXME: clean up once brianc/node-postgres#3128 lands
|
||||
if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password;
|
||||
if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host;
|
||||
if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user;
|
||||
if (typeof parsedUrl.port === 'string') {
|
||||
const parsedPort = parseInt(parsedUrl.port, 10);
|
||||
if (isNaN(parsedPort)) {
|
||||
throw new Error('Invalid port specified in DATABASE_URL environment variable');
|
||||
}
|
||||
baseConfig.port = parsedPort;
|
||||
}
|
||||
if (typeof parsedUrl.database === 'string') baseConfig.database = parsedUrl.database;
|
||||
if (typeof parsedUrl.options === 'string') baseConfig.options = parsedUrl.options;
|
||||
|
||||
// The pg-connection-string type definition isn't correct, as parsedUrl.ssl
|
||||
// can absolutely be an Object, this is to work around these incorrect
|
||||
// types, including the casting of parsedUrl.ssl to Record<string, any>
|
||||
if (typeof parsedUrl.ssl === 'boolean') {
|
||||
baseConfig.ssl = parsedUrl.ssl;
|
||||
} else if (typeof parsedUrl.ssl === 'object' && !Array.isArray(parsedUrl.ssl) && parsedUrl.ssl !== null) {
|
||||
/** @type {Record<string, any>} */
|
||||
const sslOptions = parsedUrl.ssl;
|
||||
baseConfig.ssl = {};
|
||||
|
||||
baseConfig.ssl.cert = sslOptions.cert;
|
||||
baseConfig.ssl.key = sslOptions.key;
|
||||
baseConfig.ssl.ca = sslOptions.ca;
|
||||
baseConfig.ssl.rejectUnauthorized = sslOptions.rejectUnauthorized;
|
||||
}
|
||||
|
||||
// Support overriding the database password in the connection URL
|
||||
if (!baseConfig.password && env.DB_PASS) {
|
||||
baseConfig.password = env.DB_PASS;
|
||||
}
|
||||
} else if (Object.hasOwn(pgConfigs, environment)) {
|
||||
baseConfig = pgConfigs[environment];
|
||||
|
||||
if (env.DB_SSLMODE) {
|
||||
switch(env.DB_SSLMODE) {
|
||||
case 'disable':
|
||||
case '':
|
||||
baseConfig.ssl = false;
|
||||
break;
|
||||
case 'no-verify':
|
||||
baseConfig.ssl = { rejectUnauthorized: false };
|
||||
break;
|
||||
default:
|
||||
baseConfig.ssl = {};
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unable to resolve postgresql database configuration.');
|
||||
}
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
max: parseIntFromEnvValue(env.DB_POOL, 10, 'DB_POOL'),
|
||||
connectionTimeoutMillis: 15000,
|
||||
// Deliberately set application_name to an empty string to prevent excessive
|
||||
// CPU usage with PG Bouncer. See:
|
||||
// - https://github.com/mastodon/mastodon/pull/23958
|
||||
// - https://github.com/pgbouncer/pgbouncer/issues/349
|
||||
application_name: '',
|
||||
};
|
||||
}
|
||||
|
||||
let pool;
|
||||
/**
|
||||
*
|
||||
* @param {pg.PoolConfig} config
|
||||
* @returns {pg.Pool}
|
||||
*/
|
||||
export function getPool(config) {
|
||||
if (pool) {
|
||||
return pool;
|
||||
}
|
||||
|
||||
pool = new pg.Pool(config);
|
||||
return pool;
|
||||
}
|
@ -8,15 +8,14 @@ import url from 'node:url';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import express from 'express';
|
||||
import { Redis } from 'ioredis';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import pg from 'pg';
|
||||
import pgConnectionString from 'pg-connection-string';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import * as Database from './database.js';
|
||||
import { AuthenticationError, RequestError, extractStatusAndMessage as extractErrorStatusAndMessage } from './errors.js';
|
||||
import { logger, httpLogger, initializeLogLevel, attachWebsocketHttpLogger, createWebsocketLogger } from './logging.js';
|
||||
import { setupMetrics } from './metrics.js';
|
||||
import * as Redis from './redis.js';
|
||||
import { isTruthy, normalizeHashtag, firstParam } from './utils.js';
|
||||
|
||||
const environment = process.env.NODE_ENV || 'development';
|
||||
@ -48,23 +47,6 @@ initializeLogLevel(process.env, environment);
|
||||
* @property {string} deviceId
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {RedisConfiguration} config
|
||||
* @returns {Promise<Redis>}
|
||||
*/
|
||||
const createRedisClient = async ({ redisParams, redisUrl }) => {
|
||||
let client;
|
||||
|
||||
if (typeof redisUrl === 'string') {
|
||||
client = new Redis(redisUrl, redisParams);
|
||||
} else {
|
||||
client = new Redis(redisParams);
|
||||
}
|
||||
|
||||
client.on('error', (err) => logger.error({ err }, 'Redis Client Error!'));
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to safely parse a string as JSON, used when both receiving a message
|
||||
@ -97,177 +79,6 @@ const parseJSON = (json, req) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes an environment variable that should be an integer, attempts to parse
|
||||
* it falling back to a default if not set, and handles errors parsing.
|
||||
* @param {string|undefined} value
|
||||
* @param {number} defaultValue
|
||||
* @param {string} variableName
|
||||
* @returns {number}
|
||||
*/
|
||||
const parseIntFromEnv = (value, defaultValue, variableName) => {
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
const parsedValue = parseInt(value, 10);
|
||||
if (isNaN(parsedValue)) {
|
||||
throw new Error(`Invalid ${variableName} environment variable: ${value}`);
|
||||
}
|
||||
return parsedValue;
|
||||
} else {
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from
|
||||
* @returns {pg.PoolConfig} the configuration for the PostgreSQL connection
|
||||
*/
|
||||
const pgConfigFromEnv = (env) => {
|
||||
/** @type {Record<string, pg.PoolConfig>} */
|
||||
const pgConfigs = {
|
||||
development: {
|
||||
user: env.DB_USER || pg.defaults.user,
|
||||
password: env.DB_PASS || pg.defaults.password,
|
||||
database: env.DB_NAME || 'mastodon_development',
|
||||
host: env.DB_HOST || pg.defaults.host,
|
||||
port: parseIntFromEnv(env.DB_PORT, pg.defaults.port ?? 5432, 'DB_PORT')
|
||||
},
|
||||
|
||||
production: {
|
||||
user: env.DB_USER || 'mastodon',
|
||||
password: env.DB_PASS || '',
|
||||
database: env.DB_NAME || 'mastodon_production',
|
||||
host: env.DB_HOST || 'localhost',
|
||||
port: parseIntFromEnv(env.DB_PORT, 5432, 'DB_PORT')
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {pg.PoolConfig}
|
||||
*/
|
||||
let baseConfig = {};
|
||||
|
||||
if (env.DATABASE_URL) {
|
||||
const parsedUrl = pgConnectionString.parse(env.DATABASE_URL);
|
||||
|
||||
// The result of dbUrlToConfig from pg-connection-string is not type
|
||||
// compatible with pg.PoolConfig, since parts of the connection URL may be
|
||||
// `null` when pg.PoolConfig expects `undefined`, as such we have to
|
||||
// manually create the baseConfig object from the properties of the
|
||||
// parsedUrl.
|
||||
//
|
||||
// For more information see:
|
||||
// https://github.com/brianc/node-postgres/issues/2280
|
||||
//
|
||||
// FIXME: clean up once brianc/node-postgres#3128 lands
|
||||
if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password;
|
||||
if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host;
|
||||
if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user;
|
||||
if (typeof parsedUrl.port === 'string') {
|
||||
const parsedPort = parseInt(parsedUrl.port, 10);
|
||||
if (isNaN(parsedPort)) {
|
||||
throw new Error('Invalid port specified in DATABASE_URL environment variable');
|
||||
}
|
||||
baseConfig.port = parsedPort;
|
||||
}
|
||||
if (typeof parsedUrl.database === 'string') baseConfig.database = parsedUrl.database;
|
||||
if (typeof parsedUrl.options === 'string') baseConfig.options = parsedUrl.options;
|
||||
|
||||
// The pg-connection-string type definition isn't correct, as parsedUrl.ssl
|
||||
// can absolutely be an Object, this is to work around these incorrect
|
||||
// types, including the casting of parsedUrl.ssl to Record<string, any>
|
||||
if (typeof parsedUrl.ssl === 'boolean') {
|
||||
baseConfig.ssl = parsedUrl.ssl;
|
||||
} else if (typeof parsedUrl.ssl === 'object' && !Array.isArray(parsedUrl.ssl) && parsedUrl.ssl !== null) {
|
||||
/** @type {Record<string, any>} */
|
||||
const sslOptions = parsedUrl.ssl;
|
||||
baseConfig.ssl = {};
|
||||
|
||||
baseConfig.ssl.cert = sslOptions.cert;
|
||||
baseConfig.ssl.key = sslOptions.key;
|
||||
baseConfig.ssl.ca = sslOptions.ca;
|
||||
baseConfig.ssl.rejectUnauthorized = sslOptions.rejectUnauthorized;
|
||||
}
|
||||
|
||||
// Support overriding the database password in the connection URL
|
||||
if (!baseConfig.password && env.DB_PASS) {
|
||||
baseConfig.password = env.DB_PASS;
|
||||
}
|
||||
} else if (Object.hasOwn(pgConfigs, environment)) {
|
||||
baseConfig = pgConfigs[environment];
|
||||
|
||||
if (env.DB_SSLMODE) {
|
||||
switch(env.DB_SSLMODE) {
|
||||
case 'disable':
|
||||
case '':
|
||||
baseConfig.ssl = false;
|
||||
break;
|
||||
case 'no-verify':
|
||||
baseConfig.ssl = { rejectUnauthorized: false };
|
||||
break;
|
||||
default:
|
||||
baseConfig.ssl = {};
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unable to resolve postgresql database configuration.');
|
||||
}
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
max: parseIntFromEnv(env.DB_POOL, 10, 'DB_POOL'),
|
||||
connectionTimeoutMillis: 15000,
|
||||
// Deliberately set application_name to an empty string to prevent excessive
|
||||
// CPU usage with PG Bouncer. See:
|
||||
// - https://github.com/mastodon/mastodon/pull/23958
|
||||
// - https://github.com/pgbouncer/pgbouncer/issues/349
|
||||
application_name: '',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef RedisConfiguration
|
||||
* @property {import('ioredis').RedisOptions} redisParams
|
||||
* @property {string} redisPrefix
|
||||
* @property {string|undefined} redisUrl
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from
|
||||
* @returns {RedisConfiguration} configuration for the Redis connection
|
||||
*/
|
||||
const redisConfigFromEnv = (env) => {
|
||||
// ioredis *can* transparently add prefixes for us, but it doesn't *in some cases*,
|
||||
// which means we can't use it. But this is something that should be looked into.
|
||||
const redisPrefix = env.REDIS_NAMESPACE ? `${env.REDIS_NAMESPACE}:` : '';
|
||||
|
||||
let redisPort = parseIntFromEnv(env.REDIS_PORT, 6379, 'REDIS_PORT');
|
||||
let redisDatabase = parseIntFromEnv(env.REDIS_DB, 0, 'REDIS_DB');
|
||||
|
||||
/** @type {import('ioredis').RedisOptions} */
|
||||
const redisParams = {
|
||||
host: env.REDIS_HOST || '127.0.0.1',
|
||||
port: redisPort,
|
||||
// Force support for both IPv6 and IPv4, by default ioredis sets this to 4,
|
||||
// only allowing IPv4 connections:
|
||||
// https://github.com/redis/ioredis/issues/1576
|
||||
family: 0,
|
||||
db: redisDatabase,
|
||||
password: env.REDIS_PASSWORD || undefined,
|
||||
};
|
||||
|
||||
// redisParams.path takes precedence over host and port.
|
||||
if (env.REDIS_URL && env.REDIS_URL.startsWith('unix://')) {
|
||||
redisParams.path = env.REDIS_URL.slice(7);
|
||||
}
|
||||
|
||||
return {
|
||||
redisParams,
|
||||
redisPrefix,
|
||||
redisUrl: typeof env.REDIS_URL === 'string' ? env.REDIS_URL : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const PUBLIC_CHANNELS = [
|
||||
'public',
|
||||
'public:media',
|
||||
@ -291,10 +102,12 @@ const CHANNEL_NAMES = [
|
||||
];
|
||||
|
||||
const startServer = async () => {
|
||||
const pgPool = new pg.Pool(pgConfigFromEnv(process.env));
|
||||
const pgPool = Database.getPool(Database.configFromEnv(process.env, environment));
|
||||
|
||||
const metrics = setupMetrics(CHANNEL_NAMES, pgPool);
|
||||
|
||||
const redisConfig = Redis.configFromEnv(process.env);
|
||||
const redisClient = Redis.createClient(redisConfig, logger);
|
||||
const server = http.createServer();
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
@ -386,9 +199,7 @@ const startServer = async () => {
|
||||
*/
|
||||
const subs = {};
|
||||
|
||||
const redisConfig = redisConfigFromEnv(process.env);
|
||||
const redisSubscribeClient = await createRedisClient(redisConfig);
|
||||
const redisClient = await createRedisClient(redisConfig);
|
||||
const redisSubscribeClient = Redis.createClient(redisConfig, logger);
|
||||
const { redisPrefix } = redisConfig;
|
||||
|
||||
// When checking metrics in the browser, the favicon is requested this
|
||||
|
65
streaming/redis.js
Normal file
65
streaming/redis.js
Normal file
@ -0,0 +1,65 @@
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
import { parseIntFromEnvValue } from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef RedisConfiguration
|
||||
* @property {import('ioredis').RedisOptions} redisParams
|
||||
* @property {string} redisPrefix
|
||||
* @property {string|undefined} redisUrl
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from
|
||||
* @returns {RedisConfiguration} configuration for the Redis connection
|
||||
*/
|
||||
export function configFromEnv(env) {
|
||||
// ioredis *can* transparently add prefixes for us, but it doesn't *in some cases*,
|
||||
// which means we can't use it. But this is something that should be looked into.
|
||||
const redisPrefix = env.REDIS_NAMESPACE ? `${env.REDIS_NAMESPACE}:` : '';
|
||||
|
||||
let redisPort = parseIntFromEnvValue(env.REDIS_PORT, 6379, 'REDIS_PORT');
|
||||
let redisDatabase = parseIntFromEnvValue(env.REDIS_DB, 0, 'REDIS_DB');
|
||||
|
||||
/** @type {import('ioredis').RedisOptions} */
|
||||
const redisParams = {
|
||||
host: env.REDIS_HOST || '127.0.0.1',
|
||||
port: redisPort,
|
||||
// Force support for both IPv6 and IPv4, by default ioredis sets this to 4,
|
||||
// only allowing IPv4 connections:
|
||||
// https://github.com/redis/ioredis/issues/1576
|
||||
family: 0,
|
||||
db: redisDatabase,
|
||||
password: env.REDIS_PASSWORD || undefined,
|
||||
};
|
||||
|
||||
// redisParams.path takes precedence over host and port.
|
||||
if (env.REDIS_URL && env.REDIS_URL.startsWith('unix://')) {
|
||||
redisParams.path = env.REDIS_URL.slice(7);
|
||||
}
|
||||
|
||||
return {
|
||||
redisParams,
|
||||
redisPrefix,
|
||||
redisUrl: typeof env.REDIS_URL === 'string' ? env.REDIS_URL : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RedisConfiguration} config
|
||||
* @param {import('pino').Logger} logger
|
||||
* @returns {Redis}
|
||||
*/
|
||||
export function createClient({ redisParams, redisUrl }, logger) {
|
||||
let client;
|
||||
|
||||
if (typeof redisUrl === 'string') {
|
||||
client = new Redis(redisUrl, redisParams);
|
||||
} else {
|
||||
client = new Redis(redisParams);
|
||||
}
|
||||
|
||||
client.on('error', (err) => logger.error({ err }, 'Redis Client Error!'));
|
||||
|
||||
return client;
|
||||
}
|
@ -59,3 +59,23 @@ export function firstParam(arrayOrString) {
|
||||
return arrayOrString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an environment variable that should be an integer, attempts to parse
|
||||
* it falling back to a default if not set, and handles errors parsing.
|
||||
* @param {string|undefined} value
|
||||
* @param {number} defaultValue
|
||||
* @param {string} variableName
|
||||
* @returns {number}
|
||||
*/
|
||||
export function parseIntFromEnvValue(value, defaultValue, variableName) {
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
const parsedValue = parseInt(value, 10);
|
||||
if (isNaN(parsedValue)) {
|
||||
throw new Error(`Invalid ${variableName} environment variable: ${value}`);
|
||||
}
|
||||
return parsedValue;
|
||||
} else {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user