2
0
Fork 0

Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Essem 2023-07-17 10:24:20 -05:00
commit e64c00ec84
No known key found for this signature in database
GPG key ID: 7D497397CC3A2A8C
690 changed files with 4097 additions and 3224 deletions

View file

@ -86,10 +86,9 @@ const DIGIT_CHARACTERS = [
export const decode83 = (str: string) => {
let value = 0;
let c, digit;
let digit;
for (let i = 0; i < str.length; i++) {
c = str[i];
for (const c of str) {
digit = DIGIT_CHARACTERS.indexOf(c);
value = value * 83 + digit;
}

View file

@ -5,7 +5,7 @@ import { TransitionMotion, spring } from 'react-motion';
import { reduceMotion } from '../initial_state';
import ShortNumber from './short_number';
import { ShortNumber } from './short_number';
const obfuscatedCount = (count: number) => {
if (count < 0) {
@ -33,7 +33,7 @@ export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
const willLeave = useCallback(
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
[direction]
[direction],
);
if (reduceMotion) {

View file

@ -1,16 +1,16 @@
import { FormattedMessage } from 'react-intl';
import ShortNumber from 'flavours/glitch/components/short_number';
import { ShortNumber } from 'flavours/glitch/components/short_number';
interface Props {
tag: {
name: string;
url?: string;
history?: Array<{
history?: {
uses: number;
accounts: string;
day: string;
}>;
}[];
following?: boolean;
type: 'hashtag';
};

View file

@ -33,7 +33,7 @@ export const Avatar: React.FC<Props> = ({
if (account) {
style.backgroundImage = `url(${account.get(
hovering ? 'avatar' : 'avatar_static'
hovering ? 'avatar' : 'avatar_static',
)})`;
}
@ -42,7 +42,7 @@ export const Avatar: React.FC<Props> = ({
className={classNames(
'account__avatar',
{ 'account__avatar-inline': inline },
className
className,
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}

View file

@ -1,59 +0,0 @@
// @ts-check
import { FormattedMessage } from 'react-intl';
/**
* Returns custom renderer for one of the common counter types
* @param {"statuses" | "following" | "followers"} counterType
* Type of the counter
* @param {boolean} isBold Whether display number must be displayed in bold
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
* Renderer function
* @throws If counterType is not covered by this function
*/
export function counterRenderer(counterType, isBold = true) {
/**
* @type {(displayNumber: JSX.Element) => JSX.Element}
*/
const renderCounter = isBold
? (displayNumber) => <strong>{displayNumber}</strong>
: (displayNumber) => displayNumber;
switch (counterType) {
case 'statuses': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'following': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'followers': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
}
}

View file

@ -0,0 +1,45 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
export const StatusesCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
export const FollowingCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
export const FollowersCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);

View file

@ -1,55 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import { bannerSettings } from 'flavours/glitch/settings';
import { IconButton } from './icon_button';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
class DismissableBanner extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
children: PropTypes.node,
intl: PropTypes.object.isRequired,
};
state = {
visible: !bannerSettings.get(this.props.id),
};
handleDismiss = () => {
const { id } = this.props;
this.setState({ visible: false }, () => bannerSettings.set(id, true));
};
render () {
const { visible } = this.state;
if (!visible) {
return null;
}
const { children, intl } = this.props;
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>
{children}
</div>
<div className='dismissable-banner__action'>
<IconButton icon='times' title={intl.formatMessage(messages.dismiss)} onClick={this.handleDismiss} />
</div>
</div>
);
}
}
export default injectIntl(DismissableBanner);

View file

@ -0,0 +1,47 @@
import type { PropsWithChildren } from 'react';
import { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { bannerSettings } from 'flavours/glitch/settings';
import { IconButton } from './icon_button';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
interface Props {
id: string;
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const [visible, setVisible] = useState(!bannerSettings.get(id));
const intl = useIntl();
const handleDismiss = useCallback(() => {
setVisible(false);
bannerSettings.set(id, true);
}, [id]);
if (!visible) {
return null;
}
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>{children}</div>
<div className='dismissable-banner__action'>
<IconButton
icon='times'
title={intl.formatMessage(messages.dismiss)}
onClick={handleDismiss}
/>
</div>
</div>
);
};

View file

@ -11,11 +11,12 @@ import { autoPlayGif } from '../initial_state';
import { Skeleton } from './skeleton';
interface Props {
account: Account;
others: List<Account>;
localDomain: string;
account?: Account;
others?: List<Account>;
localDomain?: string;
inline?: boolean;
}
export class DisplayName extends React.PureComponent<Props> {
handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({
currentTarget,
@ -52,7 +53,15 @@ export class DisplayName extends React.PureComponent<Props> {
render() {
const { others, localDomain, inline } = this.props;
let displayName: React.ReactNode, suffix: React.ReactNode, account: Account;
let displayName: React.ReactNode,
suffix: React.ReactNode,
account: Account | undefined;
if (others && others.size > 0) {
account = others.first();
} else if (this.props.account) {
account = this.props.account;
}
if (others && others.size > 1) {
displayName = others
@ -70,16 +79,10 @@ export class DisplayName extends React.PureComponent<Props> {
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else if ((others && others.size > 0) || this.props.account) {
if (others && others.size > 0) {
account = others.first();
} else {
account = this.props.account;
}
} else if (account) {
let acct = account.get('acct');
if (acct.indexOf('@') === -1 && localDomain) {
if (!acct.includes('@') && localDomain) {
acct = `${acct}@${localDomain}`;
}

View file

@ -33,7 +33,7 @@ export const GIFV: React.FC<Props> = ({
onClick();
}
},
[onClick]
[onClick],
);
return (

View file

@ -10,7 +10,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import ShortNumber from 'flavours/glitch/components/short_number';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import Permalink from './permalink';

View file

@ -354,7 +354,10 @@ class MediaGallery extends PureComponent {
if (uncached) {
spoilerButton = (
<button type='button' disabled className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span>
<span className='spoiler-button__overlay__label'>
<FormattedMessage id='status.uncached_media_warning' defaultMessage='Preview not available' />
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.open' defaultMessage='Click to open' /></span>
</span>
</button>
);
} else if (visible) {
@ -362,7 +365,10 @@ class MediaGallery extends PureComponent {
} else {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
<span className='spoiler-button__overlay__label'>
{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button>
);
}

View file

@ -108,7 +108,7 @@ export const timeAgoString = (
now: number,
year: number,
timeGiven: boolean,
short?: boolean
short?: boolean,
) => {
const delta = now - date.getTime();
@ -118,28 +118,28 @@ export const timeAgoString = (
relativeTime = intl.formatMessage(messages.today);
} else if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(
short ? messages.just_now : messages.just_now_full
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) }
{ number: Math.floor(delta / SECOND) },
);
} else if (delta < HOUR) {
relativeTime = intl.formatMessage(
short ? messages.minutes : messages.minutes_full,
{ number: Math.floor(delta / MINUTE) }
{ number: Math.floor(delta / MINUTE) },
);
} else if (delta < DAY) {
relativeTime = intl.formatMessage(
short ? messages.hours : messages.hours_full,
{ number: Math.floor(delta / HOUR) }
{ number: Math.floor(delta / HOUR) },
);
} else {
relativeTime = intl.formatMessage(
short ? messages.days : messages.days_full,
{ number: Math.floor(delta / DAY) }
{ number: Math.floor(delta / DAY) },
);
}
} else if (date.getFullYear() === year) {
@ -158,7 +158,7 @@ const timeRemainingString = (
intl: IntlShape,
date: Date,
now: number,
timeGiven = true
timeGiven = true,
) => {
const delta = date.getTime() - now;

View file

@ -0,0 +1,23 @@
import type { PropsWithChildren } from 'react';
import React from 'react';
import type { History } from 'history';
import { createBrowserHistory } from 'history';
import { Router as OriginalRouter } from 'react-router';
import { layoutFromWindow } from 'flavours/glitch/is_mobile';
const browserHistory = createBrowserHistory();
const originalPush = browserHistory.push.bind(browserHistory);
browserHistory.push = (path: string, state: History.LocationState) => {
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) {
originalPush(`/deck${path}`, state);
} else {
originalPush(path, state);
}
};
export const Router: React.FC<PropsWithChildren> = ({ children }) => {
return <OriginalRouter history={browserHistory}>{children}</OriginalRouter>;
};

View file

@ -9,7 +9,7 @@ import { connect } from 'react-redux';
import { fetchServer } from 'flavours/glitch/actions/server';
import { ServerHeroImage } from 'flavours/glitch/components/server_hero_image';
import ShortNumber from 'flavours/glitch/components/short_number';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import Account from 'flavours/glitch/containers/account_container';
import { domain } from 'flavours/glitch/initial_state';

View file

@ -1,114 +0,0 @@
import PropTypes from 'prop-types';
import { memo } from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
// @ts-check
/**
* @callback ShortNumberRenderer
* @param {JSX.Element} displayNumber Number to display
* @param {number} pluralReady Number used for pluralization
* @returns {JSX.Element} Final render of number
*/
/**
* @typedef {object} ShortNumberProps
* @property {number} value Number to display in short variant
* @property {ShortNumberRenderer} [renderer]
* Custom renderer for numbers, provided as a prop. If another renderer
* passed as a child of this component, this prop won't be used.
* @property {ShortNumberRenderer} [children]
* Custom renderer for numbers, provided as a child. If another renderer
* passed as a prop of this component, this one will be used instead.
*/
/**
* Component that renders short big number to a shorter version
* @param {ShortNumberProps} param0 Props for the component
* @returns {JSX.Element} Rendered number
*/
function ShortNumber({ value, renderer, children }) {
const shortNumber = toShortNumber(value);
const [, division] = shortNumber;
if (children != null && renderer != null) {
console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
}
const customRenderer = children != null ? children : renderer;
const displayNumber = <ShortNumberCounter value={shortNumber} />;
return customRenderer != null
? customRenderer(displayNumber, pluralReady(value, division))
: displayNumber;
}
ShortNumber.propTypes = {
value: PropTypes.number.isRequired,
renderer: PropTypes.func,
children: PropTypes.func,
};
/**
* @typedef {object} ShortNumberCounterProps
* @property {import('../utils/number').ShortNumber} value Short number
*/
/**
* Renders short number into corresponding localizable react fragment
* @param {ShortNumberCounterProps} param0 Props for the component
* @returns {JSX.Element} FormattedMessage ready to be embedded in code
*/
function ShortNumberCounter({ value }) {
const [rawNumber, unit, maxFractionDigits = 0] = value;
const count = (
<FormattedNumber
value={rawNumber}
maximumFractionDigits={maxFractionDigits}
/>
);
let values = { count, rawNumber };
switch (unit) {
case DECIMAL_UNITS.THOUSAND: {
return (
<FormattedMessage
id='units.short.thousand'
defaultMessage='{count}K'
values={values}
/>
);
}
case DECIMAL_UNITS.MILLION: {
return (
<FormattedMessage
id='units.short.million'
defaultMessage='{count}M'
values={values}
/>
);
}
case DECIMAL_UNITS.BILLION: {
return (
<FormattedMessage
id='units.short.billion'
defaultMessage='{count}B'
values={values}
/>
);
}
// Not sure if we should go farther - @Sasha-Sorokin
default: return count;
}
}
ShortNumberCounter.propTypes = {
value: PropTypes.arrayOf(PropTypes.number),
};
export default memo(ShortNumber);

View file

@ -0,0 +1,90 @@
import { memo } from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
type ShortNumberRenderer = (
displayNumber: JSX.Element,
pluralReady: number,
) => JSX.Element;
interface ShortNumberProps {
value: number;
renderer?: ShortNumberRenderer;
children?: ShortNumberRenderer;
}
export const ShortNumberRenderer: React.FC<ShortNumberProps> = ({
value,
renderer,
children,
}) => {
const shortNumber = toShortNumber(value);
const [, division] = shortNumber;
if (children && renderer) {
console.warn(
'Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.',
);
}
const customRenderer = children ?? renderer ?? null;
const displayNumber = <ShortNumberCounter value={shortNumber} />;
return (
customRenderer?.(displayNumber, pluralReady(value, division)) ??
displayNumber
);
};
export const ShortNumber = memo(ShortNumberRenderer);
interface ShortNumberCounterProps {
value: number[];
}
const ShortNumberCounter: React.FC<ShortNumberCounterProps> = ({ value }) => {
const [rawNumber, unit, maxFractionDigits = 0] = value;
const count = (
<FormattedNumber
value={rawNumber}
maximumFractionDigits={maxFractionDigits}
/>
);
const values = { count, rawNumber };
switch (unit) {
case DECIMAL_UNITS.THOUSAND: {
return (
<FormattedMessage
id='units.short.thousand'
defaultMessage='{count}K'
values={values}
/>
);
}
case DECIMAL_UNITS.MILLION: {
return (
<FormattedMessage
id='units.short.million'
defaultMessage='{count}M'
values={values}
/>
);
}
case DECIMAL_UNITS.BILLION: {
return (
<FormattedMessage
id='units.short.billion'
defaultMessage='{count}B'
values={values}
/>
);
}
// Not sure if we should go farther - @Sasha-Sorokin
default:
return count;
}
};

View file

@ -208,8 +208,8 @@ class StatusActionBar extends ImmutablePureComponent {
render () {
const { status, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
const { permissions } = this.context.identity;
const anonymousAccess = !me;
const { permissions, signedIn } = this.context.identity;
const mutingConversation = status.get('muted');
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
@ -233,53 +233,55 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.share), action: this.handleShareClick });
}
if (publicStatus) {
if (publicStatus && (signedIn || !isRemote)) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
menu.push(null);
if (writtenByMe && pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
}
if (writtenByMe || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
}
if (writtenByMe) {
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
if (signedIn) {
menu.push(null);
if (!this.props.onFilter) {
menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick, dangerous: true });
if (writtenByMe && pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
}
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true });
if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
if (writtenByMe || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
if (accountAdminLink !== undefined) {
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) });
}
if (statusAdminLink !== undefined) {
menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) });
}
}
if (writtenByMe) {
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
menu.push(null);
if (!this.props.onFilter) {
menu.push({ text: intl.formatMessage(messages.filter), action: this.handleFilterClick, dangerous: true });
menu.push(null);
}
if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
const domain = status.getIn(['account', 'acct']).split('@')[1];
menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true });
if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
menu.push(null);
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
if (accountAdminLink !== undefined) {
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) });
}
if (statusAdminLink !== undefined) {
menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) });
}
}
if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
const domain = status.getIn(['account', 'acct']).split('@')[1];
menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
}
}
}
}
@ -337,14 +339,13 @@ class StatusActionBar extends ImmutablePureComponent {
? <EmojiPickerDropdown className='status__action-bar-button' onPickEmoji={this.handleEmojiPick} button={reactButton} disabled={!canReact} />
: reactButton
}
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
<IconButton className='status__action-bar-button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
{filterButton}
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer
scrollKey={scrollKey}
disabled={anonymousAccess}
status={status}
items={menu}
icon='ellipsis-h'

View file

@ -97,7 +97,7 @@ class TranslateButton extends PureComponent {
}
return (
<button className='status__content__read-more-button' onClick={onClick}>
<button className='status__content__translate-button' onClick={onClick}>
<FormattedMessage id='status.translate' defaultMessage='Translate' />
</button>
);

View file

@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { Helmet } from 'react-helmet';
import { BrowserRouter, Route } from 'react-router-dom';
import { Route } from 'react-router-dom';
import { Provider as ReduxProvider } from 'react-redux';
@ -13,6 +13,7 @@ import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_sett
import { hydrateStore } from 'flavours/glitch/actions/store';
import { connectUserStream } from 'flavours/glitch/actions/streaming';
import ErrorBoundary from 'flavours/glitch/components/error_boundary';
import { Router } from 'flavours/glitch/components/router';
import UI from 'flavours/glitch/features/ui';
import initialState, { title as siteTitle } from 'flavours/glitch/initial_state';
import { IntlProvider } from 'flavours/glitch/locales';
@ -79,11 +80,11 @@ export default class Mastodon extends PureComponent {
<IntlProvider>
<ReduxProvider store={store}>
<ErrorBoundary>
<BrowserRouter>
<Router>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Route path='/' component={UI} />
</ScrollContext>
</BrowserRouter>
</Router>
<Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />
</ErrorBoundary>

View file

@ -187,7 +187,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
dispatch(openModal({
modalType: 'EMBED',
modalProps: {
url: status.get('url'),
id: status.get('id'),
onError: error => dispatch(showAlertForError(error)),
},
}));

View file

@ -196,14 +196,14 @@ class Header extends ImmutablePureComponent {
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = '';
} else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button className={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
actionBtn = <Button className={classNames({ 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
actionBtn = <Button className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
} else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
actionBtn = <Button text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
}
} else if (profileLink) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
}
if (account.get('moved') && !account.getIn(['relationship', 'following'])) {

View file

@ -12,7 +12,7 @@ import { connectCommunityStream } from 'flavours/glitch/actions/streaming';
import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
import { domain } from 'flavours/glitch/initial_state';

View file

@ -20,7 +20,7 @@ import Button from 'flavours/glitch/components/button';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { IconButton } from 'flavours/glitch/components/icon_button';
import Permalink from 'flavours/glitch/components/permalink';
import ShortNumber from 'flavours/glitch/components/short_number';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import { autoPlayGif, me, unfollowModal } from 'flavours/glitch/initial_state';
import { makeGetAccount } from 'flavours/glitch/selectors';
@ -171,16 +171,16 @@ class AccountCard extends ImmutablePureComponent {
if (!account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = '';
} else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button className={classNames('logo-button')} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
} else if (account.getIn(['relationship', 'muting'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
actionBtn = <Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
} else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
actionBtn = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
}
} else {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
}
return (

View file

@ -25,12 +25,13 @@ export type SearchData = [
BaseEmoji['native'],
Emoji['short_names'],
Search,
Emoji['unified']
Emoji['unified'],
];
export interface ShortCodesToEmojiData {
[key: ShortCodesToEmojiDataKey]: [FilenameData, SearchData];
}
export type ShortCodesToEmojiData = Record<
ShortCodesToEmojiDataKey,
[FilenameData, SearchData]
>;
export type EmojisWithoutShortCodes = FilenameData[];
export type EmojiCompressed = [
@ -38,7 +39,7 @@ export type EmojiCompressed = [
Skins,
Category[],
Data['aliases'],
EmojisWithoutShortCodes
EmojisWithoutShortCodes,
];
/*

View file

@ -9,7 +9,7 @@ import emojiCompressed from './emoji_compressed';
import { unicodeToUnifiedName } from './unicode_to_unified_name';
type Emojis = {
[key in keyof ShortCodesToEmojiData]: {
[key in NonNullable<keyof ShortCodesToEmojiData>]: {
native: BaseEmoji['native'];
search: Search;
short_names: Emoji['short_names'];

View file

@ -5,7 +5,7 @@ import classNames from 'classnames';
import { Blurhash } from 'flavours/glitch/components/blurhash';
import { accountsCountRenderer } from 'flavours/glitch/components/hashtag';
import ShortNumber from 'flavours/glitch/components/short_number';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import { Skeleton } from 'flavours/glitch/components/skeleton';

View file

@ -11,7 +11,7 @@ import { connect } from 'react-redux';
import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header';
import Search from 'flavours/glitch/features/compose/containers/search_container';
import { showTrends } from 'flavours/glitch/initial_state';
import { trendsEnabled } from 'flavours/glitch/initial_state';
import Links from './links';
import SearchResults from './results';
@ -28,7 +28,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
layout: state.getIn(['meta', 'layout']),
isSearching: state.getIn(['search', 'submitted']) || !showTrends,
isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled,
});
class Explore extends PureComponent {

View file

@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { fetchTrendingLinks } from 'flavours/glitch/actions/trends';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import Story from './components/story';

View file

@ -9,7 +9,7 @@ import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { fetchTrendingStatuses, expandTrendingStatuses } from 'flavours/glitch/actions/trends';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import StatusList from 'flavours/glitch/components/status_list';
import { getStatusList } from 'flavours/glitch/selectors';
@ -52,6 +52,7 @@ class Statuses extends PureComponent {
<StatusList
trackScroll
timelineId='explore'
statusIds={statusIds}
scrollKey='explore-statuses'
hasMore={hasMore}

View file

@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { fetchTrendingHashtags } from 'flavours/glitch/actions/trends';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';

View file

@ -10,7 +10,7 @@ import { addColumn } from 'flavours/glitch/actions/columns';
import { changeSetting } from 'flavours/glitch/actions/settings';
import { connectPublicStream, connectCommunityStream } from 'flavours/glitch/actions/streaming';
import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import SettingText from 'flavours/glitch/components/setting_text';
import initialState, { domain } from 'flavours/glitch/initial_state';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';

View file

@ -192,7 +192,7 @@ class GettingStarted extends ImmutablePureComponent {
)}
</div>
<LinkFooter />
<LinkFooter multiColumn />
</div>
{(multiColumn && showTrends) && <TrendsContainer />}

View file

@ -1,54 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import SettingText from 'flavours/glitch/components/setting_text';
import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle';
const messages = defineMessages({
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
settings: { id: 'home.settings', defaultMessage: 'Column settings' },
});
class ColumnSettings extends PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { settings, onChange, intl } = this.props;
return (
<div>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'direct']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_direct' defaultMessage='Show private mentions' />} />
</div>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div className='column-settings__row'>
<SettingText prefix='home_timeline' settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div>
</div>
);
}
}
export default injectIntl(ColumnSettings);

View file

@ -0,0 +1,109 @@
/* eslint-disable @typescript-eslint/no-unsafe-call,
@typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access
-- the settings store is not yet typed */
import { useCallback } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import SettingText from 'flavours/glitch/components/setting_text';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { changeSetting } from '../../../actions/settings';
import SettingToggle from '../../notifications/components/setting_toggle';
const messages = defineMessages({
filter_regex: {
id: 'home.column_settings.filter_regex',
defaultMessage: 'Filter out by regular expressions',
},
settings: { id: 'home.settings', defaultMessage: 'Column settings' },
});
export const ColumnSettings: React.FC = () => {
const settings = useAppSelector((state) => state.settings.get('home'));
const intl = useIntl();
const dispatch = useAppDispatch();
const onChange = useCallback(
(key: string, checked: boolean) => {
dispatch(changeSetting(['home', ...key], checked));
},
[dispatch],
);
return (
<div>
<span className='column-settings__section'>
<FormattedMessage
id='home.column_settings.basic'
defaultMessage='Basic'
/>
</span>
<div className='column-settings__row'>
<SettingToggle
prefix='home_timeline'
settings={settings}
settingPath={['shows', 'reblog']}
onChange={onChange}
label={
<FormattedMessage
id='home.column_settings.show_reblogs'
defaultMessage='Show boosts'
/>
}
/>
</div>
<div className='column-settings__row'>
<SettingToggle
prefix='home_timeline'
settings={settings}
settingPath={['shows', 'reply']}
onChange={onChange}
label={
<FormattedMessage
id='home.column_settings.show_replies'
defaultMessage='Show replies'
/>
}
/>
</div>
<div className='column-settings__row'>
<SettingToggle
prefix='home_timeline'
settings={settings}
settingPath={['shows', 'direct']}
onChange={onChange}
label={
<FormattedMessage
id='home.column_settings.show_direct'
defaultMessage='Show private mentions'
/>
}
/>
</div>
<span className='column-settings__section'>
<FormattedMessage
id='home.column_settings.advanced'
defaultMessage='Advanced'
/>
</span>
<div className='column-settings__row'>
<SettingText
prefix='home_timeline'
settings={settings}
settingPath={['regex', 'body']}
onChange={onChange}
label={intl.formatMessage(messages.filter_regex)}
/>
</div>
</div>
);
};

View file

@ -1,25 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
import background from 'mastodon/../images/friends-cropped.png';
export const ExplorePrompt = () => (
<DismissableBanner id='home.explore_prompt'>
<img src={background} alt='' className='dismissable-banner__background-image' />
<h1><FormattedMessage id='home.explore_prompt.title' defaultMessage='This is your home base within Mastodon.' /></h1>
<p><FormattedMessage id='home.explore_prompt.body' defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:" /></p>
<div className='dismissable-banner__message__actions__wrapper'>
<div className='dismissable-banner__message__actions'>
<Link to='/explore' className='button'><FormattedMessage id='home.actions.go_to_explore' defaultMessage="See what's trending" /></Link>
<Link to='/explore/suggestions' className='button button-tertiary'><FormattedMessage id='home.actions.go_to_suggestions' defaultMessage='Find people to follow' /></Link>
</div>
</div>
</DismissableBanner>
);

View file

@ -0,0 +1,46 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import background from 'mastodon/../images/friends-cropped.png';
export const ExplorePrompt = () => (
<DismissableBanner id='home.explore_prompt'>
<img
src={background}
alt=''
className='dismissable-banner__background-image'
/>
<h1>
<FormattedMessage
id='home.explore_prompt.title'
defaultMessage='This is your home base within Mastodon.'
/>
</h1>
<p>
<FormattedMessage
id='home.explore_prompt.body'
defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:"
/>
</p>
<div className='dismissable-banner__message__wrapper'>
<div className='dismissable-banner__message__actions'>
<Link to='/explore' className='button'>
<FormattedMessage
id='home.actions.go_to_explore'
defaultMessage="See what's trending"
/>
</Link>
<Link to='/explore/suggestions' className='button button-tertiary'>
<FormattedMessage
id='home.actions.go_to_suggestions'
defaultMessage='Find people to follow'
/>
</Link>
</div>
</div>
</DismissableBanner>
);

View file

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings';
import ColumnSettings from '../components/column_settings';
const mapStateToProps = state => ({
settings: state.getIn(['settings', 'home']),
});
const mapDispatchToProps = dispatch => ({
onChange (path, checked) {
dispatch(changeSetting(['home', ...path], checked));
},
onSave () {
dispatch(saveSettings());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View file

@ -22,8 +22,8 @@ import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import StatusListContainer from '../ui/containers/status_list_container';
import { ColumnSettings } from './components/column_settings';
import { ExplorePrompt } from './components/explore_prompt';
import ColumnSettingsContainer from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' },
@ -192,7 +192,7 @@ class HomeTimeline extends PureComponent {
extraButton={announcementsButton}
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
>
<ColumnSettingsContainer />
<ColumnSettings />
</ColumnHeader>
{signedIn ? (

View file

@ -17,12 +17,6 @@ import LocalSettingsPageItem from './item';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
const messages = defineMessages({
layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' },
layout_auto_hint: { id: 'layout.hint.auto', defaultMessage: 'Automatically chose layout based on “Enable advanced web interface” setting and screen size.' },
layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
layout_desktop_hint: { id: 'layout.hint.desktop', defaultMessage: 'Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.' },
layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
layout_mobile_hint: { id: 'layout.hint.single', defaultMessage: 'Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.' },
side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' },
side_arm_keep: { id: 'settings.side_arm_reply_mode.keep', defaultMessage: 'Keep its set privacy' },
side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' },
@ -166,19 +160,6 @@ class LocalSettingsPage extends PureComponent {
</section>
<section>
<h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2>
<LocalSettingsPageItem
settings={settings}
item={['layout']}
id='mastodon-settings--layout'
options={[
{ value: 'auto', message: intl.formatMessage(messages.layout_auto), hint: intl.formatMessage(messages.layout_auto_hint) },
{ value: 'multiple', message: intl.formatMessage(messages.layout_desktop), hint: intl.formatMessage(messages.layout_desktop_hint) },
{ value: 'single', message: intl.formatMessage(messages.layout_mobile), hint: intl.formatMessage(messages.layout_mobile_hint) },
]}
onChange={onChange}
>
<FormattedMessage id='settings.layout' defaultMessage='Layout:' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['stretch']}

View file

@ -12,9 +12,9 @@ import { connectPublicStream } from 'flavours/glitch/actions/streaming';
import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
import { domain } from 'flavours/glitch/initial_state';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
import { domain } from 'flavours/glitch/initial_state';
import ColumnSettingsContainer from './containers/column_settings_container';

View file

@ -1,87 +1,121 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { OrderedSet, List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { shallowEqual } from 'react-redux';
import { createSelector } from 'reselect';
import Toggle from 'react-toggle';
import { fetchAccount } from 'flavours/glitch/actions/accounts';
import Button from 'flavours/glitch/components/button';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
const messages = defineMessages({
placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' },
});
class Comment extends PureComponent {
const selectRepliedToAccountIds = createSelector(
[
(state) => state.get('statuses'),
(_, statusIds) => statusIds,
],
(statusesMap, statusIds) => statusIds.map((statusId) => statusesMap.getIn([statusId, 'in_reply_to_account_id'])),
{
resultEqualityCheck: shallowEqual,
}
);
static propTypes = {
onSubmit: PropTypes.func.isRequired,
comment: PropTypes.string.isRequired,
onChangeComment: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
isSubmitting: PropTypes.bool,
forward: PropTypes.bool,
isRemote: PropTypes.bool,
domain: PropTypes.string,
onChangeForward: PropTypes.func.isRequired,
};
const Comment = ({ comment, domain, statusIds, isRemote, isSubmitting, selectedDomains, onSubmit, onChangeComment, onToggleDomain }) => {
const intl = useIntl();
handleClick = () => {
const { onSubmit } = this.props;
onSubmit();
};
const dispatch = useAppDispatch();
const loadedRef = useRef(false);
handleChange = e => {
const { onChangeComment } = this.props;
onChangeComment(e.target.value);
};
const handleClick = useCallback(() => onSubmit(), [onSubmit]);
const handleChange = useCallback((e) => onChangeComment(e.target.value), [onChangeComment]);
const handleToggleDomain = useCallback(e => onToggleDomain(e.target.value, e.target.checked), [onToggleDomain]);
handleKeyDown = e => {
const handleKeyDown = useCallback((e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleClick();
handleClick();
}
};
}, [handleClick]);
handleForwardChange = e => {
const { onChangeForward } = this.props;
onChangeForward(e.target.checked);
};
// Memoize accountIds since we don't want it to trigger `useEffect` on each render
const accountIds = useAppSelector((state) => domain ? selectRepliedToAccountIds(state, statusIds) : ImmutableList());
render () {
const { comment, isRemote, forward, domain, isSubmitting, intl } = this.props;
// While we could memoize `availableDomains`, it is pretty inexpensive to recompute
const accountsMap = useAppSelector((state) => state.get('accounts'));
const availableDomains = domain ? OrderedSet([domain]).union(accountIds.map((accountId) => accountsMap.getIn([accountId, 'acct'], '').split('@')[1]).filter(domain => !!domain)) : OrderedSet();
return (
<>
<h3 className='report-dialog-modal__title'><FormattedMessage id='report.comment.title' defaultMessage='Is there anything else you think we should know?' /></h3>
useEffect(() => {
if (loadedRef.current) {
return;
}
<textarea
className='report-dialog-modal__textarea'
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
disabled={isSubmitting}
/>
loadedRef.current = true;
{isRemote && (
<>
<p className='report-dialog-modal__lead'><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p>
// First, pre-select known domains
availableDomains.forEach((domain) => {
onToggleDomain(domain, true);
});
<label className='report-dialog-modal__toggle'>
<Toggle checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} />
// Then, fetch missing replied-to accounts
const unknownAccounts = OrderedSet(accountIds.filter(accountId => accountId && !accountsMap.has(accountId)));
unknownAccounts.forEach((accountId) => {
dispatch(fetchAccount(accountId));
});
});
return (
<>
<h3 className='report-dialog-modal__title'><FormattedMessage id='report.comment.title' defaultMessage='Is there anything else you think we should know?' /></h3>
<textarea
className='report-dialog-modal__textarea'
placeholder={intl.formatMessage(messages.placeholder)}
value={comment}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isSubmitting}
/>
{isRemote && (
<>
<p className='report-dialog-modal__lead'><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p>
{ availableDomains.map((domain) => (
<label className='report-dialog-modal__toggle' key={`toggle-${domain}`}>
<Toggle checked={selectedDomains.includes(domain)} disabled={isSubmitting} onChange={handleToggleDomain} value={domain} />
<FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} />
</label>
</>
)}
))}
</>
)}
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={this.handleClick} disabled={isSubmitting}><FormattedMessage id='report.submit' defaultMessage='Submit report' /></Button>
</div>
</>
);
}
<div className='flex-spacer' />
<div className='report-dialog-modal__actions'>
<Button onClick={handleClick} disabled={isSubmitting}><FormattedMessage id='report.submit' defaultMessage='Submit report' /></Button>
</div>
</>
);
}
export default injectIntl(Comment);
Comment.propTypes = {
comment: PropTypes.string.isRequired,
domain: PropTypes.string,
statusIds: ImmutablePropTypes.list.isRequired,
isRemote: PropTypes.bool,
isSubmitting: PropTypes.bool,
selectedDomains: ImmutablePropTypes.set.isRequired,
onSubmit: PropTypes.func.isRequired,
onChangeComment: PropTypes.func.isRequired,
onToggleDomain: PropTypes.func.isRequired,
};
export default Comment;

View file

@ -161,52 +161,55 @@ class ActionBar extends PureComponent {
let menu = [];
if (publicStatus) {
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
}
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
if ('share' in navigator) {
menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare });
}
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
menu.push(null);
if (publicStatus && isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
}
if (writtenByMe) {
if (pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
}
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
if (publicStatus && 'share' in navigator) {
menu.push({ text: intl.formatMessage(messages.share), action: this.handleShare });
}
if (publicStatus && (signedIn || !isRemote)) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
if (signedIn) {
menu.push(null);
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true });
if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
menu.push(null);
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
if (accountAdminLink !== undefined) {
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) });
}
if (statusAdminLink !== undefined) {
menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) });
}
if (writtenByMe) {
if (pinnableStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
}
if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
const domain = status.getIn(['account', 'acct']).split('@')[1];
menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick, dangerous: true });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick, dangerous: true });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport, dangerous: true });
if (((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
menu.push(null);
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
if (accountAdminLink !== undefined) {
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: accountAdminLink(status.getIn(['account', 'id'])) });
}
if (statusAdminLink !== undefined) {
menu.push({ text: intl.formatMessage(messages.admin_status), href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')) });
}
}
if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
const domain = status.getIn(['account', 'acct']).split('@')[1];
menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: domain }), href: `/admin/instances/${domain}` });
}
}
}
}

View file

@ -108,7 +108,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(openModal({
modalType: 'EMBED',
modalProps: {
url: status.get('url'),
id: status.get('id'),
onError: error => dispatch(showAlertForError(error)),
},
}));

View file

@ -515,7 +515,7 @@ class Status extends ImmutablePureComponent {
handleEmbed = (status) => {
this.props.dispatch(openModal({
modalType: 'EMBED',
modalProps: { url: status.get('url') },
modalProps: { id: status.get('id') },
}));
};

View file

@ -14,7 +14,7 @@ const messages = defineMessages({
class EmbedModal extends ImmutablePureComponent {
static propTypes = {
url: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
@ -26,11 +26,11 @@ class EmbedModal extends ImmutablePureComponent {
};
componentDidMount () {
const { url } = this.props;
const { id } = this.props;
this.setState({ loading: true });
api().post('/api/web/embed', { url }).then(res => {
api().get(`/api/web/embeds/${id}`).then(res => {
this.setState({ loading: false, oembed: res.data });
const iframeDocument = this.iframe.contentWindow.document;

View file

@ -38,6 +38,7 @@ class LinkFooter extends PureComponent {
};
static propTypes = {
multiColumn: PropTypes.bool,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -53,6 +54,7 @@ class LinkFooter extends PureComponent {
render () {
const { signedIn, permissions } = this.context.identity;
const { multiColumn } = this.props;
const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS);
const canProfileDirectory = profileDirectory;
@ -64,7 +66,7 @@ class LinkFooter extends PureComponent {
<p>
<strong>{domain}</strong>:
{' '}
<Link to='/about'><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
<Link to='/about' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
{statusPageUrl && (
<>
{DividingCircle}
@ -84,7 +86,7 @@ class LinkFooter extends PureComponent {
</>
)}
{DividingCircle}
<Link to='/privacy-policy'><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
<Link to='/privacy-policy' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
</p>
<p>

View file

@ -4,7 +4,8 @@ import { Component } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import NavigationPortal from 'flavours/glitch/components/navigation_portal';
import { timelinePreview, showTrends } from 'flavours/glitch/initial_state';
import { timelinePreview, trendsEnabled } from 'flavours/glitch/initial_state';
import { transientSingleColumn } from 'flavours/glitch/is_mobile';
import { preferencesLink } from 'flavours/glitch/utils/backend_links';
import ColumnLink from './column_link';
@ -27,6 +28,7 @@ const messages = defineMessages({
followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
app_settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
});
@ -52,6 +54,15 @@ class NavigationPanel extends Component {
return (
<div className='navigation-panel'>
{transientSingleColumn && (
<>
<a href={`/deck${location.pathname}`} className='button button--block'>
{intl.formatMessage(messages.advancedInterface)}
</a>
<hr />
</>
)}
{signedIn && (
<>
<ColumnLink transparent to='/home' icon='home' text={intl.formatMessage(messages.home)} />
@ -60,7 +71,7 @@ class NavigationPanel extends Component {
</>
)}
{showTrends ? (
{trendsEnabled ? (
<ColumnLink transparent to='/explore' icon='hashtag' text={intl.formatMessage(messages.explore)} />
) : (
<ColumnLink transparent to='/search' icon='search' text={intl.formatMessage(messages.search)} />

View file

@ -46,25 +46,26 @@ class ReportModal extends ImmutablePureComponent {
state = {
step: 'category',
selectedStatusIds: OrderedSet(this.props.statusId ? [this.props.statusId] : []),
selectedDomains: OrderedSet(),
comment: '',
category: null,
selectedRuleIds: OrderedSet(),
forward: true,
isSubmitting: false,
isSubmitted: false,
};
handleSubmit = () => {
const { dispatch, accountId } = this.props;
const { selectedStatusIds, comment, category, selectedRuleIds, forward } = this.state;
const { selectedStatusIds, selectedDomains, comment, category, selectedRuleIds } = this.state;
this.setState({ isSubmitting: true });
dispatch(submitReport({
account_id: accountId,
status_ids: selectedStatusIds.toArray(),
selected_domains: selectedDomains.toArray(),
comment,
forward,
forward: selectedDomains.size > 0,
category,
rule_ids: selectedRuleIds.toArray(),
}, this.handleSuccess, this.handleFail));
@ -88,13 +89,19 @@ class ReportModal extends ImmutablePureComponent {
}
};
handleRuleToggle = (ruleId, checked) => {
const { selectedRuleIds } = this.state;
handleDomainToggle = (domain, checked) => {
if (checked) {
this.setState({ selectedRuleIds: selectedRuleIds.add(ruleId) });
this.setState((state) => ({ selectedDomains: state.selectedDomains.add(domain) }));
} else {
this.setState({ selectedRuleIds: selectedRuleIds.remove(ruleId) });
this.setState((state) => ({ selectedDomains: state.selectedDomains.remove(domain) }));
}
};
handleRuleToggle = (ruleId, checked) => {
if (checked) {
this.setState((state) => ({ selectedRuleIds: state.selectedRuleIds.add(ruleId) }));
} else {
this.setState((state) => ({ selectedRuleIds: state.selectedRuleIds.remove(ruleId) }));
}
};
@ -106,10 +113,6 @@ class ReportModal extends ImmutablePureComponent {
this.setState({ comment });
};
handleChangeForward = forward => {
this.setState({ forward });
};
handleNextStep = step => {
this.setState({ step });
};
@ -138,8 +141,8 @@ class ReportModal extends ImmutablePureComponent {
step,
selectedStatusIds,
selectedRuleIds,
selectedDomains,
comment,
forward,
category,
isSubmitting,
isSubmitted,
@ -187,10 +190,11 @@ class ReportModal extends ImmutablePureComponent {
isSubmitting={isSubmitting}
isRemote={isRemote}
comment={comment}
forward={forward}
domain={domain}
onChangeComment={this.handleChangeComment}
onChangeForward={this.handleChangeForward}
statusIds={selectedStatusIds}
selectedDomains={selectedDomains}
onToggleDomain={this.handleDomainToggle}
/>
);
break;

View file

@ -23,7 +23,7 @@ import PermaLink from 'flavours/glitch/components/permalink';
import PictureInPicture from 'flavours/glitch/features/picture_in_picture';
import { layoutFromWindow } from 'flavours/glitch/is_mobile';
import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding } from '../../initial_state';
import BundleColumnError from './components/bundle_column_error';
import Header from './components/header';
@ -79,7 +79,6 @@ const mapStateToProps = state => ({
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
layout_local_setting: state.getIn(['local_settings', 'layout']),
isWide: state.getIn(['local_settings', 'stretch']),
dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
unreadNotifications: state.getIn(['notifications', 'unread']),
@ -134,11 +133,11 @@ class SwitchingColumnsArea extends PureComponent {
static propTypes = {
children: PropTypes.node,
location: PropTypes.object,
mobile: PropTypes.bool,
singleColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
if (this.props.mobile) {
if (this.props.singleColumn) {
document.body.classList.toggle('layout-single-column', true);
document.body.classList.toggle('layout-multiple-columns', false);
} else {
@ -152,9 +151,9 @@ class SwitchingColumnsArea extends PureComponent {
this.node.handleChildrenContentChange();
}
if (prevProps.mobile !== this.props.mobile) {
document.body.classList.toggle('layout-single-column', this.props.mobile);
document.body.classList.toggle('layout-multiple-columns', !this.props.mobile);
if (prevProps.singleColumn !== this.props.singleColumn) {
document.body.classList.toggle('layout-single-column', this.props.singleColumn);
document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn);
}
}
@ -165,30 +164,34 @@ class SwitchingColumnsArea extends PureComponent {
};
render () {
const { children, mobile } = this.props;
const { children, singleColumn } = this.props;
const { signedIn } = this.context.identity;
const pathName = this.props.location.pathname;
let redirect;
if (signedIn) {
if (mobile) {
if (singleColumn) {
redirect = <Redirect from='/' to='/home' exact />;
} else {
redirect = <Redirect from='/' to='/getting-started' exact />;
redirect = <Redirect from='/' to='/deck/getting-started' exact />;
}
} else if (singleUserMode && owner && initialState?.accounts[owner]) {
redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
} else if (showTrends && trendsAsLanding) {
} else if (trendsEnabled && trendsAsLanding) {
redirect = <Redirect from='/' to='/explore' exact />;
} else {
redirect = <Redirect from='/' to='/about' exact />;
}
return (
<ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}>
<ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
<WrappedSwitch>
{redirect}
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null}
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
<WrappedRoute path='/about' component={About} content={children} />
@ -256,7 +259,6 @@ class UI extends Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
children: PropTypes.node,
layout_local_setting: PropTypes.string,
isWide: PropTypes.bool,
systemFontUi: PropTypes.bool,
isComposing: PropTypes.bool,
@ -381,7 +383,7 @@ class UI extends Component {
});
handleResize = () => {
const layout = layoutFromWindow(this.props.layout_local_setting);
const layout = layoutFromWindow();
if (layout !== this.props.layout) {
this.handleLayoutChange.cancel();
@ -445,19 +447,6 @@ class UI extends Component {
}
}
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.layout_local_setting !== this.props.layout_local_setting) {
const layout = layoutFromWindow(nextProps.layout_local_setting);
if (layout !== this.props.layout) {
this.handleLayoutChange.cancel();
this.props.dispatch(changeLayout(layout));
} else {
this.handleLayoutChange();
}
}
}
componentDidUpdate (prevProps) {
if (this.props.unreadNotifications !== prevProps.unreadNotifications ||
this.props.showFaviconBadge !== prevProps.showFaviconBadge) {
@ -667,7 +656,7 @@ class UI extends Component {
<Header />
<SwitchingColumnsArea location={location} mobile={layout === 'mobile' || layout === 'single-column'}>
<SwitchingColumnsArea location={location} singleColumn={layout === 'mobile' || layout === 'single-column'}>
{children}
</SwitchingColumnsArea>

View file

@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import * as React from 'react';
import { Component, PureComponent, cloneElement, Children } from 'react';
import { Switch, Route } from 'react-router-dom';
@ -10,14 +10,22 @@ import ColumnLoading from 'flavours/glitch/features/ui/components/column_loading
import BundleContainer from 'flavours/glitch/features/ui/containers/bundle_container';
// Small wrapper to pass multiColumn to the route components
export class WrappedSwitch extends React.PureComponent {
export class WrappedSwitch extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
render () {
const { multiColumn, children } = this.props;
const { location } = this.context.router.route;
const decklessLocation = multiColumn && location.pathname.startsWith('/deck')
? {...location, pathname: location.pathname.slice(5)}
: location;
return (
<Switch>
{React.Children.map(children, child => React.cloneElement(child, { multiColumn }))}
<Switch location={decklessLocation}>
{Children.map(children, child => child ? cloneElement(child, { multiColumn }) : null)}
</Switch>
);
}
@ -32,7 +40,7 @@ WrappedSwitch.propTypes = {
// Small Wraper to extract the params from the route and pass
// them to the rendered component, together with the content to
// be rendered inside (the children)
export class WrappedRoute extends React.Component {
export class WrappedRoute extends Component {
static propTypes = {
component: PropTypes.func.isRequired,

View file

@ -71,12 +71,13 @@
* @property {boolean} reduce_motion
* @property {string} repository
* @property {boolean} search_enabled
* @property {boolean} trends_enabled
* @property {boolean} single_user_mode
* @property {string} source_url
* @property {string} streaming_api_base_url
* @property {boolean} timeline_preview
* @property {string} title
* @property {boolean} trends
* @property {boolean} show_trends
* @property {boolean} trends_as_landing_page
* @property {boolean} unfollow_modal
* @property {boolean} use_blurhash
@ -89,6 +90,13 @@
* @property {string} default_content_type
*/
/** @type {string} */
const initialPath = document.querySelector("head meta[name=initialPath]")?.getAttribute("content") ?? '';
/** @type {boolean} */
export const hasMultiColumnPath = initialPath === '/'
|| initialPath === '/getting-started'
|| initialPath.startsWith('/deck');
/**
* @typedef InitialState
* @property {Record<string, Account>} accounts
@ -143,7 +151,8 @@ export const reduceMotion = getMeta('reduce_motion');
export const registrationsOpen = getMeta('registrations_open');
export const repository = getMeta('repository');
export const searchEnabled = getMeta('search_enabled');
export const showTrends = getMeta('trends');
export const trendsEnabled = getMeta('trends_enabled');
export const showTrends = getMeta('show_trends');
export const singleUserMode = getMeta('single_user_mode');
export const source_url = getMeta('source_url');
export const timelinePreview = getMeta('timeline_preview');

View file

@ -1,30 +1,21 @@
import { supportsPassiveEvents } from 'detect-passive-events';
import { forceSingleColumn } from 'flavours/glitch/initial_state';
import { forceSingleColumn, hasMultiColumnPath } from './initial_state';
const LAYOUT_BREAKPOINT = 630;
export const isMobile = (width: number) => width <= LAYOUT_BREAKPOINT;
export const transientSingleColumn = !forceSingleColumn && !hasMultiColumnPath;
export type LayoutType = 'mobile' | 'single-column' | 'multi-column';
export const layoutFromWindow = (layout_local_setting: string): LayoutType => {
switch (layout_local_setting) {
case 'multiple':
return 'multi-column';
case 'single':
if (isMobile(window.innerWidth)) {
return 'mobile';
} else {
return 'single-column';
}
default:
if (isMobile(window.innerWidth)) {
return 'mobile';
} else if (forceSingleColumn) {
return 'single-column';
} else {
return 'multi-column';
}
export const layoutFromWindow = (): LayoutType => {
if (isMobile(window.innerWidth)) {
return 'mobile';
} else if (!forceSingleColumn && !transientSingleColumn) {
return 'multi-column';
} else {
return 'single-column';
}
};

View file

@ -65,12 +65,6 @@
"keyboard_shortcuts.secondary_toot": "to send toot using secondary privacy setting",
"keyboard_shortcuts.toggle_collapse": "to collapse/uncollapse toots",
"tooltips.reactions": "Reactions",
"layout.auto": "Auto",
"layout.desktop": "Desktop",
"layout.hint.auto": "Automatically chose layout based on “Enable advanced web interface” setting and screen size.",
"layout.hint.desktop": "Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.",
"layout.hint.single": "Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.",
"layout.single": "Mobile",
"media_gallery.sensitive": "Sensitive",
"moved_to_warning": "This account is marked as moved to {moved_to_link}, and may thus not accept new follows.",
"navigation_bar.app_settings": "App settings",
@ -150,7 +144,6 @@
"settings.image_backgrounds_media_hint": "If the post has any media attachment, use the first one as a background",
"settings.image_backgrounds_users": "Give collapsed toots an image background",
"settings.inline_preview_cards": "Inline preview cards for external links",
"settings.layout": "Layout:",
"settings.layout_opts": "Layout options",
"settings.media": "Media",
"settings.media_fullwidth": "Full-width media previews",

View file

@ -3,15 +3,19 @@ export interface LocaleData {
messages: Record<string, string>;
}
let loadedLocale: LocaleData;
let loadedLocale: LocaleData | undefined;
export function setLocale(locale: LocaleData) {
loadedLocale = locale;
}
export function getLocale() {
if (!loadedLocale && process.env.NODE_ENV === 'development') {
throw new Error('getLocale() called before any locale has been set');
export function getLocale(): LocaleData {
if (!loadedLocale) {
if (process.env.NODE_ENV === 'development') {
throw new Error('getLocale() called before any locale has been set');
} else {
return { locale: 'unknown', messages: {} };
}
}
return loadedLocale;

View file

@ -6,6 +6,7 @@ import { isLocaleLoaded, setLocale } from './global_locale';
const localeLoadingSemaphore = new Semaphore(1);
export async function loadLocale() {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want to match empty strings
const locale = document.querySelector<HTMLElement>('html')?.lang || 'en';
// We use a Semaphore here so only one thing can try to load the locales at

View file

@ -4,7 +4,7 @@ import 'core-js/features/symbol';
import 'core-js/features/promise/finally';
import { decode as decodeBase64 } from '../utils/base64';
if (!HTMLCanvasElement.prototype.toBlob) {
if (!Object.hasOwn(HTMLCanvasElement.prototype, 'toBlob')) {
const BASE64_MARKER = ';base64,';
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
@ -12,12 +12,12 @@ if (!HTMLCanvasElement.prototype.toBlob) {
this: HTMLCanvasElement,
callback: BlobCallback,
type = 'image/png',
quality: unknown
quality: unknown,
) {
const dataURL: string = this.toDataURL(type, quality);
let data;
if (dataURL.indexOf(BASE64_MARKER) >= 0) {
if (dataURL.includes(BASE64_MARKER)) {
const [, base64] = dataURL.split(BASE64_MARKER);
data = decodeBase64(base64);
} else {

View file

@ -24,6 +24,7 @@ export function loadPolyfills() {
// Latest version of Firefox and Safari do not have IntersectionObserver.
// Edge does not have requestIdleCallback.
// This avoids shipping them all the polyfills.
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types */
const needsExtraPolyfills = !(
window.AbortController &&
window.IntersectionObserver &&
@ -31,6 +32,7 @@ export function loadPolyfills() {
'isIntersecting' in IntersectionObserverEntry.prototype &&
window.requestIdleCallback
);
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
return Promise.all([
loadIntlPolyfills(),

View file

@ -80,6 +80,7 @@ async function loadIntlPluralRulesPolyfills(locale: string) {
// }
export async function loadIntlPolyfills() {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want to match empty strings
const locale = document.querySelector('html')?.lang || 'en';
// order is important here

View file

@ -105,7 +105,7 @@ const initialRootState = Object.fromEntries(
reducer(undefined, {
// empty action
}),
])
]),
);
const RootStateRecord = ImmutableRecord(initialRootState, 'RootState');

View file

@ -6,7 +6,6 @@ import { LOCAL_SETTING_CHANGE, LOCAL_SETTING_DELETE } from 'flavours/glitch/acti
import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
const initialState = ImmutableMap({
layout : 'auto',
stretch : true,
side_arm : 'none',
side_arm_reply_mode : 'keep',

View file

@ -35,7 +35,7 @@ interface PopModalOption {
}
const popModal = (
state: State,
{ modalType, ignoreFocus }: PopModalOption
{ modalType, ignoreFocus }: PopModalOption,
): State => {
if (
modalType === undefined ||
@ -52,12 +52,12 @@ const popModal = (
const pushModal = (
state: State,
modalType: ModalType,
modalProps: ModalProps
modalProps: ModalProps,
): State => {
return state.withMutations((record) => {
record.set('ignoreFocus', false);
record.update('stack', (stack) =>
stack.unshift(Modal({ modalType, modalProps }))
stack.unshift(Modal({ modalType, modalProps })),
);
});
};
@ -68,14 +68,14 @@ export function modalReducer(
modalType: ModalType;
ignoreFocus: boolean;
modalProps: Record<string, unknown>;
}>
}>,
) {
switch (action.type) {
case openModal.type:
return pushModal(
state,
action.payload.modalType,
action.payload.modalProps
action.payload.modalProps,
);
case closeModal.type:
return popModal(state, action.payload);
@ -85,8 +85,8 @@ export function modalReducer(
return state.update('stack', (stack) =>
stack.filterNot(
// @ts-expect-error TIMELINE_DELETE action is not typed yet.
(modal) => modal.get('modalProps').statusId === action.id
)
(modal) => modal.get('modalProps').statusId === action.id,
),
);
default:
return state;

View file

@ -313,7 +313,7 @@ export default function notifications(state = initialState, action) {
case NOTIFICATIONS_EXPAND_FAIL:
return state.update('isLoading', (nbLoading) => nbLoading - 1);
case NOTIFICATIONS_FILTER_SET:
return state.set('items', ImmutableList()).set('hasMore', true);
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true);
case NOTIFICATIONS_SCROLL_TOP:
return updateTop(state, action.top);
case NOTIFICATIONS_UPDATE:

View file

@ -3,12 +3,12 @@ const easingOutQuint = (
t: number,
b: number,
c: number,
d: number
d: number,
) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
const scroll = (
node: Element,
key: 'scrollTop' | 'scrollLeft',
target: number
target: number,
) => {
const startTime = Date.now();
const offset = node[key];
@ -38,11 +38,13 @@ const scroll = (
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) => {
if (isScrollBehaviorSupported)
node.scrollTo({ left: position, behavior: 'smooth' });
else scroll(node, 'scrollLeft', position);
};
export const scrollTop = (node: Element) => {
if (isScrollBehaviorSupported) node.scrollTo({ top: 0, behavior: 'smooth' });
else scroll(node, 'scrollTop', 0);
};

View file

@ -30,7 +30,7 @@ export const store = configureStore({
.concat(
loadingBarMiddleware({
promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
})
}),
)
.concat(errorsMiddleware)
.concat(soundsMiddleware()),

View file

@ -14,9 +14,9 @@ const defaultTypeSuffixes: Config['promiseTypeSuffixes'] = [
];
export const loadingBarMiddleware = (
config: Config = {}
config: Config = {},
): Middleware<Record<string, never>, RootState> => {
const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
const promiseTypeSuffixes = config.promiseTypeSuffixes ?? defaultTypeSuffixes;
return ({ dispatch }) =>
(next) =>
@ -32,7 +32,7 @@ export const loadingBarMiddleware = (
if (action.type.match(isPending)) {
dispatch(showLoading());
} else if (
action.type.match(isFulfilled) ||
action.type.match(isFulfilled) ??
action.type.match(isRejected)
) {
dispatch(hideLoading());

View file

@ -1,5 +1,8 @@
import type { Middleware, AnyAction } from 'redux';
import ready from 'flavours/glitch/ready';
import { assetHost } from 'flavours/glitch/utils/config';
import type { RootState } from '..';
interface AudioSource {
@ -35,25 +38,27 @@ export const soundsMiddleware = (): Middleware<
Record<string, never>,
RootState
> => {
const soundCache: { [key: string]: HTMLAudioElement } = {
boop: createAudio([
const soundCache: Record<string, HTMLAudioElement> = {};
void ready(() => {
soundCache.boop = createAudio([
{
src: '/sounds/boop.ogg',
src: `${assetHost}/sounds/boop.ogg`,
type: 'audio/ogg',
},
{
src: '/sounds/boop.mp3',
src: `${assetHost}/sounds/boop.mp3`,
type: 'audio/mpeg',
},
]),
};
]);
});
return () =>
(next) =>
(action: AnyAction & { meta?: { sound?: string } }) => {
const sound = action?.meta?.sound;
const sound = action.meta?.sound;
if (sound && soundCache[sound]) {
if (sound && Object.hasOwn(soundCache, sound)) {
play(soundCache[sound]);
}

View file

@ -31,9 +31,19 @@ body {
// Droid Sans => Older Androids (<4.0)
// Helvetica Neue => Older macOS <10.11
// $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
$font-sans-serif, sans-serif;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
$font-sans-serif,
sans-serif;
}
&.app-body {

View file

@ -480,7 +480,9 @@ $ui-header-height: 55px;
overflow: hidden;
overflow-y: auto;
color: $darker-text-color;
transition: max-height 150ms ease-in-out, opacity 300ms linear;
transition:
max-height 150ms ease-in-out,
opacity 300ms linear;
opacity: 1;
z-index: 1;
position: relative;

View file

@ -26,7 +26,9 @@
}
.no-reduce-motion .spoiler-input {
transition: height 0.4s ease, opacity 0.4s ease;
transition:
height 0.4s ease,
opacity 0.4s ease;
}
.spoiler-input {

View file

@ -253,14 +253,16 @@
@for $i from 0 through 3 {
.mbstobon-#{$i} .drawer__inner__mastodon {
@if $i == 3 {
background: url('~flavours/glitch/images/wave-drawer.png')
background:
url('~flavours/glitch/images/wave-drawer.png')
no-repeat
bottom /
100%
auto,
lighten($ui-base-color, 13%);
} @else {
background: url('~flavours/glitch/images/wave-drawer-glitched.png')
background:
url('~flavours/glitch/images/wave-drawer-glitched.png')
no-repeat
bottom /
100%

View file

@ -150,7 +150,6 @@
.layout-multiple-columns &.button--with-bell {
font-size: 12px;
padding: 0 8px;
}
}
@ -538,14 +537,14 @@ body > [data-popper-placement] {
.dropdown-menu__separator {
border-bottom: 1px solid var(--dropdown-border-color);
margin: 5px 0;
margin: 2px 0;
height: 0;
}
.dropdown-menu {
background: var(--dropdown-background-color);
border: 1px solid var(--dropdown-border-color);
padding: 4px;
padding: 2px;
border-radius: 4px;
box-shadow: var(--dropdown-shadow);
z-index: 9999;
@ -569,8 +568,8 @@ body > [data-popper-placement] {
&__container {
&__header {
border-bottom: 1px solid var(--dropdown-border-color);
padding: 10px 14px;
padding-bottom: 14px;
padding: 6px 14px;
padding-bottom: 12px;
margin-bottom: 4px;
font-size: 13px;
line-height: 18px;
@ -610,7 +609,7 @@ body > [data-popper-placement] {
font: inherit;
display: block;
width: 100%;
padding: 10px 14px;
padding: 6px 14px;
border: 0;
margin: 0;
background: transparent;
@ -1343,34 +1342,30 @@ button.icon-button.active i.fa-retweet {
}
&__overlay {
display: block;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
background: rgba($black, 0.5);
width: 100%;
height: 100%;
padding: 0;
margin: 0;
border: 0;
&__label {
display: inline-block;
background: rgba($base-overlay-background, 0.5);
border-radius: 8px;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-direction: column;
color: $primary-text-color;
font-weight: 500;
font-size: 14px;
}
&:hover,
&:focus,
&:active {
.spoiler-button__overlay__label {
background: rgba($base-overlay-background, 0.8);
}
}
&:disabled {
.spoiler-button__overlay__label {
background: rgba($base-overlay-background, 0.5);
}
&__action {
font-weight: 400;
font-size: 13px;
}
}
}

View file

@ -709,6 +709,7 @@
&__toggle {
display: flex;
align-items: center;
margin-bottom: 10px;
& > span {
font-size: 17px;

View file

@ -673,10 +673,6 @@ a.status__display-name,
color: inherit;
}
.detailed-status .button.logo-button {
margin-bottom: 15px;
}
.detailed-status__display-name {
color: $secondary-text-color;
display: block;

View file

@ -15,7 +15,8 @@
.status__content a,
.link-footer a,
.reply-indicator__content a,
.status__content__read-more-button {
.status__content__read-more-button,
.status__content__translate-button {
text-decoration: underline;
&:hover,

View file

@ -627,14 +627,6 @@ html {
}
}
.button.logo-button {
color: $white;
svg {
fill: $white;
}
}
.notification__filter-bar button.active::after,
.account__section-headline a.active::after {
border-color: transparent transparent $white;

View file

@ -73,66 +73,18 @@
}
}
.button.logo-button {
flex: 0 auto;
font-size: 14px;
background: darken($ui-highlight-color, 2%);
color: $primary-text-color;
text-transform: none;
line-height: 1.2;
.button.logo-button svg {
width: 20px;
height: auto;
min-height: 36px;
min-width: 88px;
white-space: normal;
overflow-wrap: break-word;
hyphens: auto;
padding: 0 15px;
border: 0;
svg {
width: 20px;
height: auto;
vertical-align: middle;
margin-inline-end: 5px;
fill: $primary-text-color;
}
&:active,
&:focus,
&:hover {
background: $ui-highlight-color;
}
&:disabled,
&.disabled {
&:active,
&:focus,
&:hover {
background: $ui-primary-color;
}
}
&.button--destructive {
&:active,
&:focus,
&:hover {
background: $error-red;
}
}
vertical-align: middle;
margin-inline-end: 5px;
fill: $primary-text-color;
@media screen and (max-width: $no-gap-breakpoint) {
svg {
display: none;
}
display: none;
}
}
a.button.logo-button {
display: inline-flex;
align-items: center;
justify-content: center;
}
.embed {
.status__content[data-spoiler='folded'] {
.e-content {
@ -261,7 +213,8 @@ a.button.logo-button {
}
}
.status__content__read-more-button {
.status__content__read-more-button,
.status__content__translate-button {
display: block;
font-size: 15px;
line-height: 20px;

View file

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

View file

@ -55,7 +55,7 @@ export function toShortNumber(sourceNumber: number): ShortNumber {
*/
export function pluralReady(
sourceNumber: number,
division: DecimalUnits
division: DecimalUnits | null,
): number {
if (division == null || division < DECIMAL_UNITS.HUNDRED) {
return sourceNumber;

View file

@ -4,6 +4,5 @@ export function uuid(a?: string): string {
(a as unknown as number) ^
((Math.random() * 16) >> ((a as unknown as number) / 4))
).toString(16)
: // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
: ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid);
}

View file

@ -12,52 +12,48 @@ export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
export const ALERT_NOOP = 'ALERT_NOOP';
export function dismissAlert(alert) {
return {
type: ALERT_DISMISS,
alert,
};
}
export const dismissAlert = alert => ({
type: ALERT_DISMISS,
alert,
});
export function clearAlert() {
return {
type: ALERT_CLEAR,
};
}
export const clearAlert = () => ({
type: ALERT_CLEAR,
});
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
return {
type: ALERT_SHOW,
title,
message,
message_values,
};
}
export const showAlert = alert => ({
type: ALERT_SHOW,
alert,
});
export function showAlertForError(error, skipNotFound = false) {
export const showAlertForError = (error, skipNotFound = false) => {
if (error.response) {
const { data, status, statusText, headers } = error.response;
// Skip these errors as they are reflected in the UI
if (skipNotFound && (status === 404 || status === 410)) {
// Skip these errors as they are reflected in the UI
return { type: ALERT_NOOP };
}
// Rate limit errors
if (status === 429 && headers['x-ratelimit-reset']) {
const reset_date = new Date(headers['x-ratelimit-reset']);
return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
return showAlert({
title: messages.rateLimitedTitle,
message: messages.rateLimitedMessage,
values: { 'retry_time': new Date(headers['x-ratelimit-reset']) },
});
}
let message = statusText;
let title = `${status}`;
if (data.error) {
message = data.error;
}
return showAlert(title, message);
} else {
console.error(error);
return showAlert();
return showAlert({
title: `${status}`,
message: data.error || statusText,
});
}
console.error(error);
return showAlert({
title: messages.unexpectedTitle,
message: messages.unexpectedMessage,
});
}

View file

@ -82,6 +82,8 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
});
export const ensureComposeIsVisible = (getState, routerHistory) => {
@ -242,6 +244,13 @@ export function submitCompose(routerHistory) {
}
insertIfOnline(`account:${response.data.account.id}`);
}
dispatch(showAlert({
message: messages.published,
action: messages.open,
dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
}));
}).catch(function (error) {
dispatch(submitComposeFail(error));
});
@ -271,18 +280,19 @@ export function submitComposeFail(error) {
export function uploadCompose(files) {
return function (dispatch, getState) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size + pending > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
dispatch(showAlert({ message: messages.uploadErrorLimit }));
return;
}
if (getState().getIn(['compose', 'poll'])) {
dispatch(showAlert(undefined, messages.uploadErrorPoll));
dispatch(showAlert({ message: messages.uploadErrorPoll }));
return;
}

View file

@ -86,10 +86,9 @@ const DIGIT_CHARACTERS = [
export const decode83 = (str: string) => {
let value = 0;
let c, digit;
let digit;
for (let i = 0; i < str.length; i++) {
c = str[i];
for (const c of str) {
digit = DIGIT_CHARACTERS.indexOf(c);
value = value * 83 + digit;
}

View file

@ -8,15 +8,15 @@ import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { counterRenderer } from 'mastodon/components/common_counter';
import { EmptyAccount } from 'mastodon/components/empty_account';
import ShortNumber from 'mastodon/components/short_number';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { me } from '../initial_state';
import { Avatar } from './avatar';
import Button from './button';
import { FollowersCounter } from './counters';
import { DisplayName } from './display_name';
import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp';
@ -160,7 +160,7 @@ class Account extends ImmutablePureComponent {
<DisplayName account={account} />
{!minimal && (
<div className='account__details'>
<ShortNumber value={account.get('followers_count')} renderer={counterRenderer('followers')} /> {verification} {muteTimeRemaining}
<ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} /> {verification} {muteTimeRemaining}
</div>
)}
</div>

View file

@ -4,7 +4,7 @@ import { TransitionMotion, spring } from 'react-motion';
import { reduceMotion } from '../initial_state';
import ShortNumber from './short_number';
import { ShortNumber } from './short_number';
const obfuscatedCount = (count: number) => {
if (count < 0) {
@ -32,7 +32,7 @@ export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => {
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
const willLeave = useCallback(
() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }),
[direction]
[direction],
);
if (reduceMotion) {

View file

@ -1,16 +1,16 @@
import { FormattedMessage } from 'react-intl';
import ShortNumber from 'mastodon/components/short_number';
import { ShortNumber } from 'mastodon/components/short_number';
interface Props {
tag: {
name: string;
url?: string;
history?: Array<{
history?: {
uses: number;
accounts: string;
day: string;
}>;
}[];
following?: boolean;
type: 'hashtag';
};

View file

@ -5,7 +5,7 @@ import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';
interface Props {
account: Account;
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
size: number;
style?: React.CSSProperties;
inline?: boolean;

View file

@ -3,8 +3,8 @@ import type { Account } from '../../types/resources';
import { autoPlayGif } from '../initial_state';
interface Props {
account: Account;
friend: Account;
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
size?: number;
baseSize?: number;
overlaySize?: number;

View file

@ -1,60 +0,0 @@
// @ts-check
import { FormattedMessage } from 'react-intl';
/**
* Returns custom renderer for one of the common counter types
* @param {"statuses" | "following" | "followers"} counterType
* Type of the counter
* @param {boolean} isBold Whether display number must be displayed in bold
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
* Renderer function
* @throws If counterType is not covered by this function
*/
export function counterRenderer(counterType, isBold = true) {
/**
* @type {(displayNumber: JSX.Element) => JSX.Element}
*/
const renderCounter = isBold
? (displayNumber) => <strong>{displayNumber}</strong>
: (displayNumber) => displayNumber;
switch (counterType) {
case 'statuses': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'following': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'followers': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
}
}

View file

@ -0,0 +1,45 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
export const StatusesCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.statuses_counter'
defaultMessage='{count, plural, one {{counter} Post} other {{counter} Posts}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
export const FollowingCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.following_counter'
defaultMessage='{count, plural, one {{counter} Following} other {{counter} Following}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);
export const FollowersCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.followers_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);

View file

@ -1,55 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import { bannerSettings } from 'mastodon/settings';
import { IconButton } from './icon_button';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
class DismissableBanner extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
children: PropTypes.node,
intl: PropTypes.object.isRequired,
};
state = {
visible: !bannerSettings.get(this.props.id),
};
handleDismiss = () => {
const { id } = this.props;
this.setState({ visible: false }, () => bannerSettings.set(id, true));
};
render () {
const { visible } = this.state;
if (!visible) {
return null;
}
const { children, intl } = this.props;
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>
{children}
</div>
<div className='dismissable-banner__action'>
<IconButton icon='times' title={intl.formatMessage(messages.dismiss)} onClick={this.handleDismiss} />
</div>
</div>
);
}
}
export default injectIntl(DismissableBanner);

View file

@ -0,0 +1,47 @@
import type { PropsWithChildren } from 'react';
import { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { bannerSettings } from 'mastodon/settings';
import { IconButton } from './icon_button';
const messages = defineMessages({
dismiss: { id: 'dismissable_banner.dismiss', defaultMessage: 'Dismiss' },
});
interface Props {
id: string;
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const [visible, setVisible] = useState(!bannerSettings.get(id));
const intl = useIntl();
const handleDismiss = useCallback(() => {
setVisible(false);
bannerSettings.set(id, true);
}, [id]);
if (!visible) {
return null;
}
return (
<div className='dismissable-banner'>
<div className='dismissable-banner__message'>{children}</div>
<div className='dismissable-banner__action'>
<IconButton
icon='times'
title={intl.formatMessage(messages.dismiss)}
onClick={handleDismiss}
/>
</div>
</div>
);
};

View file

@ -78,7 +78,7 @@ export class DisplayName extends React.PureComponent<Props> {
} else if (account) {
let acct = account.get('acct');
if (acct.indexOf('@') === -1 && localDomain) {
if (!acct.includes('@') && localDomain) {
acct = `${acct}@${localDomain}`;
}

View file

@ -32,7 +32,7 @@ export const GIFV: React.FC<Props> = ({
onClick();
}
},
[onClick]
[onClick],
);
return (

View file

@ -11,7 +11,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import ShortNumber from 'mastodon/components/short_number';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
class SilentErrorBoundary extends Component {

View file

@ -321,7 +321,10 @@ class MediaGallery extends PureComponent {
if (uncached) {
spoilerButton = (
<button type='button' disabled className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'><FormattedMessage id='status.uncached_media_warning' defaultMessage='Not available' /></span>
<span className='spoiler-button__overlay__label'>
<FormattedMessage id='status.uncached_media_warning' defaultMessage='Preview not available' />
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.open' defaultMessage='Click to open' /></span>
</span>
</button>
);
} else if (visible) {
@ -329,7 +332,10 @@ class MediaGallery extends PureComponent {
} else {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
<span className='spoiler-button__overlay__label'>
{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button>
);
}

View file

@ -108,7 +108,7 @@ export const timeAgoString = (
now: number,
year: number,
timeGiven: boolean,
short?: boolean
short?: boolean,
) => {
const delta = now - date.getTime();
@ -118,28 +118,28 @@ export const timeAgoString = (
relativeTime = intl.formatMessage(messages.today);
} else if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(
short ? messages.just_now : messages.just_now_full
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) }
{ number: Math.floor(delta / SECOND) },
);
} else if (delta < HOUR) {
relativeTime = intl.formatMessage(
short ? messages.minutes : messages.minutes_full,
{ number: Math.floor(delta / MINUTE) }
{ number: Math.floor(delta / MINUTE) },
);
} else if (delta < DAY) {
relativeTime = intl.formatMessage(
short ? messages.hours : messages.hours_full,
{ number: Math.floor(delta / HOUR) }
{ number: Math.floor(delta / HOUR) },
);
} else {
relativeTime = intl.formatMessage(
short ? messages.days : messages.days_full,
{ number: Math.floor(delta / DAY) }
{ number: Math.floor(delta / DAY) },
);
}
} else if (date.getFullYear() === year) {
@ -158,7 +158,7 @@ const timeRemainingString = (
intl: IntlShape,
date: Date,
now: number,
timeGiven = true
timeGiven = true,
) => {
const delta = date.getTime() - now;

View file

@ -0,0 +1,23 @@
import type { PropsWithChildren } from 'react';
import React from 'react';
import type { History } from 'history';
import { createBrowserHistory } from 'history';
import { Router as OriginalRouter } from 'react-router';
import { layoutFromWindow } from 'mastodon/is_mobile';
const browserHistory = createBrowserHistory();
const originalPush = browserHistory.push.bind(browserHistory);
browserHistory.push = (path: string, state: History.LocationState) => {
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) {
originalPush(`/deck${path}`, state);
} else {
originalPush(path, state);
}
};
export const Router: React.FC<PropsWithChildren> = ({ children }) => {
return <OriginalRouter history={browserHistory}>{children}</OriginalRouter>;
};

View file

@ -9,7 +9,7 @@ import { connect } from 'react-redux';
import { fetchServer } from 'mastodon/actions/server';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
import ShortNumber from 'mastodon/components/short_number';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
import Account from 'mastodon/containers/account_container';
import { domain } from 'mastodon/initial_state';

View file

@ -1,115 +0,0 @@
import PropTypes from 'prop-types';
import { memo } from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
// @ts-check
/**
* @callback ShortNumberRenderer
* @param {JSX.Element} displayNumber Number to display
* @param {number} pluralReady Number used for pluralization
* @returns {JSX.Element} Final render of number
*/
/**
* @typedef {object} ShortNumberProps
* @property {number} value Number to display in short variant
* @property {ShortNumberRenderer} [renderer]
* Custom renderer for numbers, provided as a prop. If another renderer
* passed as a child of this component, this prop won't be used.
* @property {ShortNumberRenderer} [children]
* Custom renderer for numbers, provided as a child. If another renderer
* passed as a prop of this component, this one will be used instead.
*/
/**
* Component that renders short big number to a shorter version
* @param {ShortNumberProps} param0 Props for the component
* @returns {JSX.Element} Rendered number
*/
function ShortNumber({ value, renderer, children }) {
const shortNumber = toShortNumber(value);
const [, division] = shortNumber;
if (children != null && renderer != null) {
console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
}
const customRenderer = children != null ? children : renderer;
const displayNumber = <ShortNumberCounter value={shortNumber} />;
return customRenderer != null
? customRenderer(displayNumber, pluralReady(value, division))
: displayNumber;
}
ShortNumber.propTypes = {
value: PropTypes.number.isRequired,
renderer: PropTypes.func,
children: PropTypes.func,
};
/**
* @typedef {object} ShortNumberCounterProps
* @property {import('../utils/number').ShortNumber} value Short number
*/
/**
* Renders short number into corresponding localizable react fragment
* @param {ShortNumberCounterProps} param0 Props for the component
* @returns {JSX.Element} FormattedMessage ready to be embedded in code
*/
function ShortNumberCounter({ value }) {
const [rawNumber, unit, maxFractionDigits = 0] = value;
const count = (
<FormattedNumber
value={rawNumber}
maximumFractionDigits={maxFractionDigits}
/>
);
let values = { count, rawNumber };
switch (unit) {
case DECIMAL_UNITS.THOUSAND: {
return (
<FormattedMessage
id='units.short.thousand'
defaultMessage='{count}K'
values={values}
/>
);
}
case DECIMAL_UNITS.MILLION: {
return (
<FormattedMessage
id='units.short.million'
defaultMessage='{count}M'
values={values}
/>
);
}
case DECIMAL_UNITS.BILLION: {
return (
<FormattedMessage
id='units.short.billion'
defaultMessage='{count}B'
values={values}
/>
);
}
// Not sure if we should go farther - @Sasha-Sorokin
default: return count;
}
}
ShortNumberCounter.propTypes = {
value: PropTypes.arrayOf(PropTypes.number),
};
export default memo(ShortNumber);

Some files were not shown because too many files have changed in this diff Show more