mirror of https://github.com/Siphonay/mastodon
Merge pull request #2650 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 6f7615ba86
This commit is contained in:
commit
a25c900a08
|
@ -36,4 +36,4 @@ jobs:
|
||||||
- name: Run haml-lint
|
- name: Run haml-lint
|
||||||
run: |
|
run: |
|
||||||
echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json"
|
echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json"
|
||||||
bundle exec haml-lint
|
bundle exec haml-lint --reporter github
|
||||||
|
|
|
@ -744,7 +744,7 @@ GEM
|
||||||
terrapin (1.0.1)
|
terrapin (1.0.1)
|
||||||
climate_control
|
climate_control
|
||||||
test-prof (1.3.1)
|
test-prof (1.3.1)
|
||||||
thor (1.3.0)
|
thor (1.3.1)
|
||||||
tilt (2.3.0)
|
tilt (2.3.0)
|
||||||
timeout (0.4.1)
|
timeout (0.4.1)
|
||||||
tpm-key_attestation (0.12.0)
|
tpm-key_attestation (0.12.0)
|
||||||
|
|
|
@ -180,7 +180,7 @@ class ApplicationController < ActionController::Base
|
||||||
use_pack 'error'
|
use_pack 'error'
|
||||||
render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html]
|
render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html]
|
||||||
end
|
end
|
||||||
format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: code }
|
format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: 410 }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
import { IconButton } from 'flavours/glitch/components/icon_button';
|
||||||
|
|
||||||
import DropdownMenu from './dropdown_menu';
|
import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
|
||||||
|
|
||||||
export const DropdownIconButton = ({ value, disabled, icon, onChange, iconComponent, title, options }) => {
|
export const DropdownIconButton = ({ value, disabled, icon, onChange, iconComponent, title, options }) => {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
@ -53,7 +53,7 @@ export const DropdownIconButton = ({ value, disabled, icon, onChange, iconCompon
|
||||||
{({ props, placement }) => (
|
{({ props, placement }) => (
|
||||||
<div {...props}>
|
<div {...props}>
|
||||||
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
||||||
<DropdownMenu
|
<PrivacyDropdownMenu
|
||||||
items={options}
|
items={options}
|
||||||
value={value}
|
value={value}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
|
|
|
@ -1,125 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
|
||||||
|
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
|
|
||||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
|
||||||
|
|
||||||
// copied from PrivacyDropdown; will require refactor with upstream down the line
|
|
||||||
class DropdownMenu extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
style: PropTypes.object,
|
|
||||||
items: PropTypes.array.isRequired,
|
|
||||||
value: PropTypes.string.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDocumentClick = e => {
|
|
||||||
if (this.node && !this.node.contains(e.target)) {
|
|
||||||
this.props.onClose();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleKeyDown = e => {
|
|
||||||
const { items } = this.props;
|
|
||||||
const value = e.currentTarget.getAttribute('data-index');
|
|
||||||
const index = items.findIndex(item => {
|
|
||||||
return (item.value === value);
|
|
||||||
});
|
|
||||||
let element = null;
|
|
||||||
|
|
||||||
switch(e.key) {
|
|
||||||
case 'Escape':
|
|
||||||
this.props.onClose();
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
this.handleClick(e);
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
element = this.node.childNodes[index + 1] || this.node.firstChild;
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
element = this.node.childNodes[index - 1] || this.node.lastChild;
|
|
||||||
break;
|
|
||||||
case 'Tab':
|
|
||||||
if (e.shiftKey) {
|
|
||||||
element = this.node.childNodes[index - 1] || this.node.lastChild;
|
|
||||||
} else {
|
|
||||||
element = this.node.childNodes[index + 1] || this.node.firstChild;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Home':
|
|
||||||
element = this.node.firstChild;
|
|
||||||
break;
|
|
||||||
case 'End':
|
|
||||||
element = this.node.lastChild;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
element.focus();
|
|
||||||
this.props.onChange(element.getAttribute('data-index'));
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClick = e => {
|
|
||||||
const value = e.currentTarget.getAttribute('data-index');
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
this.props.onClose();
|
|
||||||
this.props.onChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
document.addEventListener('click', this.handleDocumentClick, { capture: true });
|
|
||||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
|
||||||
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
|
|
||||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.node = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
setFocusRef = c => {
|
|
||||||
this.focusedItem = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { style, items, value } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ ...style }} role='listbox' ref={this.setRef}>
|
|
||||||
{items.map(item => (
|
|
||||||
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
|
|
||||||
<div className='privacy-dropdown__option__icon'>
|
|
||||||
<Icon id={item.icon} icon={item.iconComponent} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='privacy-dropdown__option__content'>
|
|
||||||
<strong>{item.text}</strong>
|
|
||||||
{item.meta}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DropdownMenu;
|
|
|
@ -141,6 +141,7 @@ class LanguageDropdownMenu extends PureComponent {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
onClose();
|
onClose();
|
||||||
break;
|
break;
|
||||||
|
case ' ':
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
this.handleClick(e);
|
this.handleClick(e);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -5,16 +5,16 @@ import { injectIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
|
||||||
import Overlay from 'react-overlays/Overlay';
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
|
import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||||
|
@ -28,126 +28,6 @@ const messages = defineMessages({
|
||||||
unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
|
unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
|
||||||
|
|
||||||
class PrivacyDropdownMenu extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
style: PropTypes.object,
|
|
||||||
items: PropTypes.array.isRequired,
|
|
||||||
value: PropTypes.string.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDocumentClick = e => {
|
|
||||||
if (this.node && !this.node.contains(e.target)) {
|
|
||||||
this.props.onClose();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleKeyDown = e => {
|
|
||||||
const { items } = this.props;
|
|
||||||
const value = e.currentTarget.getAttribute('data-index');
|
|
||||||
const index = items.findIndex(item => {
|
|
||||||
return (item.value === value);
|
|
||||||
});
|
|
||||||
let element = null;
|
|
||||||
|
|
||||||
switch(e.key) {
|
|
||||||
case 'Escape':
|
|
||||||
this.props.onClose();
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
this.handleClick(e);
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
element = this.node.childNodes[index + 1] || this.node.firstChild;
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
element = this.node.childNodes[index - 1] || this.node.lastChild;
|
|
||||||
break;
|
|
||||||
case 'Tab':
|
|
||||||
if (e.shiftKey) {
|
|
||||||
element = this.node.childNodes[index - 1] || this.node.lastChild;
|
|
||||||
} else {
|
|
||||||
element = this.node.childNodes[index + 1] || this.node.firstChild;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Home':
|
|
||||||
element = this.node.firstChild;
|
|
||||||
break;
|
|
||||||
case 'End':
|
|
||||||
element = this.node.lastChild;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
element.focus();
|
|
||||||
this.props.onChange(element.getAttribute('data-index'));
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClick = e => {
|
|
||||||
const value = e.currentTarget.getAttribute('data-index');
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
this.props.onClose();
|
|
||||||
this.props.onChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
document.addEventListener('click', this.handleDocumentClick, { capture: true });
|
|
||||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
|
||||||
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
|
|
||||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.node = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
setFocusRef = c => {
|
|
||||||
this.focusedItem = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { style, items, value } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ ...style }} role='listbox' ref={this.setRef}>
|
|
||||||
{items.map(item => (
|
|
||||||
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
|
|
||||||
<div className='privacy-dropdown__option__icon'>
|
|
||||||
<Icon id={item.icon} icon={item.iconComponent} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='privacy-dropdown__option__content'>
|
|
||||||
<strong>{item.text}</strong>
|
|
||||||
{item.meta}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.extra && (
|
|
||||||
<div className='privacy-dropdown__option__additional' title={item.extra}>
|
|
||||||
<Icon id='info-circle' icon={InfoIcon} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class PrivacyDropdown extends PureComponent {
|
class PrivacyDropdown extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
|
|
@ -0,0 +1,128 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
|
|
||||||
|
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||||
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
|
|
||||||
|
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||||
|
|
||||||
|
export const PrivacyDropdownMenu = ({ style, items, value, onClose, onChange }) => {
|
||||||
|
const nodeRef = useRef(null);
|
||||||
|
const focusedItemRef = useRef(null);
|
||||||
|
const [currentValue, setCurrentValue] = useState(value);
|
||||||
|
|
||||||
|
const handleDocumentClick = useCallback((e) => {
|
||||||
|
if (nodeRef.current && !nodeRef.current.contains(e.target)) {
|
||||||
|
onClose();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}, [nodeRef, onClose]);
|
||||||
|
|
||||||
|
const handleClick = useCallback((e) => {
|
||||||
|
const value = e.currentTarget.getAttribute('data-index');
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
onChange(value);
|
||||||
|
}, [onClose, onChange]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e) => {
|
||||||
|
const value = e.currentTarget.getAttribute('data-index');
|
||||||
|
const index = items.findIndex(item => (item.value === value));
|
||||||
|
|
||||||
|
let element = null;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
handleClick(e);
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
if (e.shiftKey) {
|
||||||
|
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
||||||
|
} else {
|
||||||
|
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
element = nodeRef.current.firstChild;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
element = nodeRef.current.lastChild;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
setCurrentValue(element.getAttribute('data-index'));
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}, [nodeRef, items, onClose, handleClick, setCurrentValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||||
|
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||||
|
focusedItemRef.current?.focus({ preventScroll: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick, { capture: true });
|
||||||
|
document.removeEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||||
|
};
|
||||||
|
}, [handleDocumentClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul style={{ ...style }} role='listbox' ref={nodeRef}>
|
||||||
|
{items.map(item => (
|
||||||
|
<li
|
||||||
|
role='option'
|
||||||
|
tabIndex={0}
|
||||||
|
key={item.value}
|
||||||
|
data-index={item.value}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={classNames('privacy-dropdown__option', { active: item.value === currentValue })}
|
||||||
|
aria-selected={item.value === currentValue}
|
||||||
|
ref={item.value === currentValue ? focusedItemRef : null}
|
||||||
|
>
|
||||||
|
<div className='privacy-dropdown__option__icon'>
|
||||||
|
<Icon id={item.icon} icon={item.iconComponent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='privacy-dropdown__option__content'>
|
||||||
|
<strong>{item.text}</strong>
|
||||||
|
{item.meta}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.extra && (
|
||||||
|
<div className='privacy-dropdown__option__additional' title={item.extra}>
|
||||||
|
<Icon id='info-circle' icon={InfoIcon} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PrivacyDropdownMenu.propTypes = {
|
||||||
|
style: PropTypes.object,
|
||||||
|
items: PropTypes.array.isRequired,
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
};
|
|
@ -1065,6 +1065,7 @@ a.name-tag,
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__permissions {
|
&__permissions {
|
||||||
|
|
|
@ -1080,6 +1080,7 @@ code {
|
||||||
|
|
||||||
&__type {
|
&__type {
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -141,6 +141,7 @@ class LanguageDropdownMenu extends PureComponent {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
onClose();
|
onClose();
|
||||||
break;
|
break;
|
||||||
|
case ' ':
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
this.handleClick(e);
|
this.handleClick(e);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -5,16 +5,16 @@ import { injectIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
|
||||||
import Overlay from 'react-overlays/Overlay';
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
|
||||||
|
import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||||
|
@ -28,126 +28,6 @@ const messages = defineMessages({
|
||||||
unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
|
unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
|
||||||
|
|
||||||
class PrivacyDropdownMenu extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
style: PropTypes.object,
|
|
||||||
items: PropTypes.array.isRequired,
|
|
||||||
value: PropTypes.string.isRequired,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDocumentClick = e => {
|
|
||||||
if (this.node && !this.node.contains(e.target)) {
|
|
||||||
this.props.onClose();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleKeyDown = e => {
|
|
||||||
const { items } = this.props;
|
|
||||||
const value = e.currentTarget.getAttribute('data-index');
|
|
||||||
const index = items.findIndex(item => {
|
|
||||||
return (item.value === value);
|
|
||||||
});
|
|
||||||
let element = null;
|
|
||||||
|
|
||||||
switch(e.key) {
|
|
||||||
case 'Escape':
|
|
||||||
this.props.onClose();
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
this.handleClick(e);
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
element = this.node.childNodes[index + 1] || this.node.firstChild;
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
element = this.node.childNodes[index - 1] || this.node.lastChild;
|
|
||||||
break;
|
|
||||||
case 'Tab':
|
|
||||||
if (e.shiftKey) {
|
|
||||||
element = this.node.childNodes[index - 1] || this.node.lastChild;
|
|
||||||
} else {
|
|
||||||
element = this.node.childNodes[index + 1] || this.node.firstChild;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Home':
|
|
||||||
element = this.node.firstChild;
|
|
||||||
break;
|
|
||||||
case 'End':
|
|
||||||
element = this.node.lastChild;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
element.focus();
|
|
||||||
this.props.onChange(element.getAttribute('data-index'));
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClick = e => {
|
|
||||||
const value = e.currentTarget.getAttribute('data-index');
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
this.props.onClose();
|
|
||||||
this.props.onChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
document.addEventListener('click', this.handleDocumentClick, { capture: true });
|
|
||||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
|
||||||
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
|
|
||||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.node = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
setFocusRef = c => {
|
|
||||||
this.focusedItem = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { style, items, value } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ ...style }} role='listbox' ref={this.setRef}>
|
|
||||||
{items.map(item => (
|
|
||||||
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
|
|
||||||
<div className='privacy-dropdown__option__icon'>
|
|
||||||
<Icon id={item.icon} icon={item.iconComponent} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='privacy-dropdown__option__content'>
|
|
||||||
<strong>{item.text}</strong>
|
|
||||||
{item.meta}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.extra && (
|
|
||||||
<div className='privacy-dropdown__option__additional' title={item.extra}>
|
|
||||||
<Icon id='info-circle' icon={InfoIcon} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class PrivacyDropdown extends PureComponent {
|
class PrivacyDropdown extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
|
|
@ -0,0 +1,128 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
|
|
||||||
|
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
|
||||||
|
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||||
|
|
||||||
|
export const PrivacyDropdownMenu = ({ style, items, value, onClose, onChange }) => {
|
||||||
|
const nodeRef = useRef(null);
|
||||||
|
const focusedItemRef = useRef(null);
|
||||||
|
const [currentValue, setCurrentValue] = useState(value);
|
||||||
|
|
||||||
|
const handleDocumentClick = useCallback((e) => {
|
||||||
|
if (nodeRef.current && !nodeRef.current.contains(e.target)) {
|
||||||
|
onClose();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}, [nodeRef, onClose]);
|
||||||
|
|
||||||
|
const handleClick = useCallback((e) => {
|
||||||
|
const value = e.currentTarget.getAttribute('data-index');
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
onChange(value);
|
||||||
|
}, [onClose, onChange]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e) => {
|
||||||
|
const value = e.currentTarget.getAttribute('data-index');
|
||||||
|
const index = items.findIndex(item => (item.value === value));
|
||||||
|
|
||||||
|
let element = null;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
handleClick(e);
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
if (e.shiftKey) {
|
||||||
|
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
||||||
|
} else {
|
||||||
|
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
element = nodeRef.current.firstChild;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
element = nodeRef.current.lastChild;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
setCurrentValue(element.getAttribute('data-index'));
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}, [nodeRef, items, onClose, handleClick, setCurrentValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||||
|
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||||
|
focusedItemRef.current?.focus({ preventScroll: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick, { capture: true });
|
||||||
|
document.removeEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||||
|
};
|
||||||
|
}, [handleDocumentClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul style={{ ...style }} role='listbox' ref={nodeRef}>
|
||||||
|
{items.map(item => (
|
||||||
|
<li
|
||||||
|
role='option'
|
||||||
|
tabIndex={0}
|
||||||
|
key={item.value}
|
||||||
|
data-index={item.value}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={classNames('privacy-dropdown__option', { active: item.value === currentValue })}
|
||||||
|
aria-selected={item.value === currentValue}
|
||||||
|
ref={item.value === currentValue ? focusedItemRef : null}
|
||||||
|
>
|
||||||
|
<div className='privacy-dropdown__option__icon'>
|
||||||
|
<Icon id={item.icon} icon={item.iconComponent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='privacy-dropdown__option__content'>
|
||||||
|
<strong>{item.text}</strong>
|
||||||
|
{item.meta}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.extra && (
|
||||||
|
<div className='privacy-dropdown__option__additional' title={item.extra}>
|
||||||
|
<Icon id='info-circle' icon={InfoIcon} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PrivacyDropdownMenu.propTypes = {
|
||||||
|
style: PropTypes.object,
|
||||||
|
items: PropTypes.array.isRequired,
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
};
|
|
@ -40,7 +40,7 @@
|
||||||
"account.following_counter": "{count, plural, one {Fylgjer {counter}} other {Fylgjer {counter}}}",
|
"account.following_counter": "{count, plural, one {Fylgjer {counter}} other {Fylgjer {counter}}}",
|
||||||
"account.follows.empty": "Denne brukaren fylgjer ikkje nokon enno.",
|
"account.follows.empty": "Denne brukaren fylgjer ikkje nokon enno.",
|
||||||
"account.go_to_profile": "Gå til profil",
|
"account.go_to_profile": "Gå til profil",
|
||||||
"account.hide_reblogs": "Skjul framhevingar frå @{name}",
|
"account.hide_reblogs": "Gøym framhevingar frå @{name}",
|
||||||
"account.in_memoriam": "Til minne om.",
|
"account.in_memoriam": "Til minne om.",
|
||||||
"account.joined_short": "Vart med",
|
"account.joined_short": "Vart med",
|
||||||
"account.languages": "Endre språktingingar",
|
"account.languages": "Endre språktingingar",
|
||||||
|
@ -113,7 +113,7 @@
|
||||||
"column.community": "Lokal tidsline",
|
"column.community": "Lokal tidsline",
|
||||||
"column.direct": "Private omtaler",
|
"column.direct": "Private omtaler",
|
||||||
"column.directory": "Sjå gjennom profilar",
|
"column.directory": "Sjå gjennom profilar",
|
||||||
"column.domain_blocks": "Skjulte domene",
|
"column.domain_blocks": "Blokkerte domene",
|
||||||
"column.favourites": "Favorittar",
|
"column.favourites": "Favorittar",
|
||||||
"column.firehose": "Tidslinjer",
|
"column.firehose": "Tidslinjer",
|
||||||
"column.follow_requests": "Fylgjeførespurnadar",
|
"column.follow_requests": "Fylgjeførespurnadar",
|
||||||
|
@ -124,7 +124,7 @@
|
||||||
"column.pins": "Festa tut",
|
"column.pins": "Festa tut",
|
||||||
"column.public": "Samla tidsline",
|
"column.public": "Samla tidsline",
|
||||||
"column_back_button.label": "Attende",
|
"column_back_button.label": "Attende",
|
||||||
"column_header.hide_settings": "Gøym innstillingar",
|
"column_header.hide_settings": "Gøym innstillingane",
|
||||||
"column_header.moveLeft_settings": "Flytt kolonne til venstre",
|
"column_header.moveLeft_settings": "Flytt kolonne til venstre",
|
||||||
"column_header.moveRight_settings": "Flytt kolonne til høgre",
|
"column_header.moveRight_settings": "Flytt kolonne til høgre",
|
||||||
"column_header.pin": "Fest",
|
"column_header.pin": "Fest",
|
||||||
|
@ -171,14 +171,14 @@
|
||||||
"confirmations.delete_list.message": "Er du sikker på at du vil sletta denne lista for alltid?",
|
"confirmations.delete_list.message": "Er du sikker på at du vil sletta denne lista for alltid?",
|
||||||
"confirmations.discard_edit_media.confirm": "Forkast",
|
"confirmations.discard_edit_media.confirm": "Forkast",
|
||||||
"confirmations.discard_edit_media.message": "Du har ulagra endringar i mediaskildringa eller førehandsvisinga. Vil du forkasta dei likevel?",
|
"confirmations.discard_edit_media.message": "Du har ulagra endringar i mediaskildringa eller førehandsvisinga. Vil du forkasta dei likevel?",
|
||||||
"confirmations.domain_block.confirm": "Skjul alt frå domenet",
|
"confirmations.domain_block.confirm": "Blokker heile domenet",
|
||||||
"confirmations.domain_block.message": "Er du heilt, heilt sikker på at du vil skjula heile {domain}? I dei fleste tilfelle er det godt nok og føretrekt med nokre få målretta blokkeringar eller målbindingar. Du kjem ikkje til å sjå innhald frå domenet i fødererte tidsliner eller i varsla dine. Fylgjarane dine frå domenet vert fjerna.",
|
"confirmations.domain_block.message": "Er du heilt, heilt sikker på at du vil skjula heile {domain}? I dei fleste tilfelle er det godt nok og føretrekt med nokre få målretta blokkeringar eller målbindingar. Du kjem ikkje til å sjå innhald frå domenet i fødererte tidsliner eller i varsla dine. Fylgjarane dine frå domenet vert fjerna.",
|
||||||
"confirmations.edit.confirm": "Rediger",
|
"confirmations.edit.confirm": "Rediger",
|
||||||
"confirmations.edit.message": "Å redigera no vil overskriva den meldinga du er i ferd med å skriva. Er du sikker på at du vil halda fram?",
|
"confirmations.edit.message": "Å redigera no vil overskriva den meldinga du er i ferd med å skriva. Er du sikker på at du vil halda fram?",
|
||||||
"confirmations.logout.confirm": "Logg ut",
|
"confirmations.logout.confirm": "Logg ut",
|
||||||
"confirmations.logout.message": "Er du sikker på at du vil logga ut?",
|
"confirmations.logout.message": "Er du sikker på at du vil logga ut?",
|
||||||
"confirmations.mute.confirm": "Målbind",
|
"confirmations.mute.confirm": "Målbind",
|
||||||
"confirmations.mute.explanation": "Dette vil skjula innlegg som kjem frå og som nemner dei, men vil framleis la dei sjå innlegga dine og fylgje deg.",
|
"confirmations.mute.explanation": "Dette vil gøyma innlegga deira og innlegg som nemner dei, men dei vil framleis kunna sjå innlegga dine og fylgja deg.",
|
||||||
"confirmations.mute.message": "Er du sikker på at du vil målbinda {name}?",
|
"confirmations.mute.message": "Er du sikker på at du vil målbinda {name}?",
|
||||||
"confirmations.redraft.confirm": "Slett & skriv på nytt",
|
"confirmations.redraft.confirm": "Slett & skriv på nytt",
|
||||||
"confirmations.redraft.message": "Er du sikker på at du vil sletta denne statusen og skriva han på nytt? Då misser du favorittar og framhevingar, og svar til det opprinnelege innlegget vert foreldrelause.",
|
"confirmations.redraft.message": "Er du sikker på at du vil sletta denne statusen og skriva han på nytt? Då misser du favorittar og framhevingar, og svar til det opprinnelege innlegget vert foreldrelause.",
|
||||||
|
@ -230,7 +230,7 @@
|
||||||
"empty_column.bookmarked_statuses": "Du har ikkje lagra noko bokmerke enno. Når du set bokmerke på eit innlegg, dukkar det opp her.",
|
"empty_column.bookmarked_statuses": "Du har ikkje lagra noko bokmerke enno. Når du set bokmerke på eit innlegg, dukkar det opp her.",
|
||||||
"empty_column.community": "Den lokale tidslina er tom. Skriv noko offentleg å få ballen til å rulle!",
|
"empty_column.community": "Den lokale tidslina er tom. Skriv noko offentleg å få ballen til å rulle!",
|
||||||
"empty_column.direct": "Du har ingen private omtaler enda. Etter du har sendt eller mottatt en, så vil den dukke opp her.",
|
"empty_column.direct": "Du har ingen private omtaler enda. Etter du har sendt eller mottatt en, så vil den dukke opp her.",
|
||||||
"empty_column.domain_blocks": "Det er ingen skjulte domene til no.",
|
"empty_column.domain_blocks": "Det er ingen blokkerte domene enno.",
|
||||||
"empty_column.explore_statuses": "Ingenting er i støytet nett no. Prøv igjen seinare!",
|
"empty_column.explore_statuses": "Ingenting er i støytet nett no. Prøv igjen seinare!",
|
||||||
"empty_column.favourited_statuses": "Du har ingen favoritt-statusar ennå. Når du merkjer ein som favoritt, dukkar han opp her.",
|
"empty_column.favourited_statuses": "Du har ingen favoritt-statusar ennå. Når du merkjer ein som favoritt, dukkar han opp her.",
|
||||||
"empty_column.favourites": "Ingen har merkt denne statusen som favoritt enno. Når nokon gjer det, dukkar dei opp her.",
|
"empty_column.favourites": "Ingen har merkt denne statusen som favoritt enno. Når nokon gjer det, dukkar dei opp her.",
|
||||||
|
@ -277,7 +277,13 @@
|
||||||
"follow_request.authorize": "Autoriser",
|
"follow_request.authorize": "Autoriser",
|
||||||
"follow_request.reject": "Avvis",
|
"follow_request.reject": "Avvis",
|
||||||
"follow_requests.unlocked_explanation": "Sjølv om kontoen din ikkje er låst tenkte dei som driv {domain} at du kanskje ville gå gjennom førespurnadar frå desse kontoane manuelt.",
|
"follow_requests.unlocked_explanation": "Sjølv om kontoen din ikkje er låst tenkte dei som driv {domain} at du kanskje ville gå gjennom førespurnadar frå desse kontoane manuelt.",
|
||||||
|
"follow_suggestions.curated_suggestion": "Utvalt av staben",
|
||||||
"follow_suggestions.dismiss": "Ikkje vis igjen",
|
"follow_suggestions.dismiss": "Ikkje vis igjen",
|
||||||
|
"follow_suggestions.hints.featured": "Denne profilen er handplukka av folka på {domain}.",
|
||||||
|
"follow_suggestions.hints.friends_of_friends": "Denne profilen er populær hjå dei du fylgjer.",
|
||||||
|
"follow_suggestions.hints.most_followed": "Mange på {domain} fylgjer denne profilen.",
|
||||||
|
"follow_suggestions.hints.most_interactions": "Denne profilen har nyss fått mykje merksemd på {domain}.",
|
||||||
|
"follow_suggestions.hints.similar_to_recently_followed": "Denne profilen liknar på dei andre profilane du har fylgt i det siste.",
|
||||||
"follow_suggestions.personalized_suggestion": "Personleg forslag",
|
"follow_suggestions.personalized_suggestion": "Personleg forslag",
|
||||||
"follow_suggestions.popular_suggestion": "Populært forslag",
|
"follow_suggestions.popular_suggestion": "Populært forslag",
|
||||||
"follow_suggestions.view_all": "Vis alle",
|
"follow_suggestions.view_all": "Vis alle",
|
||||||
|
@ -395,7 +401,7 @@
|
||||||
"media_gallery.toggle_visible": "{number, plural, one {Skjul bilete} other {Skjul bilete}}",
|
"media_gallery.toggle_visible": "{number, plural, one {Skjul bilete} other {Skjul bilete}}",
|
||||||
"moved_to_account_banner.text": "Kontoen din, {disabledAccount} er for tida deaktivert fordi du har flytta til {movedToAccount}.",
|
"moved_to_account_banner.text": "Kontoen din, {disabledAccount} er for tida deaktivert fordi du har flytta til {movedToAccount}.",
|
||||||
"mute_modal.duration": "Varigheit",
|
"mute_modal.duration": "Varigheit",
|
||||||
"mute_modal.hide_notifications": "Skjul varsel frå denne brukaren?",
|
"mute_modal.hide_notifications": "Gøym varsel frå denne brukaren?",
|
||||||
"mute_modal.indefinite": "På ubestemt tid",
|
"mute_modal.indefinite": "På ubestemt tid",
|
||||||
"navigation_bar.about": "Om",
|
"navigation_bar.about": "Om",
|
||||||
"navigation_bar.advanced_interface": "Opne i avansert nettgrensesnitt",
|
"navigation_bar.advanced_interface": "Opne i avansert nettgrensesnitt",
|
||||||
|
@ -479,7 +485,8 @@
|
||||||
"onboarding.follows.empty": "Me kan ikkje visa deg nokon resultat no. Du kan prøva å søkja eller bla gjennom utforsk-sida for å finna folk å fylgja, eller du kan prøva att seinare.",
|
"onboarding.follows.empty": "Me kan ikkje visa deg nokon resultat no. Du kan prøva å søkja eller bla gjennom utforsk-sida for å finna folk å fylgja, eller du kan prøva att seinare.",
|
||||||
"onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!",
|
"onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!",
|
||||||
"onboarding.follows.title": "Popular on Mastodon",
|
"onboarding.follows.title": "Popular on Mastodon",
|
||||||
"onboarding.profile.discoverable": "Gjør min profil synlig",
|
"onboarding.profile.discoverable": "Gjer profilen min synleg",
|
||||||
|
"onboarding.profile.discoverable_hint": "Når du vel å gjera profilen din synleg på Mastodon, vil innlegga dine syna i søkjeresultat og populære innlegg, og profilen din kan bli føreslegen for folk med liknande interesser som deg.",
|
||||||
"onboarding.profile.display_name": "Synleg namn",
|
"onboarding.profile.display_name": "Synleg namn",
|
||||||
"onboarding.profile.display_name_hint": "Det fulle namnet eller kallenamnet ditt…",
|
"onboarding.profile.display_name_hint": "Det fulle namnet eller kallenamnet ditt…",
|
||||||
"onboarding.profile.lead": "Du kan alltid fullføra dette seinare i innstillingane, og der er det endå fleire tilpassingsalternativ.",
|
"onboarding.profile.lead": "Du kan alltid fullføra dette seinare i innstillingane, og der er det endå fleire tilpassingsalternativ.",
|
||||||
|
@ -528,11 +535,12 @@
|
||||||
"privacy.private.short": "Følgjarar",
|
"privacy.private.short": "Følgjarar",
|
||||||
"privacy.public.long": "Kven som helst på og av Mastodon",
|
"privacy.public.long": "Kven som helst på og av Mastodon",
|
||||||
"privacy.public.short": "Offentleg",
|
"privacy.public.short": "Offentleg",
|
||||||
|
"privacy.unlisted.additional": "Dette er akkurat som offentleg, bortsett frå at innlegga ikkje dukkar opp i direktestraumar eller merkelappar, i oppdagingar eller Mastodon-søk, sjølv om du har sagt ja til at kontoen skal vera synleg.",
|
||||||
"privacy.unlisted.long": "Færre algoritmiske fanfarar",
|
"privacy.unlisted.long": "Færre algoritmiske fanfarar",
|
||||||
"privacy.unlisted.short": "Stille offentleg",
|
"privacy.unlisted.short": "Stille offentleg",
|
||||||
"privacy_policy.last_updated": "Sist oppdatert {date}",
|
"privacy_policy.last_updated": "Sist oppdatert {date}",
|
||||||
"privacy_policy.title": "Personvernsreglar",
|
"privacy_policy.title": "Personvernsreglar",
|
||||||
"recommended": "Anbefalt",
|
"recommended": "Tilrådd",
|
||||||
"refresh": "Oppdater",
|
"refresh": "Oppdater",
|
||||||
"regeneration_indicator.label": "Lastar…",
|
"regeneration_indicator.label": "Lastar…",
|
||||||
"regeneration_indicator.sublabel": "Heimetidslina di vert førebudd!",
|
"regeneration_indicator.sublabel": "Heimetidslina di vert førebudd!",
|
||||||
|
@ -605,7 +613,7 @@
|
||||||
"search.quick_action.status_search": "Innlegg som samsvarer med {x}",
|
"search.quick_action.status_search": "Innlegg som samsvarer med {x}",
|
||||||
"search.search_or_paste": "Søk eller lim inn URL",
|
"search.search_or_paste": "Søk eller lim inn URL",
|
||||||
"search_popout.full_text_search_disabled_message": "Ikkje tilgjengeleg på {domain}.",
|
"search_popout.full_text_search_disabled_message": "Ikkje tilgjengeleg på {domain}.",
|
||||||
"search_popout.full_text_search_logged_out_message": "Bare tilgjengelig når man er logget inn.",
|
"search_popout.full_text_search_logged_out_message": "Berre tilgjengeleg når du er logga inn.",
|
||||||
"search_popout.language_code": "ISO-språkkode",
|
"search_popout.language_code": "ISO-språkkode",
|
||||||
"search_popout.options": "Søkjealternativ",
|
"search_popout.options": "Søkjealternativ",
|
||||||
"search_popout.quick_actions": "Hurtighandlinger",
|
"search_popout.quick_actions": "Hurtighandlinger",
|
||||||
|
@ -654,7 +662,7 @@
|
||||||
"status.load_more": "Last inn meir",
|
"status.load_more": "Last inn meir",
|
||||||
"status.media.open": "Klikk for å opne",
|
"status.media.open": "Klikk for å opne",
|
||||||
"status.media.show": "Klikk for å vise",
|
"status.media.show": "Klikk for å vise",
|
||||||
"status.media_hidden": "Medium gøymd",
|
"status.media_hidden": "Mediet er gøymt",
|
||||||
"status.mention": "Nemn @{name}",
|
"status.mention": "Nemn @{name}",
|
||||||
"status.more": "Meir",
|
"status.more": "Meir",
|
||||||
"status.mute": "Målbind @{name}",
|
"status.mute": "Målbind @{name}",
|
||||||
|
|
|
@ -1060,6 +1060,7 @@ a.name-tag,
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__permissions {
|
&__permissions {
|
||||||
|
|
|
@ -1078,6 +1078,7 @@ code {
|
||||||
|
|
||||||
&__type {
|
&__type {
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,7 @@ class Admin::Metrics::Measure::InstanceMediaAttachmentsMeasure < Admin::Metrics:
|
||||||
WHERE date_trunc('day', media_attachments.created_at)::date = axis.period
|
WHERE date_trunc('day', media_attachments.created_at)::date = axis.period
|
||||||
AND #{account_domain_sql(params[:include_subdomains])}
|
AND #{account_domain_sql(params[:include_subdomains])}
|
||||||
)
|
)
|
||||||
SELECT SUM(size) FROM new_media_attachments
|
SELECT COALESCE(SUM(size), 0) FROM new_media_attachments
|
||||||
) AS value
|
) AS value
|
||||||
FROM (
|
FROM (
|
||||||
SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
|
SELECT generate_series(date_trunc('day', :start_at::timestamp)::date, date_trunc('day', :end_at::timestamp)::date, interval '1 day') AS period
|
||||||
|
|
|
@ -12,6 +12,7 @@ nn:
|
||||||
last_attempt: Du har eitt forsøk igjen før kontoen din vert låst.
|
last_attempt: Du har eitt forsøk igjen før kontoen din vert låst.
|
||||||
locked: Kontoen din er låst.
|
locked: Kontoen din er låst.
|
||||||
not_found_in_database: Ugyldig %{authentication_keys} eller passord.
|
not_found_in_database: Ugyldig %{authentication_keys} eller passord.
|
||||||
|
omniauth_user_creation_failure: Greidde ikkje laga konto for denne identiteten.
|
||||||
pending: Kontoen din er vert gjennomgått enno.
|
pending: Kontoen din er vert gjennomgått enno.
|
||||||
timeout: Økta di er utgått. Logg inn omatt for å halde fram.
|
timeout: Økta di er utgått. Logg inn omatt for å halde fram.
|
||||||
unauthenticated: Du må logge inn eller registere deg før du kan halde fram.
|
unauthenticated: Du må logge inn eller registere deg før du kan halde fram.
|
||||||
|
@ -47,19 +48,19 @@ nn:
|
||||||
subject: 'Mastodon: Instuksjonar for å endra passord'
|
subject: 'Mastodon: Instuksjonar for å endra passord'
|
||||||
title: Attstilling av passord
|
title: Attstilling av passord
|
||||||
two_factor_disabled:
|
two_factor_disabled:
|
||||||
explanation: Innlogging er nå mulig med kun e-postadresse og passord.
|
explanation: No kan du logga inn med berre epostadresse og passord.
|
||||||
subject: 'Mastodon: To-faktor-autentisering deaktivert'
|
subject: 'Mastodon: To-faktor-autentisering deaktivert'
|
||||||
subtitle: To-faktor autentisering for din konto har blitt deaktivert.
|
subtitle: Tofaktorinnlogging for denne kontoen er skrudd av.
|
||||||
title: 2FA deaktivert
|
title: 2FA deaktivert
|
||||||
two_factor_enabled:
|
two_factor_enabled:
|
||||||
explanation: En token generert av den sammenkoblede TOTP-appen vil være påkrevd for innlogging.
|
explanation: Du treng ein kode frå den tilkopla tofaktor-appen din for å logga inn.
|
||||||
subject: 'Mastodon: To-faktor-autentisering aktivert'
|
subject: 'Mastodon: To-faktor-autentisering aktivert'
|
||||||
subtitle: Tofaktorautentisering er aktivert for din konto.
|
subtitle: Tofaktorpålogging er skrudd på for kontoen din.
|
||||||
title: 2FA aktivert
|
title: 2FA aktivert
|
||||||
two_factor_recovery_codes_changed:
|
two_factor_recovery_codes_changed:
|
||||||
explanation: Dei førre gjenopprettingskodane er ugyldige og nye er genererte.
|
explanation: Dei førre gjenopprettingskodane er ugyldige og nye er genererte.
|
||||||
subject: 'Mastodon: To-faktor-gjenopprettingskodar har vorte genererte på nytt'
|
subject: 'Mastodon: To-faktor-gjenopprettingskodar har vorte genererte på nytt'
|
||||||
subtitle: De forrige gjenopprettingskodene er gjort ugyldige og nye er generert.
|
subtitle: Dei førre innloggingskodane er ikkje gyldige lenger, og nye kodar er laga.
|
||||||
title: 2FA-gjenopprettingskodane er endra
|
title: 2FA-gjenopprettingskodane er endra
|
||||||
unlock_instructions:
|
unlock_instructions:
|
||||||
subject: 'Mastodon: Instruksjonar for å opne kontoen igjen'
|
subject: 'Mastodon: Instruksjonar for å opne kontoen igjen'
|
||||||
|
@ -73,13 +74,13 @@ nn:
|
||||||
subject: 'Mastodon: Sikkerheitsnøkkel sletta'
|
subject: 'Mastodon: Sikkerheitsnøkkel sletta'
|
||||||
title: Ein av sikkerheitsnøklane dine har blitt sletta
|
title: Ein av sikkerheitsnøklane dine har blitt sletta
|
||||||
webauthn_disabled:
|
webauthn_disabled:
|
||||||
explanation: Autentisering med sikkerhetsnøkler er deaktivert for kontoen din.
|
explanation: Innlogging med tryggingsnykjel er skrudd av for kontoen din.
|
||||||
extra: Innlogging er nå mulig med kun tilgangstoken generert av den sammenkoblede TOTP-appen.
|
extra: No kan du logga inn med berre kodane som er laga av den tilkopla tofaktor-appen din.
|
||||||
subject: 'Mastodon: Autentisering med sikkerheitsnøklar vart skrudd av'
|
subject: 'Mastodon: Autentisering med sikkerheitsnøklar vart skrudd av'
|
||||||
title: Sikkerheitsnøklar deaktivert
|
title: Sikkerheitsnøklar deaktivert
|
||||||
webauthn_enabled:
|
webauthn_enabled:
|
||||||
explanation: Sikkerhetsnøkkelautentisering har blitt aktivert for kontoen din.
|
explanation: Innlogging med tryggingsnyklar er skrudd på for kontoen din.
|
||||||
extra: Sikkerhetsnøkkelen din kan nå bli brukt for innlogging.
|
extra: No kan du bruka tryggingsnykjelen din for å logga inn.
|
||||||
subject: 'Mastodon: Sikkerheitsnøkkelsautentisering vart skrudd på'
|
subject: 'Mastodon: Sikkerheitsnøkkelsautentisering vart skrudd på'
|
||||||
title: Sikkerheitsnøklar aktivert
|
title: Sikkerheitsnøklar aktivert
|
||||||
omniauth_callbacks:
|
omniauth_callbacks:
|
||||||
|
|
|
@ -31,7 +31,7 @@ nn:
|
||||||
created_msg: Moderatormerknad er laga!
|
created_msg: Moderatormerknad er laga!
|
||||||
destroyed_msg: Moderatormerknad er utsletta!
|
destroyed_msg: Moderatormerknad er utsletta!
|
||||||
accounts:
|
accounts:
|
||||||
add_email_domain_block: Gøym e-postdomene
|
add_email_domain_block: Blokker e-postdomene
|
||||||
approve: Godtak
|
approve: Godtak
|
||||||
approved_msg: Godkjende %{username} sin registreringssøknad
|
approved_msg: Godkjende %{username} sin registreringssøknad
|
||||||
are_you_sure: Er du sikker?
|
are_you_sure: Er du sikker?
|
||||||
|
@ -767,13 +767,15 @@ nn:
|
||||||
disabled: Til ingen
|
disabled: Til ingen
|
||||||
users: Til lokale brukarar som er logga inn
|
users: Til lokale brukarar som er logga inn
|
||||||
registrations:
|
registrations:
|
||||||
|
moderation_recommandation: Pass på at du har mange og kjappe redaktørar og moderatorar på laget ditt før du opnar for allmenn registrering!
|
||||||
preamble: Kontroller kven som kan oppretta konto på tenaren din.
|
preamble: Kontroller kven som kan oppretta konto på tenaren din.
|
||||||
title: Registreringar
|
title: Registreringar
|
||||||
registrations_mode:
|
registrations_mode:
|
||||||
modes:
|
modes:
|
||||||
approved: Godkjenning kreves for påmelding
|
approved: Godkjenning krevst for å registrera seg
|
||||||
none: Ingen kan melda seg inn
|
none: Ingen kan melda seg inn
|
||||||
open: Kven som helst kan melda seg inn
|
open: Kven som helst kan melda seg inn
|
||||||
|
warning_hint: Me rår til at du bruker "Godkjenning krevst for å registrera seg" viss du ikkje er sikker på at moderatorane kan handtera søppel og illmeinte registreringar kvikt.
|
||||||
security:
|
security:
|
||||||
authorized_fetch: Krev autentisering frå fødererte tenarar
|
authorized_fetch: Krev autentisering frå fødererte tenarar
|
||||||
authorized_fetch_hint: Krav om autentisering frå fødererte tenarar gjer det mogleg med strengare handheving av blokkering, både på brukar- og tenar-nivå. Likevel, dette har ein kostnad når det gjeld yting, reduserer rekkevidda til svara dine og kan medføra kompabilitetsproblem med enkelte fødererte tenester. Dette vil heller ikkje hindra dei som verkeleg vil i å henta dei offentlege innlegga eller kontoane dine.
|
authorized_fetch_hint: Krav om autentisering frå fødererte tenarar gjer det mogleg med strengare handheving av blokkering, både på brukar- og tenar-nivå. Likevel, dette har ein kostnad når det gjeld yting, reduserer rekkevidda til svara dine og kan medføra kompabilitetsproblem med enkelte fødererte tenester. Dette vil heller ikkje hindra dei som verkeleg vil i å henta dei offentlege innlegga eller kontoane dine.
|
||||||
|
@ -1450,7 +1452,7 @@ nn:
|
||||||
moderation:
|
moderation:
|
||||||
title: Moderasjon
|
title: Moderasjon
|
||||||
move_handler:
|
move_handler:
|
||||||
carry_blocks_over_text: Denne brukaren flytta frå %{acct}, som du gøymde.
|
carry_blocks_over_text: Denne brukaren flytta frå %{acct}, som du hadde blokkert.
|
||||||
carry_mutes_over_text: Denne brukeren flyttet fra %{acct}, som du hadde dempet.
|
carry_mutes_over_text: Denne brukeren flyttet fra %{acct}, som du hadde dempet.
|
||||||
copy_account_note_text: 'Denne brukeren flyttet fra %{acct}, her var dine tidligere notater om dem:'
|
copy_account_note_text: 'Denne brukeren flyttet fra %{acct}, her var dine tidligere notater om dem:'
|
||||||
navigation:
|
navigation:
|
||||||
|
@ -1537,7 +1539,7 @@ nn:
|
||||||
privacy:
|
privacy:
|
||||||
hint_html: "<strong>Tilpass korleis du vil at andre skal finna profilen og innlegga dine.</strong> Mastodon har fleire funksjonar du kan ta i bruk for å få kontakt med eit større publikum. Sjå gjerne gjennom innstillingane slik at du er sikker på at dei passar til deg og din bruk."
|
hint_html: "<strong>Tilpass korleis du vil at andre skal finna profilen og innlegga dine.</strong> Mastodon har fleire funksjonar du kan ta i bruk for å få kontakt med eit større publikum. Sjå gjerne gjennom innstillingane slik at du er sikker på at dei passar til deg og din bruk."
|
||||||
privacy: Personvern
|
privacy: Personvern
|
||||||
privacy_hint_html: Ha kontroll over kor mykje du vil dela. Folk finn interessante profilar og fine appar ved å sjå gjennom kva andre fylgjer og kva appar dei legg ut innlegg med, men det kan henda du vil gøyma desse opplysingane.
|
privacy_hint_html: Kontroller kor mykje du vil dela. Folk finn interessante profilar og fine appar ved å sjå gjennom kva andre fylgjer og kva appar dei legg ut innlegg med, men det kan henda du vil gøyma desse opplysingane.
|
||||||
reach: Nå andre
|
reach: Nå andre
|
||||||
reach_hint_html: Hald styring med om du vil at andre skal kunna oppdaga og fylgja deg. Vil du at innlegga dine skal stå på Utforsk-sida? Vil du at andre skal sjå deg i tilrådingane for kven dei skal fylgja? Vil du ta imot nye fylgjarar automatisk, eller vil du kontrollera kvar einskild fylgjar?
|
reach_hint_html: Hald styring med om du vil at andre skal kunna oppdaga og fylgja deg. Vil du at innlegga dine skal stå på Utforsk-sida? Vil du at andre skal sjå deg i tilrådingane for kven dei skal fylgja? Vil du ta imot nye fylgjarar automatisk, eller vil du kontrollera kvar einskild fylgjar?
|
||||||
search: Søk
|
search: Søk
|
||||||
|
@ -1550,8 +1552,8 @@ nn:
|
||||||
limit_reached: Grensen for forskjellige reaksjoner nådd
|
limit_reached: Grensen for forskjellige reaksjoner nådd
|
||||||
unrecognized_emoji: er ikke en gjenkjent emoji
|
unrecognized_emoji: er ikke en gjenkjent emoji
|
||||||
redirects:
|
redirects:
|
||||||
prompt: Hvis du stoler på denne lenken, så trykk på den for å fortsette.
|
prompt: Viss du stolar på denne lenka, klikkar du på ho for å halda fram.
|
||||||
title: Du forlater %{instance}.
|
title: No forlèt du %{instance}.
|
||||||
relationships:
|
relationships:
|
||||||
activity: Kontoaktivitet
|
activity: Kontoaktivitet
|
||||||
confirm_follow_selected_followers: Er du sikker på at du ynskjer å fylgja dei valde fylgjarane?
|
confirm_follow_selected_followers: Er du sikker på at du ynskjer å fylgja dei valde fylgjarane?
|
||||||
|
@ -1781,7 +1783,7 @@ nn:
|
||||||
webauthn: Sikkerhetsnøkler
|
webauthn: Sikkerhetsnøkler
|
||||||
user_mailer:
|
user_mailer:
|
||||||
appeal_approved:
|
appeal_approved:
|
||||||
action: Kontoinnstillinger
|
action: Kontoinnstillingar
|
||||||
explanation: Apellen på prikken mot din kontor på %{strike_date} som du la inn på %{appeal_date} har blitt godkjend. Din konto er nok ein gong i god stand.
|
explanation: Apellen på prikken mot din kontor på %{strike_date} som du la inn på %{appeal_date} har blitt godkjend. Din konto er nok ein gong i god stand.
|
||||||
subject: Din klage fra %{date} er godkjent
|
subject: Din klage fra %{date} er godkjent
|
||||||
subtitle: Kontoen din er tilbake i god stand.
|
subtitle: Kontoen din er tilbake i god stand.
|
||||||
|
@ -1789,11 +1791,11 @@ nn:
|
||||||
appeal_rejected:
|
appeal_rejected:
|
||||||
explanation: Klagen på advarselen mot din konto den %{strike_date} som du sendte inn den %{appeal_date} har blitt avvist.
|
explanation: Klagen på advarselen mot din konto den %{strike_date} som du sendte inn den %{appeal_date} har blitt avvist.
|
||||||
subject: Din klage fra %{date} er avvist
|
subject: Din klage fra %{date} er avvist
|
||||||
subtitle: Anken din har blitt avvist.
|
subtitle: Klaga di vart avvist.
|
||||||
title: Anke avvist
|
title: Anke avvist
|
||||||
backup_ready:
|
backup_ready:
|
||||||
explanation: Du etterspurte en fullstendig sikkerhetskopi av din Mastodon-konto.
|
explanation: Du ba om ein fullstendig tryggingskopi av Mastodon-kontoen din.
|
||||||
extra: Den er nå klar for nedlasting!
|
extra: No kan du lasta han ned!
|
||||||
subject: Arkivet ditt er klart til å lastes ned
|
subject: Arkivet ditt er klart til å lastes ned
|
||||||
title: Nedlasting av arkiv
|
title: Nedlasting av arkiv
|
||||||
failed_2fa:
|
failed_2fa:
|
||||||
|
|
|
@ -39,12 +39,14 @@ nn:
|
||||||
text: Ei åtvaring kan kun ankast ein gong
|
text: Ei åtvaring kan kun ankast ein gong
|
||||||
defaults:
|
defaults:
|
||||||
autofollow: Folk som lagar ein konto gjennom innbydinga fylgjer deg automatisk
|
autofollow: Folk som lagar ein konto gjennom innbydinga fylgjer deg automatisk
|
||||||
|
avatar: WEBP, PNG, GIF eller JPG. Maks %{size}. Blir forminska til %{dimensions}pkt
|
||||||
bot: Denne kontoen utfører i hovedsak automatiserte handlinger og blir kanskje ikke holdt øye med
|
bot: Denne kontoen utfører i hovedsak automatiserte handlinger og blir kanskje ikke holdt øye med
|
||||||
context: En eller flere sammenhenger der filteret skal gjelde
|
context: En eller flere sammenhenger der filteret skal gjelde
|
||||||
current_password: For sikkerhetsgrunner, vennligst oppgi passordet til den nåværende bruker
|
current_password: For sikkerhetsgrunner, vennligst oppgi passordet til den nåværende bruker
|
||||||
current_username: Skriv inn brukarnamnet til den noverande kontoen for å stadfesta
|
current_username: Skriv inn brukarnamnet til den noverande kontoen for å stadfesta
|
||||||
digest: Kun sendt etter en lang periode med inaktivitet og bare dersom du har mottatt noen personlige meldinger mens du var borte
|
digest: Kun sendt etter en lang periode med inaktivitet og bare dersom du har mottatt noen personlige meldinger mens du var borte
|
||||||
email: Du får snart ein stadfestings-e-post
|
email: Du får snart ein stadfestings-e-post
|
||||||
|
header: WEBP, PNG, GIF eller JPG. Maks %{size}. Blir forminska til %{dimensions}pkt
|
||||||
inbox_url: Kopier URLen fra forsiden til overgangen du vil bruke
|
inbox_url: Kopier URLen fra forsiden til overgangen du vil bruke
|
||||||
irreversible: Filtrerte tut vil verta borte for evig, sjølv om filteret vert fjerna seinare
|
irreversible: Filtrerte tut vil verta borte for evig, sjølv om filteret vert fjerna seinare
|
||||||
locale: Språket til brukargrensesnittet, e-postar og push-varsel
|
locale: Språket til brukargrensesnittet, e-postar og push-varsel
|
||||||
|
@ -53,8 +55,8 @@ nn:
|
||||||
scopes: API-ane som programmet vil få tilgjenge til. Ettersom du vel eit toppnivåomfang tarv du ikkje velja einskilde API-ar.
|
scopes: API-ane som programmet vil få tilgjenge til. Ettersom du vel eit toppnivåomfang tarv du ikkje velja einskilde API-ar.
|
||||||
setting_aggregate_reblogs: Ikkje vis nye framhevingar for tut som nyleg har vorte heva fram (Påverkar berre nylege framhevingar)
|
setting_aggregate_reblogs: Ikkje vis nye framhevingar for tut som nyleg har vorte heva fram (Påverkar berre nylege framhevingar)
|
||||||
setting_always_send_emails: Vanlegvis vil ikkje e-postvarsel bli sendt når du brukar Mastodon aktivt
|
setting_always_send_emails: Vanlegvis vil ikkje e-postvarsel bli sendt når du brukar Mastodon aktivt
|
||||||
setting_default_sensitive: Nærtakande media vert gøymd som standard og kan synast med eit klikk
|
setting_default_sensitive: Sensitive media vert gøymde som standard, og du syner dei ved å klikka på dei
|
||||||
setting_display_media_default: Gøym media som er merka som nærtakande
|
setting_display_media_default: Gøym media som er merka som sensitive
|
||||||
setting_display_media_hide_all: Alltid skjul alt media
|
setting_display_media_hide_all: Alltid skjul alt media
|
||||||
setting_display_media_show_all: Vis alltid media
|
setting_display_media_show_all: Vis alltid media
|
||||||
setting_use_blurhash: Overgangar er basert på fargane til skjulte grafikkelement, men gjer detaljar utydelege
|
setting_use_blurhash: Overgangar er basert på fargane til skjulte grafikkelement, men gjer detaljar utydelege
|
||||||
|
@ -218,7 +220,7 @@ nn:
|
||||||
setting_theme: Sidetema
|
setting_theme: Sidetema
|
||||||
setting_trends: Vis kva som er populært i dag
|
setting_trends: Vis kva som er populært i dag
|
||||||
setting_unfollow_modal: Vis stadfesting før du sluttar å fylgja nokon
|
setting_unfollow_modal: Vis stadfesting før du sluttar å fylgja nokon
|
||||||
setting_use_blurhash: Vis fargerike overgangar for gøymt media
|
setting_use_blurhash: Vis fargerike overgangar for gøymde medium
|
||||||
setting_use_pending_items: Saktemodus
|
setting_use_pending_items: Saktemodus
|
||||||
severity: Alvorsgrad
|
severity: Alvorsgrad
|
||||||
sign_in_token_attempt: Trygdenykel
|
sign_in_token_attempt: Trygdenykel
|
||||||
|
@ -233,8 +235,8 @@ nn:
|
||||||
name: Emneknagg
|
name: Emneknagg
|
||||||
filters:
|
filters:
|
||||||
actions:
|
actions:
|
||||||
hide: Gøym totalt
|
hide: Gøym heilt
|
||||||
warn: Gøym med ei advarsel
|
warn: Gøym med ei åtvaring
|
||||||
form_admin_settings:
|
form_admin_settings:
|
||||||
activity_api_enabled: Legg ut samla statistikk om brukaraktiviteten i APIet
|
activity_api_enabled: Legg ut samla statistikk om brukaraktiviteten i APIet
|
||||||
backups_retention_period: Arkiveringsperiode for brukararkiv
|
backups_retention_period: Arkiveringsperiode for brukararkiv
|
||||||
|
@ -264,9 +266,9 @@ nn:
|
||||||
trends: Aktiver trendar
|
trends: Aktiver trendar
|
||||||
trends_as_landing_page: Bruk trendar som startside
|
trends_as_landing_page: Bruk trendar som startside
|
||||||
interactions:
|
interactions:
|
||||||
must_be_follower: Gøym varslingar frå folk som ikkje fylgjer deg
|
must_be_follower: Blokker varsel frå folk som ikkje fylgjer deg
|
||||||
must_be_following: Gøym varslingar frå folk du ikkje fylgjer
|
must_be_following: Blokker varsel frå folk du ikkje fylgjer
|
||||||
must_be_following_dm: Gøym direktemeldinger frå folk du ikkje fylgjer
|
must_be_following_dm: Blokker direktemeldinger frå folk du ikkje fylgjer
|
||||||
invite:
|
invite:
|
||||||
comment: Kommentar
|
comment: Kommentar
|
||||||
invite_request:
|
invite_request:
|
||||||
|
|
|
@ -14,17 +14,6 @@ describe CustomCssController do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns public cache control header' do
|
it_behaves_like 'cacheable response'
|
||||||
expect(response.headers['Cache-Control']).to include('public')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not set cookies' do
|
|
||||||
expect(response.cookies).to be_empty
|
|
||||||
expect(response.headers['Set-Cookies']).to be_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not set sessions' do
|
|
||||||
expect(session).to be_empty
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,23 +12,18 @@ RSpec.describe InstanceActorsController do
|
||||||
get :show, params: { format: format }
|
get :show, params: { format: format }
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http success with correct media type, headers, and session values' do
|
it 'returns http success with correct media type and body' do
|
||||||
expect(response)
|
expect(response)
|
||||||
.to have_http_status(200)
|
.to have_http_status(200)
|
||||||
.and have_attributes(
|
.and have_attributes(
|
||||||
media_type: eq('application/activity+json'),
|
media_type: eq('application/activity+json')
|
||||||
cookies: be_empty
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(response.headers)
|
|
||||||
.to include('Cache-Control' => include('public'))
|
|
||||||
.and not_include('Set-Cookies')
|
|
||||||
|
|
||||||
expect(session).to be_empty
|
|
||||||
|
|
||||||
expect(body_as_json)
|
expect(body_as_json)
|
||||||
.to include(:id, :type, :preferredUsername, :inbox, :publicKey, :inbox, :outbox, :url)
|
.to include(:id, :type, :preferredUsername, :inbox, :publicKey, :inbox, :outbox, :url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'cacheable response'
|
||||||
end
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
|
|
@ -14,17 +14,6 @@ describe ManifestsController do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns public cache control header' do
|
it_behaves_like 'cacheable response'
|
||||||
expect(response.headers['Cache-Control']).to include('public')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not set cookies' do
|
|
||||||
expect(response.cookies).to be_empty
|
|
||||||
expect(response.headers['Set-Cookies']).to be_nil
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not set sessions' do
|
|
||||||
expect(session).to be_empty
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,13 +20,7 @@ RSpec.describe TagsController do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns Vary header' do
|
it_behaves_like 'cacheable response', expects_vary: 'Accept, Accept-Language, Cookie'
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns public Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'public'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when requested as JSON' do
|
context 'when requested as JSON' do
|
||||||
|
@ -36,13 +30,7 @@ RSpec.describe TagsController do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns Vary header' do
|
it_behaves_like 'cacheable response', expects_vary: 'Accept, Accept-Language, Cookie'
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns public Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'public'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
shared_examples 'cacheable response' do |expects_vary: false|
|
shared_examples 'cacheable response' do |expects_vary: false|
|
||||||
it 'sets correct cache and vary headers and does not set cookies or session' do
|
it 'sets correct cache and vary headers and does not set cookies or session', :aggregate_failures do
|
||||||
expect(response.cookies).to be_empty
|
expect(response.cookies).to be_empty
|
||||||
expect(response.headers['Set-Cookies']).to be_nil
|
expect(response.headers['Set-Cookies']).to be_nil
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,12 @@ module ProfileStories
|
||||||
click_on I18n.t('auth.login')
|
click_on I18n.t('auth.login')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def as_a_logged_in_admin
|
||||||
|
# This is a bit awkward, but this avoids code duplication.
|
||||||
|
as_a_logged_in_user
|
||||||
|
bob.update!(role: UserRole.find_by!(name: 'Admin'))
|
||||||
|
end
|
||||||
|
|
||||||
def with_alice_as_local_user
|
def with_alice_as_local_user
|
||||||
@alice_bio = '@alice and @bob are fictional characters commonly used as' \
|
@alice_bio = '@alice and @bob are fictional characters commonly used as' \
|
||||||
'placeholder names in #cryptology, as well as #science and' \
|
'placeholder names in #cryptology, as well as #science and' \
|
||||||
|
|
|
@ -109,6 +109,9 @@ RSpec.configure do |config|
|
||||||
# Also needs to be set per-example here because of the database cleaner.
|
# Also needs to be set per-example here because of the database cleaner.
|
||||||
Setting.registrations_mode = 'open'
|
Setting.registrations_mode = 'open'
|
||||||
|
|
||||||
|
# Load seeds so we have the default roles otherwise cleared by `DatabaseCleaner`
|
||||||
|
Rails.application.load_seed
|
||||||
|
|
||||||
example.run
|
example.run
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe 'report interface', :paperclip_processing do
|
||||||
|
include ProfileStories
|
||||||
|
|
||||||
|
let(:email) { 'admin@example.com' }
|
||||||
|
let(:password) { 'password' }
|
||||||
|
let(:confirmed_at) { Time.zone.now }
|
||||||
|
let(:finished_onboarding) { true }
|
||||||
|
|
||||||
|
let(:reported_account) { Fabricate(:account) }
|
||||||
|
let(:reported_status) { Fabricate(:status, account: reported_account) }
|
||||||
|
let(:media_attachment) { Fabricate(:media_attachment, account: reported_account, status: reported_status, file: attachment_fixture('attachment.jpg')) }
|
||||||
|
let!(:report) { Fabricate(:report, target_account: reported_account, status_ids: [media_attachment.status.id]) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
as_a_logged_in_admin
|
||||||
|
visit admin_report_path(report)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'displays the report interface, including the javascript bits' do
|
||||||
|
# The report category selector React component is properly rendered
|
||||||
|
expect(page).to have_css('.report-reason-selector')
|
||||||
|
|
||||||
|
# The media React component is properly rendered
|
||||||
|
page.scroll_to(page.find('.batch-table__row'))
|
||||||
|
expect(page).to have_css('.spoiler-button__overlay__label')
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,4 +1,8 @@
|
||||||
|
/* eslint-disable import/no-commonjs */
|
||||||
|
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
|
// @ts-ignore - This needs to be a CJS file (eslint does not yet support ESM configs), and TS is complaining we use require
|
||||||
const { defineConfig } = require('eslint-define-config');
|
const { defineConfig } = require('eslint-define-config');
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
|
@ -22,22 +26,18 @@ module.exports = defineConfig({
|
||||||
// to maintain.
|
// to maintain.
|
||||||
'no-delete-var': 'off',
|
'no-delete-var': 'off',
|
||||||
|
|
||||||
// The streaming server is written in commonjs, not ESM for now:
|
|
||||||
'import/no-commonjs': 'off',
|
|
||||||
|
|
||||||
// This overrides the base configuration for this rule to pick up
|
// This overrides the base configuration for this rule to pick up
|
||||||
// dependencies for the streaming server from the correct package.json file.
|
// dependencies for the streaming server from the correct package.json file.
|
||||||
'import/no-extraneous-dependencies': [
|
'import/no-extraneous-dependencies': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
devDependencies: [
|
devDependencies: ['streaming/.eslintrc.cjs'],
|
||||||
'streaming/.eslintrc.js',
|
|
||||||
],
|
|
||||||
optionalDependencies: false,
|
optionalDependencies: false,
|
||||||
peerDependencies: false,
|
peerDependencies: false,
|
||||||
includeTypes: true,
|
includeTypes: true,
|
||||||
packageDir: __dirname,
|
packageDir: __dirname,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'import/extensions': ['error', 'always'],
|
||||||
},
|
},
|
||||||
});
|
});
|
|
@ -5,15 +5,14 @@
|
||||||
* override it in let statements.
|
* override it in let statements.
|
||||||
* @type {string}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
const UNEXPECTED_ERROR_MESSAGE = 'An unexpected error occurred';
|
export const UNEXPECTED_ERROR_MESSAGE = 'An unexpected error occurred';
|
||||||
exports.UNKNOWN_ERROR_MESSAGE = UNEXPECTED_ERROR_MESSAGE;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the status and message properties from the error object, if
|
* Extracts the status and message properties from the error object, if
|
||||||
* available for public use. The `unknown` is for catch statements
|
* available for public use. The `unknown` is for catch statements
|
||||||
* @param {Error | AuthenticationError | RequestError | unknown} err
|
* @param {Error | AuthenticationError | RequestError | unknown} err
|
||||||
*/
|
*/
|
||||||
exports.extractStatusAndMessage = function(err) {
|
export function extractStatusAndMessage(err) {
|
||||||
let statusCode = 500;
|
let statusCode = 500;
|
||||||
let errorMessage = UNEXPECTED_ERROR_MESSAGE;
|
let errorMessage = UNEXPECTED_ERROR_MESSAGE;
|
||||||
if (err instanceof AuthenticationError || err instanceof RequestError) {
|
if (err instanceof AuthenticationError || err instanceof RequestError) {
|
||||||
|
@ -22,9 +21,9 @@ exports.extractStatusAndMessage = function(err) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return { statusCode, errorMessage };
|
return { statusCode, errorMessage };
|
||||||
};
|
}
|
||||||
|
|
||||||
class RequestError extends Error {
|
export class RequestError extends Error {
|
||||||
/**
|
/**
|
||||||
* @param {string} message
|
* @param {string} message
|
||||||
*/
|
*/
|
||||||
|
@ -35,9 +34,7 @@ class RequestError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.RequestError = RequestError;
|
export class AuthenticationError extends Error {
|
||||||
|
|
||||||
class AuthenticationError extends Error {
|
|
||||||
/**
|
/**
|
||||||
* @param {string} message
|
* @param {string} message
|
||||||
*/
|
*/
|
||||||
|
@ -47,5 +44,3 @@ class AuthenticationError extends Error {
|
||||||
this.status = 401;
|
this.status = 401;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.AuthenticationError = AuthenticationError;
|
|
||||||
|
|
|
@ -1,32 +1,36 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const fs = require('fs');
|
import fs from 'node:fs';
|
||||||
const http = require('http');
|
import http from 'node:http';
|
||||||
const path = require('path');
|
import path from 'node:path';
|
||||||
const url = require('url');
|
import url from 'node:url';
|
||||||
|
|
||||||
const cors = require('cors');
|
import cors from 'cors';
|
||||||
const dotenv = require('dotenv');
|
import dotenv from 'dotenv';
|
||||||
const express = require('express');
|
import express from 'express';
|
||||||
const { Redis } = require('ioredis');
|
import { Redis } from 'ioredis';
|
||||||
const { JSDOM } = require('jsdom');
|
import { JSDOM } from 'jsdom';
|
||||||
const pg = require('pg');
|
import pg from 'pg';
|
||||||
const dbUrlToConfig = require('pg-connection-string').parse;
|
import pgConnectionString from 'pg-connection-string';
|
||||||
const WebSocket = require('ws');
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
const errors = require('./errors');
|
import { AuthenticationError, RequestError, extractStatusAndMessage as extractErrorStatusAndMessage } from './errors.js';
|
||||||
const { AuthenticationError, RequestError } = require('./errors');
|
import { logger, httpLogger, initializeLogLevel, attachWebsocketHttpLogger, createWebsocketLogger } from './logging.js';
|
||||||
const { logger, httpLogger, initializeLogLevel, attachWebsocketHttpLogger, createWebsocketLogger } = require('./logging');
|
import { setupMetrics } from './metrics.js';
|
||||||
const { setupMetrics } = require('./metrics');
|
import { isTruthy, normalizeHashtag, firstParam } from './utils.js';
|
||||||
const { isTruthy, normalizeHashtag, firstParam } = require("./utils");
|
|
||||||
|
|
||||||
const environment = process.env.NODE_ENV || 'development';
|
const environment = process.env.NODE_ENV || 'development';
|
||||||
|
|
||||||
// Correctly detect and load .env or .env.production file based on environment:
|
// Correctly detect and load .env or .env.production file based on environment:
|
||||||
const dotenvFile = environment === 'production' ? '.env.production' : '.env';
|
const dotenvFile = environment === 'production' ? '.env.production' : '.env';
|
||||||
|
const dotenvFilePath = path.resolve(
|
||||||
|
url.fileURLToPath(
|
||||||
|
new URL(path.join('..', dotenvFile), import.meta.url)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
path: path.resolve(__dirname, path.join('..', dotenvFile))
|
path: dotenvFilePath
|
||||||
});
|
});
|
||||||
|
|
||||||
initializeLogLevel(process.env, environment);
|
initializeLogLevel(process.env, environment);
|
||||||
|
@ -143,7 +147,7 @@ const pgConfigFromEnv = (env) => {
|
||||||
let baseConfig = {};
|
let baseConfig = {};
|
||||||
|
|
||||||
if (env.DATABASE_URL) {
|
if (env.DATABASE_URL) {
|
||||||
const parsedUrl = dbUrlToConfig(env.DATABASE_URL);
|
const parsedUrl = pgConnectionString.parse(env.DATABASE_URL);
|
||||||
|
|
||||||
// The result of dbUrlToConfig from pg-connection-string is not type
|
// The result of dbUrlToConfig from pg-connection-string is not type
|
||||||
// compatible with pg.PoolConfig, since parts of the connection URL may be
|
// compatible with pg.PoolConfig, since parts of the connection URL may be
|
||||||
|
@ -326,7 +330,7 @@ const startServer = async () => {
|
||||||
// Unfortunately for using the on('upgrade') setup, we need to manually
|
// Unfortunately for using the on('upgrade') setup, we need to manually
|
||||||
// write a HTTP Response to the Socket to close the connection upgrade
|
// write a HTTP Response to the Socket to close the connection upgrade
|
||||||
// attempt, so the following code is to handle all of that.
|
// attempt, so the following code is to handle all of that.
|
||||||
const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
|
const {statusCode, errorMessage } = extractErrorStatusAndMessage(err);
|
||||||
|
|
||||||
/** @type {Record<string, string | number | import('pino-http').ReqId>} */
|
/** @type {Record<string, string | number | import('pino-http').ReqId>} */
|
||||||
const headers = {
|
const headers = {
|
||||||
|
@ -748,7 +752,7 @@ const startServer = async () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
|
const {statusCode, errorMessage } = extractErrorStatusAndMessage(err);
|
||||||
|
|
||||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: errorMessage }));
|
res.end(JSON.stringify({ error: errorMessage }));
|
||||||
|
@ -1162,7 +1166,7 @@ const startServer = async () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
streamFrom(channelIds, req, req.log, onSend, onEnd, 'eventsource', options.needsFiltering, options.allowLocalOnly);
|
streamFrom(channelIds, req, req.log, onSend, onEnd, 'eventsource', options.needsFiltering, options.allowLocalOnly);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
|
const {statusCode, errorMessage } = extractErrorStatusAndMessage(err);
|
||||||
|
|
||||||
res.log.info({ err }, 'Eventsource subscription error');
|
res.log.info({ err }, 'Eventsource subscription error');
|
||||||
|
|
||||||
|
@ -1374,7 +1378,7 @@ const startServer = async () => {
|
||||||
stopHeartbeat,
|
stopHeartbeat,
|
||||||
};
|
};
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
|
const {statusCode, errorMessage } = extractErrorStatusAndMessage(err);
|
||||||
|
|
||||||
logger.error({ err }, 'Websocket subscription error');
|
logger.error({ err }, 'Websocket subscription error');
|
||||||
|
|
||||||
|
@ -1503,13 +1507,15 @@ const startServer = async () => {
|
||||||
// Decrement the metrics for connected clients:
|
// Decrement the metrics for connected clients:
|
||||||
connectedClients.labels({ type: 'websocket' }).dec();
|
connectedClients.labels({ type: 'websocket' }).dec();
|
||||||
|
|
||||||
// We need to delete the session object as to ensure it correctly gets
|
// We need to unassign the session object as to ensure it correctly gets
|
||||||
// garbage collected, without doing this we could accidentally hold on to
|
// garbage collected, without doing this we could accidentally hold on to
|
||||||
// references to the websocket, the request, and the logger, causing
|
// references to the websocket, the request, and the logger, causing
|
||||||
// memory leaks.
|
// memory leaks.
|
||||||
//
|
|
||||||
// @ts-ignore
|
// This is commented out because `delete` only operated on object properties
|
||||||
delete session;
|
// It needs to be replaced by `session = undefined`, but it requires every calls to
|
||||||
|
// `session` to check for it, thus a significant refactor
|
||||||
|
// delete session;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: immediately after the `error` event is emitted, the `close` event
|
// Note: immediately after the `error` event is emitted, the `close` event
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const { pino } = require('pino');
|
import { pino } from 'pino';
|
||||||
const { pinoHttp, stdSerializers: pinoHttpSerializers } = require('pino-http');
|
import { pinoHttp, stdSerializers as pinoHttpSerializers } from 'pino-http';
|
||||||
const uuid = require('uuid');
|
import * as uuid from 'uuid';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the Request ID for logging and setting on responses
|
* Generates the Request ID for logging and setting on responses
|
||||||
|
@ -36,7 +36,7 @@ function sanitizeRequestLog(req) {
|
||||||
return log;
|
return log;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = pino({
|
export const logger = pino({
|
||||||
name: "streaming",
|
name: "streaming",
|
||||||
// Reformat the log level to a string:
|
// Reformat the log level to a string:
|
||||||
formatters: {
|
formatters: {
|
||||||
|
@ -59,7 +59,7 @@ const logger = pino({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const httpLogger = pinoHttp({
|
export const httpLogger = pinoHttp({
|
||||||
logger,
|
logger,
|
||||||
genReqId: generateRequestId,
|
genReqId: generateRequestId,
|
||||||
serializers: {
|
serializers: {
|
||||||
|
@ -71,7 +71,7 @@ const httpLogger = pinoHttp({
|
||||||
* Attaches a logger to the request object received by http upgrade handlers
|
* Attaches a logger to the request object received by http upgrade handlers
|
||||||
* @param {http.IncomingMessage} request
|
* @param {http.IncomingMessage} request
|
||||||
*/
|
*/
|
||||||
function attachWebsocketHttpLogger(request) {
|
export function attachWebsocketHttpLogger(request) {
|
||||||
generateRequestId(request);
|
generateRequestId(request);
|
||||||
|
|
||||||
request.log = logger.child({
|
request.log = logger.child({
|
||||||
|
@ -84,7 +84,7 @@ function attachWebsocketHttpLogger(request) {
|
||||||
* @param {http.IncomingMessage} request
|
* @param {http.IncomingMessage} request
|
||||||
* @param {import('./index.js').ResolvedAccount} resolvedAccount
|
* @param {import('./index.js').ResolvedAccount} resolvedAccount
|
||||||
*/
|
*/
|
||||||
function createWebsocketLogger(request, resolvedAccount) {
|
export function createWebsocketLogger(request, resolvedAccount) {
|
||||||
// ensure the request.id is always present.
|
// ensure the request.id is always present.
|
||||||
generateRequestId(request);
|
generateRequestId(request);
|
||||||
|
|
||||||
|
@ -98,17 +98,12 @@ function createWebsocketLogger(request, resolvedAccount) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.logger = logger;
|
|
||||||
exports.httpLogger = httpLogger;
|
|
||||||
exports.attachWebsocketHttpLogger = attachWebsocketHttpLogger;
|
|
||||||
exports.createWebsocketLogger = createWebsocketLogger;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the log level based on the environment
|
* Initializes the log level based on the environment
|
||||||
* @param {Object<string, any>} env
|
* @param {Object<string, any>} env
|
||||||
* @param {string} environment
|
* @param {string} environment
|
||||||
*/
|
*/
|
||||||
exports.initializeLogLevel = function initializeLogLevel(env, environment) {
|
export function initializeLogLevel(env, environment) {
|
||||||
if (env.LOG_LEVEL && Object.keys(logger.levels.values).includes(env.LOG_LEVEL)) {
|
if (env.LOG_LEVEL && Object.keys(logger.levels.values).includes(env.LOG_LEVEL)) {
|
||||||
logger.level = env.LOG_LEVEL;
|
logger.level = env.LOG_LEVEL;
|
||||||
} else if (environment === 'development') {
|
} else if (environment === 'development') {
|
||||||
|
@ -116,4 +111,4 @@ exports.initializeLogLevel = function initializeLogLevel(env, environment) {
|
||||||
} else {
|
} else {
|
||||||
logger.level = 'info';
|
logger.level = 'info';
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const metrics = require('prom-client');
|
import metrics from 'prom-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef StreamingMetrics
|
* @typedef StreamingMetrics
|
||||||
|
@ -18,7 +18,7 @@ const metrics = require('prom-client');
|
||||||
* @param {import('pg').Pool} pgPool
|
* @param {import('pg').Pool} pgPool
|
||||||
* @returns {StreamingMetrics}
|
* @returns {StreamingMetrics}
|
||||||
*/
|
*/
|
||||||
function setupMetrics(channels, pgPool) {
|
export function setupMetrics(channels, pgPool) {
|
||||||
// Collect metrics from Node.js
|
// Collect metrics from Node.js
|
||||||
metrics.collectDefaultMetrics();
|
metrics.collectDefaultMetrics();
|
||||||
|
|
||||||
|
@ -101,5 +101,3 @@ function setupMetrics(channels, pgPool) {
|
||||||
messagesSent,
|
messagesSent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.setupMetrics = setupMetrics;
|
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
},
|
},
|
||||||
"description": "Mastodon's Streaming Server",
|
"description": "Mastodon's Streaming Server",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/mastodon/mastodon.git"
|
"url": "https://github.com/mastodon/mastodon.git"
|
||||||
|
|
|
@ -2,11 +2,11 @@
|
||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"module": "CommonJS",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "NodeNext",
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"tsBuildInfoFile": "../tmp/cache/streaming/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "../tmp/cache/streaming/tsconfig.tsbuildinfo",
|
||||||
"paths": {},
|
"paths": {},
|
||||||
},
|
},
|
||||||
"include": ["./*.js", "./.eslintrc.js"],
|
"include": ["./*.js", "./.eslintrc.cjs"],
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,11 +16,9 @@ const FALSE_VALUES = [
|
||||||
* @param {any} value
|
* @param {any} value
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
const isTruthy = value =>
|
export function isTruthy(value) {
|
||||||
value && !FALSE_VALUES.includes(value);
|
return value && !FALSE_VALUES.includes(value);
|
||||||
|
}
|
||||||
exports.isTruthy = isTruthy;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See app/lib/ascii_folder.rb for the canon definitions
|
* See app/lib/ascii_folder.rb for the canon definitions
|
||||||
|
@ -33,7 +31,7 @@ const EQUIVALENT_ASCII_CHARS = 'AAAAAAaaaaaaAaAaAaCcCcCcCcCcDdDdDdEEEEeeeeEeEeEe
|
||||||
* @param {string} str
|
* @param {string} str
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function foldToASCII(str) {
|
export function foldToASCII(str) {
|
||||||
const regex = new RegExp(NON_ASCII_CHARS.split('').join('|'), 'g');
|
const regex = new RegExp(NON_ASCII_CHARS.split('').join('|'), 'g');
|
||||||
|
|
||||||
return str.replace(regex, function(match) {
|
return str.replace(regex, function(match) {
|
||||||
|
@ -42,28 +40,22 @@ function foldToASCII(str) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.foldToASCII = foldToASCII;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} str
|
* @param {string} str
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function normalizeHashtag(str) {
|
export function normalizeHashtag(str) {
|
||||||
return foldToASCII(str.normalize('NFKC').toLowerCase()).replace(/[^\p{L}\p{N}_\u00b7\u200c]/gu, '');
|
return foldToASCII(str.normalize('NFKC').toLowerCase()).replace(/[^\p{L}\p{N}_\u00b7\u200c]/gu, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.normalizeHashtag = normalizeHashtag;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string|string[]} arrayOrString
|
* @param {string|string[]} arrayOrString
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function firstParam(arrayOrString) {
|
export function firstParam(arrayOrString) {
|
||||||
if (Array.isArray(arrayOrString)) {
|
if (Array.isArray(arrayOrString)) {
|
||||||
return arrayOrString[0];
|
return arrayOrString[0];
|
||||||
} else {
|
} else {
|
||||||
return arrayOrString;
|
return arrayOrString;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.firstParam = firstParam;
|
|
||||||
|
|
Loading…
Reference in New Issue