Merge commit '51b83ed19536b06ce3f57b260400ecec2d1dd187' into glitch-soc/merge-upstream

pull/2216/head
Claire 2023-05-09 23:37:38 +02:00
commit facc7ab03c
31 changed files with 407 additions and 245 deletions

View File

@ -9,6 +9,7 @@ module.exports = {
'plugin:import/recommended',
'plugin:promise/recommended',
'plugin:jsdoc/recommended',
'plugin:prettier/recommended',
],
env: {
@ -62,20 +63,9 @@ module.exports = {
},
rules: {
'brace-style': 'warn',
'comma-dangle': ['error', 'always-multiline'],
'comma-spacing': [
'warn',
{
before: false,
after: true,
},
],
'comma-style': ['warn', 'last'],
'consistent-return': 'error',
'dot-notation': 'error',
eqeqeq: ['error', 'always', { 'null': 'ignore' }],
indent: ['warn', 2],
'jsx-quotes': ['error', 'prefer-single'],
'no-case-declarations': 'off',
'no-catch-shadow': 'error',
@ -95,7 +85,6 @@ module.exports = {
{ property: 'substr', message: 'Use .slice instead of .substr.' },
],
'no-self-assign': 'off',
'no-trailing-spaces': 'warn',
'no-unused-expressions': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
@ -107,29 +96,14 @@ module.exports = {
ignoreRestSiblings: true,
},
],
'object-curly-spacing': ['error', 'always'],
'padded-blocks': [
'error',
{
classes: 'always',
},
],
quotes: ['error', 'single'],
semi: 'error',
'valid-typeof': 'error',
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', 'tsx'] }],
'react/jsx-boolean-value': 'error',
'react/jsx-closing-bracket-location': ['error', 'line-aligned'],
'react/jsx-curly-spacing': 'error',
'react/display-name': 'off',
'react/jsx-equals-spacing': 'error',
'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'],
'react/jsx-indent': ['error', 2],
'react/jsx-no-bind': 'error',
'react/jsx-no-target-blank': 'off',
'react/jsx-tag-spacing': 'error',
'react/jsx-wrap-multilines': 'error',
'react/no-deprecated': 'off',
'react/no-unknown-property': 'off',
'react/self-closing-comp': 'error',
@ -291,6 +265,7 @@ module.exports = {
'plugin:import/typescript',
'plugin:promise/recommended',
'plugin:jsdoc/recommended',
'plugin:prettier/recommended',
],
rules: {

View File

@ -70,8 +70,6 @@ app/javascript/styles/mastodon/reset.scss
# Ignore Javascript pending https://github.com/mastodon/mastodon/pull/23631
*.js
*.jsx
*.ts
*.tsx
# Ignore HTML till cleaned and included in CI
*.html

View File

@ -1,3 +1,4 @@
module.exports = {
singleQuote: true
singleQuote: true,
jsxSingleQuote: true
}

View File

@ -98,9 +98,9 @@ export const decode83 = (str: string) => {
};
export const intToRGB = (int: number) => ({
r: Math.max(0, (int >> 16)),
r: Math.max(0, int >> 16),
g: Math.max(0, (int >> 8) & 255),
b: Math.max(0, (int & 255)),
b: Math.max(0, int & 255),
});
export const getAverageFromBlurhash = (blurhash: string) => {

View File

@ -1,4 +1,4 @@
export function compareId (id1: string, id2: string) {
export function compareId(id1: string, id2: string) {
if (id1 === id2) {
return 0;
}

View File

@ -16,13 +16,10 @@ const obfuscatedCount = (count: number) => {
type Props = {
value: number;
obfuscate?: boolean;
}
export const AnimatedNumber: React.FC<Props> = ({
value,
obfuscate,
})=> {
};
export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
const [previousValue, setPreviousValue] = useState(value);
const [direction, setDirection] = useState<1|-1>(1);
const [direction, setDirection] = useState<1 | -1>(1);
if (previousValue !== value) {
setPreviousValue(value);
@ -30,24 +27,45 @@ export const AnimatedNumber: React.FC<Props> = ({
}
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
const willLeave = useCallback(() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }), [direction]);
const willLeave = useCallback(
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
[direction]
);
if (reduceMotion) {
return obfuscate ? <>{obfuscatedCount(value)}</> : <ShortNumber value={value} />;
return obfuscate ? (
<>{obfuscatedCount(value)}</>
) : (
<ShortNumber value={value} />
);
}
const styles = [{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
}];
const styles = [
{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
},
];
return (
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
{items => (
<TransitionMotion
styles={styles}
willEnter={willEnter}
willLeave={willLeave}
>
{(items) => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
<span
key={key}
style={{
position: direction * style.y > 0 ? 'absolute' : 'static',
transform: `translateY(${style.y * 100}%)`,
}}
>
{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}
</span>
))}
</span>
)}

View File

@ -18,13 +18,19 @@ export const AvatarOverlay: React.FC<Props> = ({
baseSize = 36,
overlaySize = 24,
}) => {
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(autoPlayGif);
const accountSrc = hovering ? account?.get('avatar') : account?.get('avatar_static');
const friendSrc = hovering ? friend?.get('avatar') : friend?.get('avatar_static');
const { hovering, handleMouseEnter, handleMouseLeave } =
useHovering(autoPlayGif);
const accountSrc = hovering
? account?.get('avatar')
: account?.get('avatar_static');
const friendSrc = hovering
? friend?.get('avatar')
: friend?.get('avatar_static');
return (
<div
className='account__avatar-overlay' style={{ width: size, height: size }}
className='account__avatar-overlay'
style={{ width: size, height: size }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>

View File

@ -8,7 +8,7 @@ type Props = {
dummy?: boolean; // Whether dummy mode is enabled. If enabled, nothing is rendered and canvas left untouched
children?: never;
[key: string]: any;
}
};
const Blurhash: React.FC<Props> = ({
hash,
width = 32,

View File

@ -1,7 +1,15 @@
import React from 'react';
export const Check: React.FC = () => (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor'>
<path fillRule='evenodd' d='M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z' clipRule='evenodd' />
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 20 20'
fill='currentColor'
>
<path
fillRule='evenodd'
d='M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'
clipRule='evenodd'
/>
</svg>
);

View File

@ -8,7 +8,7 @@ type Props = {
width: number;
height: number;
onClick?: () => void;
}
};
export const GIFV: React.FC<Props> = ({
src,
@ -17,19 +17,23 @@ export const GIFV: React.FC<Props> = ({
width,
height,
onClick,
})=> {
}) => {
const [loading, setLoading] = useState(true);
const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> = useCallback(() => {
setLoading(false);
}, [setLoading]);
const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> =
useCallback(() => {
setLoading(false);
}, [setLoading]);
const handleClick: React.MouseEventHandler = useCallback((e) => {
if (onClick) {
e.stopPropagation();
onClick();
}
}, [onClick]);
const handleClick: React.MouseEventHandler = useCallback(
(e) => {
if (onClick) {
e.stopPropagation();
onClick();
}
},
[onClick]
);
return (
<div className='gifv' style={{ position: 'relative' }}>

View File

@ -7,6 +7,15 @@ type Props = {
fixedWidth?: boolean;
children?: never;
[key: string]: any;
}
export const Icon: React.FC<Props> = ({ id, className, fixedWidth, ...other }) =>
<i className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })} {...other} />;
};
export const Icon: React.FC<Props> = ({
id,
className,
fixedWidth,
...other
}) => (
<i
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
{...other}
/>
);

View File

@ -25,13 +25,12 @@ type Props = {
obfuscateCount?: boolean;
href?: string;
ariaHidden: boolean;
}
};
type States = {
activate: boolean,
deactivate: boolean,
}
activate: boolean;
deactivate: boolean;
};
export class IconButton extends React.PureComponent<Props, States> {
static defaultProps = {
size: 18,
active: false,
@ -47,7 +46,7 @@ export class IconButton extends React.PureComponent<Props, States> {
deactivate: false,
};
UNSAFE_componentWillReceiveProps (nextProps: Props) {
UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (!nextProps.animate) return;
if (this.props.active && !nextProps.active) {
@ -57,7 +56,7 @@ export class IconButton extends React.PureComponent<Props, States> {
}
}
handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
if (!this.props.disabled && this.props.onClick != null) {
@ -83,7 +82,7 @@ export class IconButton extends React.PureComponent<Props, States> {
}
};
render () {
render() {
const style = {
fontSize: `${this.props.size}px`,
width: `${this.props.size * 1.28571429}px`,
@ -109,10 +108,7 @@ export class IconButton extends React.PureComponent<Props, States> {
ariaHidden,
} = this.props;
const {
activate,
deactivate,
} = this.state;
const { activate, deactivate } = this.state;
const classes = classNames(className, 'icon-button', {
active,
@ -130,7 +126,12 @@ export class IconButton extends React.PureComponent<Props, States> {
let contents = (
<React.Fragment>
<Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
<Icon id={icon} fixedWidth aria-hidden='true' />{' '}
{typeof counter !== 'undefined' && (
<span className='icon-button__counter'>
<AnimatedNumber value={counter} obfuscate={obfuscateCount} />
</span>
)}
</React.Fragment>
);
@ -162,5 +163,4 @@ export class IconButton extends React.PureComponent<Props, States> {
</button>
);
}
}

View File

@ -1,18 +1,25 @@
import React from 'react';
import { Icon } from './icon';
const formatNumber = (num: number): number | string => num > 40 ? '40+' : num;
const formatNumber = (num: number): number | string => (num > 40 ? '40+' : num);
type Props = {
id: string;
count: number;
issueBadge: boolean;
className: string;
}
export const IconWithBadge: React.FC<Props> = ({ id, count, issueBadge, className }) => (
};
export const IconWithBadge: React.FC<Props> = ({
id,
count,
issueBadge,
className,
}) => (
<i className='icon-with-badge'>
<Icon id={id} fixedWidth className={className} />
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
{count > 0 && (
<i className='icon-with-badge__badge'>{formatNumber(count)}</i>
)}
{issueBadge && <i className='icon-with-badge__issue-badge' />}
</i>
);

View File

@ -7,9 +7,14 @@ type Props = {
srcSet?: string;
blurhash?: string;
className?: string;
}
};
export const Image: React.FC<Props> = ({ src, srcSet, blurhash, className }) => {
export const Image: React.FC<Props> = ({
src,
srcSet,
blurhash,
className,
}) => {
const [loaded, setLoaded] = useState(false);
const handleLoad = useCallback(() => {
@ -17,7 +22,10 @@ export const Image: React.FC<Props> = ({ src, srcSet, blurhash, className }) =>
}, [setLoaded]);
return (
<div className={classNames('image', { loaded }, className)} role='presentation'>
<div
className={classNames('image', { loaded }, className)}
role='presentation'
>
{blurhash && <Blurhash hash={blurhash} className='image__preview' />}
<img src={src} srcSet={srcSet} alt='' onLoad={handleLoad} />
</div>

View File

@ -4,7 +4,10 @@ import { FormattedMessage } from 'react-intl';
export const NotSignedInIndicator: React.FC = () => (
<div className='scrollable scrollable--flex'>
<div className='empty-column-indicator'>
<FormattedMessage id='not_signed_in_indicator.not_signed_in' defaultMessage='You need to sign in to access this resource.' />
<FormattedMessage
id='not_signed_in_indicator.not_signed_in'
defaultMessage='You need to sign in to access this resource.'
/>
</div>
</div>
);

View File

@ -9,7 +9,13 @@ type Props = {
label: React.ReactNode;
};
export const RadioButton: React.FC<Props> = ({ name, value, checked, onChange, label }) => {
export const RadioButton: React.FC<Props> = ({
name,
value,
checked,
onChange,
label,
}) => {
return (
<label className='radio-button'>
<input

View File

@ -4,20 +4,50 @@ import { injectIntl, defineMessages, InjectedIntl } from 'react-intl';
const messages = defineMessages({
today: { id: 'relative_time.today', defaultMessage: 'today' },
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
just_now_full: { id: 'relative_time.full.just_now', defaultMessage: 'just now' },
just_now_full: {
id: 'relative_time.full.just_now',
defaultMessage: 'just now',
},
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
seconds_full: { id: 'relative_time.full.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} ago' },
seconds_full: {
id: 'relative_time.full.seconds',
defaultMessage: '{number, plural, one {# second} other {# seconds}} ago',
},
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
minutes_full: { id: 'relative_time.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago' },
minutes_full: {
id: 'relative_time.full.minutes',
defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago',
},
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
hours_full: { id: 'relative_time.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} ago' },
hours_full: {
id: 'relative_time.full.hours',
defaultMessage: '{number, plural, one {# hour} other {# hours}} ago',
},
days: { id: 'relative_time.days', defaultMessage: '{number}d' },
days_full: { id: 'relative_time.full.days', defaultMessage: '{number, plural, one {# day} other {# days}} ago' },
moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
days_full: {
id: 'relative_time.full.days',
defaultMessage: '{number, plural, one {# day} other {# days}} ago',
},
moments_remaining: {
id: 'time_remaining.moments',
defaultMessage: 'Moments remaining',
},
seconds_remaining: {
id: 'time_remaining.seconds',
defaultMessage: '{number, plural, one {# second} other {# seconds}} left',
},
minutes_remaining: {
id: 'time_remaining.minutes',
defaultMessage: '{number, plural, one {# minute} other {# minutes}} left',
},
hours_remaining: {
id: 'time_remaining.hours',
defaultMessage: '{number, plural, one {# hour} other {# hours}} left',
},
days_remaining: {
id: 'time_remaining.days',
defaultMessage: '{number, plural, one {# day} other {# days}} left',
},
});
const dateFormatOptions = {
@ -36,8 +66,8 @@ const shortDateFormatOptions = {
const SECOND = 1000;
const MINUTE = 1000 * 60;
const HOUR = 1000 * 60 * 60;
const DAY = 1000 * 60 * 60 * 24;
const HOUR = 1000 * 60 * 60;
const DAY = 1000 * 60 * 60 * 24;
const MAX_DELAY = 2147483647;
@ -57,20 +87,27 @@ const selectUnits = (delta: number) => {
const getUnitDelay = (units: string) => {
switch (units) {
case 'second':
return SECOND;
case 'minute':
return MINUTE;
case 'hour':
return HOUR;
case 'day':
return DAY;
default:
return MAX_DELAY;
case 'second':
return SECOND;
case 'minute':
return MINUTE;
case 'hour':
return HOUR;
case 'day':
return DAY;
default:
return MAX_DELAY;
}
};
export const timeAgoString = (intl: InjectedIntl, date: Date, now: number, year: number, timeGiven: boolean, short?: boolean) => {
export const timeAgoString = (
intl: InjectedIntl,
date: Date,
now: number,
year: number,
timeGiven: boolean,
short?: boolean
) => {
const delta = now - date.getTime();
let relativeTime;
@ -78,27 +115,49 @@ export const timeAgoString = (intl: InjectedIntl, date: Date, now: number, year:
if (delta < DAY && !timeGiven) {
relativeTime = intl.formatMessage(messages.today);
} else if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(short ? messages.just_now : messages.just_now_full);
relativeTime = intl.formatMessage(
short ? messages.just_now : messages.just_now_full
);
} else if (delta < 7 * DAY) {
if (delta < MINUTE) {
relativeTime = intl.formatMessage(short ? messages.seconds : messages.seconds_full, { number: Math.floor(delta / SECOND) });
relativeTime = intl.formatMessage(
short ? messages.seconds : messages.seconds_full,
{ number: Math.floor(delta / SECOND) }
);
} else if (delta < HOUR) {
relativeTime = intl.formatMessage(short ? messages.minutes : messages.minutes_full, { number: Math.floor(delta / MINUTE) });
relativeTime = intl.formatMessage(
short ? messages.minutes : messages.minutes_full,
{ number: Math.floor(delta / MINUTE) }
);
} else if (delta < DAY) {
relativeTime = intl.formatMessage(short ? messages.hours : messages.hours_full, { number: Math.floor(delta / HOUR) });
relativeTime = intl.formatMessage(
short ? messages.hours : messages.hours_full,
{ number: Math.floor(delta / HOUR) }
);
} else {
relativeTime = intl.formatMessage(short ? messages.days : messages.days_full, { number: Math.floor(delta / DAY) });
relativeTime = intl.formatMessage(
short ? messages.days : messages.days_full,
{ number: Math.floor(delta / DAY) }
);
}
} else if (date.getFullYear() === year) {
relativeTime = intl.formatDate(date, shortDateFormatOptions);
} else {
relativeTime = intl.formatDate(date, { ...shortDateFormatOptions, year: 'numeric' });
relativeTime = intl.formatDate(date, {
...shortDateFormatOptions,
year: 'numeric',
});
}
return relativeTime;
};
const timeRemainingString = (intl: InjectedIntl, date: Date, now: number, timeGiven = true) => {
const timeRemainingString = (
intl: InjectedIntl,
date: Date,
now: number,
timeGiven = true
) => {
const delta = date.getTime() - now;
let relativeTime;
@ -108,13 +167,21 @@ const timeRemainingString = (intl: InjectedIntl, date: Date, now: number, timeGi
} else if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(messages.moments_remaining);
} else if (delta < MINUTE) {
relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
relativeTime = intl.formatMessage(messages.seconds_remaining, {
number: Math.floor(delta / SECOND),
});
} else if (delta < HOUR) {
relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) });
relativeTime = intl.formatMessage(messages.minutes_remaining, {
number: Math.floor(delta / MINUTE),
});
} else if (delta < DAY) {
relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) });
relativeTime = intl.formatMessage(messages.hours_remaining, {
number: Math.floor(delta / HOUR),
});
} else {
relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) });
relativeTime = intl.formatMessage(messages.days_remaining, {
number: Math.floor(delta / DAY),
});
}
return relativeTime;
@ -126,78 +193,86 @@ type Props = {
year: number;
futureDate?: boolean;
short?: boolean;
}
};
type States = {
now: number;
}
};
class RelativeTimestamp extends React.Component<Props, States> {
state = {
now: this.props.intl.now(),
};
static defaultProps = {
year: (new Date()).getFullYear(),
year: new Date().getFullYear(),
short: true,
};
_timer: number | undefined;
shouldComponentUpdate (nextProps: Props, nextState: States) {
shouldComponentUpdate(nextProps: Props, nextState: States) {
// As of right now the locale doesn't change without a new page load,
// but we might as well check in case that ever changes.
return this.props.timestamp !== nextProps.timestamp ||
return (
this.props.timestamp !== nextProps.timestamp ||
this.props.intl.locale !== nextProps.intl.locale ||
this.state.now !== nextState.now;
this.state.now !== nextState.now
);
}
UNSAFE_componentWillReceiveProps (nextProps: Props) {
UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (this.props.timestamp !== nextProps.timestamp) {
this.setState({ now: this.props.intl.now() });
}
}
componentDidMount () {
componentDidMount() {
this._scheduleNextUpdate(this.props, this.state);
}
UNSAFE_componentWillUpdate (nextProps: Props, nextState: States) {
UNSAFE_componentWillUpdate(nextProps: Props, nextState: States) {
this._scheduleNextUpdate(nextProps, nextState);
}
componentWillUnmount () {
componentWillUnmount() {
window.clearTimeout(this._timer);
}
_scheduleNextUpdate (props: Props, state: States) {
_scheduleNextUpdate(props: Props, state: States) {
window.clearTimeout(this._timer);
const { timestamp } = props;
const delta = (new Date(timestamp)).getTime() - state.now;
const unitDelay = getUnitDelay(selectUnits(delta));
const unitRemainder = Math.abs(delta % unitDelay);
const { timestamp } = props;
const delta = new Date(timestamp).getTime() - state.now;
const unitDelay = getUnitDelay(selectUnits(delta));
const unitRemainder = Math.abs(delta % unitDelay);
const updateInterval = 1000 * 10;
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
const delay =
delta < 0
? Math.max(updateInterval, unitDelay - unitRemainder)
: Math.max(updateInterval, unitRemainder);
this._timer = window.setTimeout(() => {
this.setState({ now: this.props.intl.now() });
}, delay);
}
render () {
render() {
const { timestamp, intl, year, futureDate, short } = this.props;
const timeGiven = timestamp.includes('T');
const date = new Date(timestamp);
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven, short);
const timeGiven = timestamp.includes('T');
const date = new Date(timestamp);
const relativeTime = futureDate
? timeRemainingString(intl, date, this.state.now, timeGiven)
: timeAgoString(intl, date, this.state.now, year, timeGiven, short);
return (
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
<time
dateTime={timestamp}
title={intl.formatDate(date, dateFormatOptions)}
>
{relativeTime}
</time>
);
}
}
const RelativeTimestampWithIntl = injectIntl(RelativeTimestamp);

View File

@ -1,4 +1,4 @@
export const PERMISSION_INVITE_USERS = 0x0000000000010000;
export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
export const PERMISSION_INVITE_USERS = 0x0000000000010000;
export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
export const PERMISSION_MANAGE_FEDERATION = 0x0000000000000020;
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;

View File

@ -10,7 +10,7 @@ if (!HTMLCanvasElement.prototype.toBlob) {
const BASE64_MARKER = ';base64,';
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
value(callback: BlobCallback, type = 'image/png', quality: any) {
value(callback: BlobCallback, type = 'image/png', quality: any) {
const dataURL = this.toDataURL(type, quality);
let data;

View File

@ -14,18 +14,18 @@ const initialState = Record<MissedUpdatesState>({
export function missedUpdatesReducer(
state = initialState,
action: Action<string>,
action: Action<string>
) {
switch (action.type) {
case focusApp.type:
return state.set('focused', true).set('unread', 0);
case unfocusApp.type:
return state.set('focused', false);
case NOTIFICATIONS_UPDATE:
return state.get('focused')
? state
: state.update('unread', (x) => x + 1);
default:
return state;
case focusApp.type:
return state.set('focused', true).set('unread', 0);
case unfocusApp.type:
return state.set('focused', false);
case NOTIFICATIONS_UPDATE:
return state.get('focused')
? state
: state.update('unread', (x) => x + 1);
default:
return state;
}
}

View File

@ -1,13 +1,23 @@
const easingOutQuint = (x: number, t: number, b: number, c: number, d: number) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
const scroll = (node: Element, key: 'scrollTop' | 'scrollLeft', target: number) => {
const easingOutQuint = (
x: number,
t: number,
b: number,
c: number,
d: number
) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
const scroll = (
node: Element,
key: 'scrollTop' | 'scrollLeft',
target: number
) => {
const startTime = Date.now();
const offset = node[key];
const gap = target - offset;
const duration = 1000;
let interrupt = false;
const offset = node[key];
const gap = target - offset;
const duration = 1000;
let interrupt = false;
const step = () => {
const elapsed = Date.now() - startTime;
const elapsed = Date.now() - startTime;
const percentage = elapsed / duration;
if (percentage > 1 || interrupt) {
@ -25,7 +35,14 @@ const scroll = (node: Element, key: 'scrollTop' | 'scrollLeft', target: number)
};
};
const isScrollBehaviorSupported = 'scrollBehavior' in document.documentElement.style;
const isScrollBehaviorSupported =
'scrollBehavior' in document.documentElement.style;
export const scrollRight = (node: Element, position: number) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position);
export const scrollTop = (node: Element) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0);
export const scrollRight = (node: Element, position: number) =>
isScrollBehaviorSupported
? node.scrollTo({ left: position, behavior: 'smooth' })
: scroll(node, 'scrollLeft', position);
export const scrollTop = (node: Element) =>
isScrollBehaviorSupported
? node.scrollTo({ top: 0, behavior: 'smooth' })
: scroll(node, 'scrollTop', 0);

View File

@ -7,17 +7,21 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
export const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(
loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }))
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(
loadingBarMiddleware({
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
})
)
.concat(errorsMiddleware)
.concat(soundsMiddleware()),
});
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof rootReducer>
export type RootState = ReturnType<typeof rootReducer>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@ -5,7 +5,9 @@ import { RootState } from '..';
const defaultFailSuffix = 'FAIL';
export const errorsMiddleware: Middleware<Record<string, never>, RootState> =
({ dispatch }) => next => action => {
({ dispatch }) =>
(next) =>
(action) => {
if (action.type && !action.skipAlert) {
const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');

View File

@ -3,29 +3,40 @@ import { Middleware } from 'redux';
import { RootState } from '..';
interface Config {
promiseTypeSuffixes?: string[]
promiseTypeSuffixes?: string[];
}
const defaultTypeSuffixes: Config['promiseTypeSuffixes'] = ['PENDING', 'FULFILLED', 'REJECTED'];
const defaultTypeSuffixes: Config['promiseTypeSuffixes'] = [
'PENDING',
'FULFILLED',
'REJECTED',
];
export const loadingBarMiddleware = (config: Config = {}): Middleware<Record<string, never>, RootState> => {
export const loadingBarMiddleware = (
config: Config = {}
): Middleware<Record<string, never>, RootState> => {
const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
return ({ dispatch }) => next => (action) => {
if (action.type && !action.skipLoading) {
const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
return ({ dispatch }) =>
(next) =>
(action) => {
if (action.type && !action.skipLoading) {
const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
const isPending = new RegExp(`${PENDING}$`, 'g');
const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
const isRejected = new RegExp(`${REJECTED}$`, 'g');
const isPending = new RegExp(`${PENDING}$`, 'g');
const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
const isRejected = new RegExp(`${REJECTED}$`, 'g');
if (action.type.match(isPending)) {
dispatch(showLoading());
} else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
dispatch(hideLoading());
if (action.type.match(isPending)) {
dispatch(showLoading());
} else if (
action.type.match(isFulfilled) ||
action.type.match(isRejected)
) {
dispatch(hideLoading());
}
}
}
return next(action);
};
return next(action);
};
};

View File

@ -2,8 +2,8 @@ import { Middleware, AnyAction } from 'redux';
import { RootState } from '..';
interface AudioSource {
src: string
type: string
src: string;
type: string;
}
const createAudio = (sources: AudioSource[]) => {
@ -30,8 +30,11 @@ const play = (audio: HTMLAudioElement) => {
audio.play();
};
export const soundsMiddleware = (): Middleware<Record<string, never>, RootState> => {
const soundCache: {[key: string]: HTMLAudioElement} = {
export const soundsMiddleware = (): Middleware<
Record<string, never>,
RootState
> => {
const soundCache: { [key: string]: HTMLAudioElement } = {
boop: createAudio([
{
src: '/sounds/boop.ogg',
@ -44,7 +47,7 @@ export const soundsMiddleware = (): Middleware<Record<string, never>, RootState
]),
};
return () => next => (action: AnyAction) => {
return () => (next) => (action: AnyAction) => {
const sound = action?.meta?.sound;
if (sound && soundCache[sound]) {

View File

@ -1,16 +1,16 @@
export const toServerSideType = (columnType: string) => {
switch (columnType) {
case 'home':
case 'notifications':
case 'public':
case 'thread':
case 'account':
return columnType;
default:
if (columnType.indexOf('list:') > -1) {
return 'home';
} else {
return 'public'; // community, account, hashtag
}
case 'home':
case 'notifications':
case 'public':
case 'thread':
case 'account':
return columnType;
default:
if (columnType.indexOf('list:') > -1) {
return 'home';
} else {
return 'public'; // community, account, hashtag
}
}
};

View File

@ -5,17 +5,8 @@ const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
const buildHashtagPatternRegex = () => {
try {
return new RegExp(
'(?:^|[^\\/\\)\\w])#((' +
'[' + WORD + '_]' +
'[' + WORD + HASHTAG_SEPARATORS + ']*' +
'[' + ALPHA + HASHTAG_SEPARATORS + ']' +
'[' + WORD + HASHTAG_SEPARATORS +']*' +
'[' + WORD + '_]' +
')|(' +
'[' + WORD + '_]*' +
'[' + ALPHA + ']' +
'[' + WORD + '_]*' +
'))', 'iu',
`(?:^|[^\\/\\)\\w])#(([${WORD}_][${WORD}${HASHTAG_SEPARATORS}]*[${ALPHA}${HASHTAG_SEPARATORS}][${WORD}${HASHTAG_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))`,
'iu'
);
} catch {
return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
@ -25,17 +16,8 @@ const buildHashtagPatternRegex = () => {
const buildHashtagRegex = () => {
try {
return new RegExp(
'^((' +
'[' + WORD + '_]' +
'[' + WORD + HASHTAG_SEPARATORS + ']*' +
'[' + ALPHA + HASHTAG_SEPARATORS + ']' +
'[' + WORD + HASHTAG_SEPARATORS +']*' +
'[' + WORD + '_]' +
')|(' +
'[' + WORD + '_]*' +
'[' + ALPHA + ']' +
'[' + WORD + '_]*' +
'))$', 'iu',
`^(([${WORD}_][${WORD}${HASHTAG_SEPARATORS}]*[${ALPHA}${HASHTAG_SEPARATORS}][${WORD}${HASHTAG_SEPARATORS}]*[${WORD}_])|([${WORD}_]*[${ALPHA}][${WORD}_]*))$`,
'iu'
);
} catch {
return /^(\w*[a-zA-Z·]\w*)$/i;

View File

@ -21,7 +21,7 @@ const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
* shortNumber(5936);
* // => [5.936, 1000, 1]
*/
export type ShortNumber = [number, DecimalUnits, 0 | 1] // Array of: shorten number, unit of shorten number and maximum fraction digits
export type ShortNumber = [number, DecimalUnits, 0 | 1]; // Array of: shorten number, unit of shorten number and maximum fraction digits
export function toShortNumber(sourceNumber: number): ShortNumber {
if (sourceNumber < DECIMAL_UNITS.THOUSAND) {
return [sourceNumber, DECIMAL_UNITS.ONE, 0];
@ -38,11 +38,7 @@ export function toShortNumber(sourceNumber: number): ShortNumber {
sourceNumber < TEN_MILLIONS ? 1 : 0,
];
} else if (sourceNumber < DECIMAL_UNITS.TRILLION) {
return [
sourceNumber / DECIMAL_UNITS.BILLION,
DECIMAL_UNITS.BILLION,
0,
];
return [sourceNumber / DECIMAL_UNITS.BILLION, DECIMAL_UNITS.BILLION, 0];
}
return [sourceNumber, DECIMAL_UNITS.ONE, 0];
@ -56,7 +52,10 @@ export function toShortNumber(sourceNumber: number): ShortNumber {
* pluralReady(1793, DECIMAL_UNITS.THOUSAND)
* // => 1790
*/
export function pluralReady(sourceNumber: number, division: DecimalUnits): number {
export function pluralReady(
sourceNumber: number,
division: DecimalUnits
): number {
if (division == null || division < DECIMAL_UNITS.HUNDRED) {
return sourceNumber;
}

View File

@ -1,8 +1,8 @@
export function uuid(a?: string): string {
return a
? (
(a as any as number) ^
(a as any as number) ^
((Math.random() * 16) >> ((a as any as number) / 4))
).toString(16)
).toString(16)
: ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
}

View File

@ -183,10 +183,12 @@
"@typescript-eslint/parser": "^5.59.5",
"babel-jest": "^29.5.0",
"eslint": "^8.39.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-formatjs": "^4.10.1",
"eslint-plugin-import": "~2.27.5",
"eslint-plugin-jsdoc": "^43.1.1",
"eslint-plugin-jsx-a11y": "~6.7.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "~6.1.1",
"eslint-plugin-react": "~7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",

View File

@ -5006,6 +5006,11 @@ escodegen@^2.0.0:
optionalDependencies:
source-map "~0.6.1"
eslint-config-prettier@^8.8.0:
version "8.8.0"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz#bfda738d412adc917fd7b038857110efe98c9348"
integrity sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==
eslint-import-resolver-node@^0.3.7:
version "0.3.7"
resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz#83b375187d412324a1963d84fa664377a23eb4d7"
@ -5096,6 +5101,13 @@ eslint-plugin-jsx-a11y@~6.7.1:
object.fromentries "^2.0.6"
semver "^6.3.0"
eslint-plugin-prettier@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b"
integrity sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==
dependencies:
prettier-linter-helpers "^1.0.0"
eslint-plugin-promise@~6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz#269a3e2772f62875661220631bd4dafcb4083816"
@ -5440,6 +5452,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-diff@^1.1.2:
version "1.2.0"
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
fast-glob@^3.2.12, fast-glob@^3.2.9:
version "3.2.12"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
@ -9214,6 +9231,13 @@ prelude-ls@~1.1.2:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
prettier-linter-helpers@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b"
integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==
dependencies:
fast-diff "^1.1.2"
prettier@^2.8.8:
version "2.8.8"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"