Merge pull request #2644 from ClearlyClaire/glitch-soc/merge-dreaded-upstream-composer-change

Merge and port upstream's composer change
main
Claire 2024-02-24 13:46:26 +01:00 committed by GitHub
commit 25ac55ecd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
151 changed files with 4207 additions and 4974 deletions

View File

@ -165,7 +165,7 @@ module.exports = defineConfig({
// }, // },
// ], // ],
'jsx-a11y/no-noninteractive-tabindex': 'off', 'jsx-a11y/no-noninteractive-tabindex': 'off',
'jsx-a11y/no-onchange': 'warn', 'jsx-a11y/no-onchange': 'off',
// recommended is full 'error' // recommended is full 'error'
'jsx-a11y/no-static-element-interactions': [ 'jsx-a11y/no-static-element-interactions': [
'warn', 'warn',

View File

@ -21,7 +21,6 @@ let fetchComposeSuggestionsAccountsController;
let fetchComposeSuggestionsTagsController; let fetchComposeSuggestionsTagsController;
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
@ -59,7 +58,7 @@ export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE'; export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE';
export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE'; export const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE';
@ -117,12 +116,6 @@ export function changeCompose(text) {
}; };
} }
export function cycleElefriendCompose() {
return {
type: COMPOSE_CYCLE_ELEFRIEND,
};
}
export function replyCompose(status, routerHistory) { export function replyCompose(status, routerHistory) {
return (dispatch, getState) => { return (dispatch, getState) => {
const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']); const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']);
@ -148,13 +141,13 @@ export function resetCompose() {
}; };
} }
export const focusCompose = (routerHistory, defaultText) => dispatch => { export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => {
dispatch({ dispatch({
type: COMPOSE_FOCUS, type: COMPOSE_FOCUS,
defaultText, defaultText,
}); });
ensureComposeIsVisible(routerHistory); ensureComposeIsVisible(getState, routerHistory);
}; };
export function mentionCompose(account, routerHistory) { export function mentionCompose(account, routerHistory) {
@ -179,7 +172,7 @@ export function directCompose(account, routerHistory) {
}; };
} }
export function submitCompose(routerHistory) { export function submitCompose(routerHistory, overridePrivacy = null) {
return function (dispatch, getState) { return function (dispatch, getState) {
let status = getState().getIn(['compose', 'text'], ''); let status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
@ -228,7 +221,7 @@ export function submitCompose(routerHistory) {
media_attributes, media_attributes,
sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
spoiler_text: spoilerText, spoiler_text: spoilerText,
visibility: getState().getIn(['compose', 'privacy']), visibility: overridePrivacy || getState().getIn(['compose', 'privacy']),
poll: getState().getIn(['compose', 'poll'], null), poll: getState().getIn(['compose', 'poll'], null),
language: getState().getIn(['compose', 'language']), language: getState().getIn(['compose', 'language']),
}, },
@ -246,11 +239,6 @@ export function submitCompose(routerHistory) {
dispatch(insertIntoTagHistory(response.data.tags, status)); dispatch(insertIntoTagHistory(response.data.tags, status));
dispatch(submitComposeSuccess({ ...response.data })); dispatch(submitComposeSuccess({ ...response.data }));
// If the response has no data then we can't do anything else.
if (!response.data) {
return;
}
// To make the app more responsive, immediately push the status // To make the app more responsive, immediately push the status
// into the columns // into the columns
const insertIfOnline = timelineId => { const insertIfOnline = timelineId => {
@ -660,15 +648,19 @@ export const readyComposeSuggestionsTags = (token, tags) => ({
export function selectComposeSuggestion(position, token, suggestion, path) { export function selectComposeSuggestion(position, token, suggestion, path) {
return (dispatch, getState) => { return (dispatch, getState) => {
let completion; let completion, startPosition;
if (suggestion.type === 'emoji') { if (suggestion.type === 'emoji') {
completion = suggestion.native || suggestion.colons; completion = suggestion.native || suggestion.colons;
startPosition = position - 1;
dispatch(useEmoji(suggestion)); dispatch(useEmoji(suggestion));
} else if (suggestion.type === 'hashtag') { } else if (suggestion.type === 'hashtag') {
completion = `#${suggestion.name}`; completion = `#${suggestion.name}`;
startPosition = position - 1;
} else if (suggestion.type === 'account') { } else if (suggestion.type === 'account') {
completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']); completion = getState().getIn(['accounts', suggestion.id, 'acct']);
startPosition = position;
} }
// We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that // We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
@ -676,7 +668,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) { if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
dispatch({ dispatch({
type: COMPOSE_SUGGESTION_SELECT, type: COMPOSE_SUGGESTION_SELECT,
position, position: startPosition,
token, token,
completion, completion,
path, path,
@ -684,7 +676,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
} else { } else {
dispatch({ dispatch({
type: COMPOSE_SUGGESTION_IGNORE, type: COMPOSE_SUGGESTION_IGNORE,
position, position: startPosition,
token, token,
completion, completion,
path, path,
@ -786,18 +778,26 @@ export function changeComposeVisibility(value) {
}; };
} }
export function changeComposeContentType(value) { export function insertEmojiCompose(position, emoji, needsSpace) {
return {
type: COMPOSE_CONTENT_TYPE_CHANGE,
value,
};
}
export function insertEmojiCompose(position, emoji) {
return { return {
type: COMPOSE_EMOJI_INSERT, type: COMPOSE_EMOJI_INSERT,
position, position,
emoji, emoji,
needsSpace,
};
}
export function changeComposing(value) {
return {
type: COMPOSE_COMPOSING_CHANGE,
value,
};
}
export function changeComposeContentType(value) {
return {
type: COMPOSE_CONTENT_TYPE_CHANGE,
value,
}; };
} }

View File

@ -37,10 +37,10 @@ class Account extends ImmutablePureComponent {
static propTypes = { static propTypes = {
size: PropTypes.number, size: PropTypes.number,
account: ImmutablePropTypes.record, account: ImmutablePropTypes.record,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func,
onMute: PropTypes.func.isRequired, onMute: PropTypes.func,
onMuteNotifications: PropTypes.func.isRequired, onMuteNotifications: PropTypes.func,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
minimal: PropTypes.bool, minimal: PropTypes.bool,

View File

@ -35,7 +35,7 @@ export default class AutosuggestEmoji extends PureComponent {
alt={emoji.native || emoji.colons} alt={emoji.native || emoji.colons}
/> />
{emoji.colons} <div className='autosuggest-emoji__name'>{emoji.colons}</div>
</div> </div>
); );
} }

View File

@ -1,5 +1,3 @@
import { FormattedMessage } from 'react-intl';
import { ShortNumber } from 'flavours/glitch/components/short_number'; import { ShortNumber } from 'flavours/glitch/components/short_number';
interface Props { interface Props {
@ -16,27 +14,18 @@ interface Props {
}; };
} }
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => { export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => (
const weeklyUses = tag.history && ( <div className='autosuggest-hashtag'>
<ShortNumber <div className='autosuggest-hashtag__name'>
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)} #<strong>{tag.name}</strong>
/>
);
return (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<FormattedMessage
id='autosuggest_hashtag.per_week'
defaultMessage='{count} per week'
values={{ count: weeklyUses }}
/>
</div>
)}
</div> </div>
);
}; {tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
</div>
)}
</div>
);

View File

@ -5,6 +5,8 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Overlay from 'react-overlays/Overlay';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestEmoji from './autosuggest_emoji';
@ -13,8 +15,8 @@ import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word; let word;
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); let left = str.slice(0, caretPosition).search(/\S+$/);
let right = str.slice(caretPosition).search(/[\s\u200B]/); let right = str.slice(caretPosition).search(/\s/);
if (right < 0) { if (right < 0) {
word = str.slice(left); word = str.slice(left);
@ -29,7 +31,7 @@ const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
word = word.trim().toLowerCase(); word = word.trim().toLowerCase();
if (word.length > 0) { if (word.length > 0) {
return [left, word]; return [left + 1, word];
} else { } else {
return [null, null]; return [null, null];
} }
@ -195,34 +197,37 @@ export default class AutosuggestInput extends ImmutablePureComponent {
return ( return (
<div className='autosuggest-input'> <div className='autosuggest-input'>
<label> <input
<span style={{ display: 'none' }}>{placeholder}</span> type='text'
ref={this.setInput}
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
dir='auto'
aria-autocomplete='list'
aria-label={placeholder}
id={id}
className={className}
maxLength={maxLength}
lang={lang}
spellCheck={spellCheck}
/>
<input <Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
type='text' {({ props }) => (
ref={this.setInput} <div {...props}>
disabled={disabled} <div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
placeholder={placeholder} {suggestions.map(this.renderSuggestion)}
autoFocus={autoFocus} </div>
value={value} </div>
onChange={this.onChange} )}
onKeyDown={this.onKeyDown} </Overlay>
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
dir='auto'
aria-autocomplete='list'
id={id}
className={className}
maxLength={maxLength}
lang={lang}
spellCheck={spellCheck}
/>
</label>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div> </div>
); );
} }

View File

@ -5,6 +5,7 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Overlay from 'react-overlays/Overlay';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
@ -15,8 +16,8 @@ import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition) => { const textAtCursorMatchesToken = (str, caretPosition) => {
let word; let word;
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); let left = str.slice(0, caretPosition).search(/\S+$/);
let right = str.slice(caretPosition).search(/[\s\u200B]/); let right = str.slice(caretPosition).search(/\s/);
if (right < 0) { if (right < 0) {
word = str.slice(left); word = str.slice(left);
@ -31,7 +32,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
word = word.trim().toLowerCase(); word = word.trim().toLowerCase();
if (word.length > 0) { if (word.length > 0) {
return [left, word]; return [left + 1, word];
} else { } else {
return [null, null]; return [null, null];
} }
@ -52,7 +53,6 @@ const AutosuggestTextarea = forwardRef(({
onFocus, onFocus,
autoFocus = true, autoFocus = true,
lang, lang,
children,
}, textareaRef) => { }, textareaRef) => {
const [suggestionsHidden, setSuggestionsHidden] = useState(true); const [suggestionsHidden, setSuggestionsHidden] = useState(true);
@ -183,40 +183,38 @@ const AutosuggestTextarea = forwardRef(({
); );
}; };
return [ return (
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> <div className='autosuggest-textarea'>
<div className='autosuggest-textarea'> <Textarea
<label> ref={textareaRef}
<span style={{ display: 'none' }}>{placeholder}</span> className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={onKeyUp}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
dir='auto'
aria-autocomplete='list'
aria-label={placeholder}
lang={lang}
/>
<Textarea <Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
ref={textareaRef} {({ props }) => (
className='autosuggest-textarea__textarea' <div {...props}>
disabled={disabled} <div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
placeholder={placeholder} {suggestions.map(renderSuggestion)}
autoFocus={autoFocus} </div>
value={value} </div>
onChange={handleChange} )}
onKeyDown={handleKeyDown} </Overlay>
onKeyUp={onKeyUp} </div>
onFocus={handleFocus} );
onBlur={handleBlur}
onPaste={handlePaste}
dir='auto'
aria-autocomplete='list'
lang={lang}
/>
</label>
</div>
{children}
</div>,
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(renderSuggestion)}
</div>
</div>,
];
}); });
AutosuggestTextarea.propTypes = { AutosuggestTextarea.propTypes = {
@ -232,7 +230,6 @@ AutosuggestTextarea.propTypes = {
onKeyDown: PropTypes.func, onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired,
onFocus:PropTypes.func, onFocus:PropTypes.func,
children: PropTypes.node,
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
lang: PropTypes.string, lang: PropTypes.string,
}; };

View File

@ -165,7 +165,7 @@ class Dropdown extends PureComponent {
children: PropTypes.node, children: PropTypes.node,
icon: PropTypes.string, icon: PropTypes.string,
iconComponent: PropTypes.func, iconComponent: PropTypes.func,
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired, items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]),
loading: PropTypes.bool, loading: PropTypes.bool,
size: PropTypes.number, size: PropTypes.number,
title: PropTypes.string, title: PropTypes.string,

View File

@ -1,9 +1,9 @@
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
import MailIcon from '@/material-icons/400-24px/mail.svg?react'; import MailIcon from '@/material-icons/400-24px/mail.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import { Icon } from './icon'; import { Icon } from './icon';
@ -11,14 +11,17 @@ type Visibility = 'public' | 'unlisted' | 'private' | 'direct';
const messages = defineMessages({ const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, unlisted_short: {
id: 'privacy.unlisted.short',
defaultMessage: 'Quiet public',
},
private_short: { private_short: {
id: 'privacy.private.short', id: 'privacy.private.short',
defaultMessage: 'Followers only', defaultMessage: 'Followers',
}, },
direct_short: { direct_short: {
id: 'privacy.direct.short', id: 'privacy.direct.short',
defaultMessage: 'Mentioned people only', defaultMessage: 'Specific people',
}, },
}); });
@ -35,7 +38,7 @@ export const VisibilityIcon: React.FC<{ visibility: Visibility }> = ({
}, },
unlisted: { unlisted: {
icon: 'unlock', icon: 'unlock',
iconComponent: LockOpenIcon, iconComponent: QuietTimeIcon,
text: intl.formatMessage(messages.unlisted_short), text: intl.formatMessage(messages.unlisted_short),
}, },
private: { private: {

View File

@ -1,14 +1,12 @@
import { PureComponent } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { fetchCustomEmojis } from '../actions/custom_emojis'; import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
import { hydrateStore } from '../actions/store'; import { hydrateStore } from 'flavours/glitch/actions/store';
import Compose from '../features/standalone/compose'; import { Router } from 'flavours/glitch/components/router';
import initialState from '../initial_state'; import Compose from 'flavours/glitch/features/standalone/compose';
import { IntlProvider } from '../locales'; import initialState from 'flavours/glitch/initial_state';
import { store } from '../store'; import { IntlProvider } from 'flavours/glitch/locales';
import { store } from 'flavours/glitch/store';
if (initialState) { if (initialState) {
store.dispatch(hydrateStore(initialState)); store.dispatch(hydrateStore(initialState));
@ -16,16 +14,14 @@ if (initialState) {
store.dispatch(fetchCustomEmojis()); store.dispatch(fetchCustomEmojis());
export default class ComposeContainer extends PureComponent { const ComposeContainer = () => (
<IntlProvider>
<Provider store={store}>
<Router>
<Compose />
</Router>
</Provider>
</IntlProvider>
);
render () { export default ComposeContainer;
return (
<IntlProvider>
<Provider store={store}>
<Compose />
</Provider>
</IntlProvider>
);
}
}

View File

@ -1,15 +1,13 @@
import PropTypes from 'prop-types'; import { useCallback } from 'react';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes'; import { useDispatch } from 'react-redux';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { preferencesLink, profileLink } from 'flavours/glitch/utils/backend_links'; import { openModal } from 'flavours/glitch/actions/modal';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { logOut } from 'flavours/glitch/utils/log_out';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
const messages = defineMessages({ const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
@ -25,51 +23,52 @@ const messages = defineMessages({
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' }, filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
}); });
class ActionBar extends PureComponent { export const ActionBar = () => {
const dispatch = useDispatch();
const intl = useIntl();
static propTypes = { const handleLogoutClick = useCallback(() => {
account: ImmutablePropTypes.record.isRequired, dispatch(openModal({
onLogout: PropTypes.func.isRequired, modalType: 'CONFIRM',
intl: PropTypes.object.isRequired, modalProps: {
}; message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
}, [dispatch, intl]);
handleLogout = () => { let menu = [];
this.props.onLogout();
};
render () { menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
const { intl } = this.props; menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.logout), action: handleLogoutClick });
let menu = []; return (
<DropdownMenuContainer
menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink }); items={menu}
menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink }); icon='bars'
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); iconComponent={MoreHorizIcon}
menu.push(null); size={24}
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); direction='right'
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); />
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); );
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); };
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
return (
<div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'>
<DropdownMenuContainer items={menu} icon='bars' iconComponent={MenuIcon} size={24} direction='right' />
</div>
</div>
);
}
}
export default injectIntl(ActionBar);

View File

@ -15,8 +15,8 @@ export default class AutosuggestAccount extends ImmutablePureComponent {
return ( return (
<div className='autosuggest-account' title={account.get('acct')}> <div className='autosuggest-account' title={account.get('acct')}>
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div> <Avatar account={account} size={24} />
<DisplayName account={account} inline /> <DisplayName account={account} />
</div> </div>
); );
} }

View File

@ -1,26 +1,18 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { length } from 'stringz'; import { length } from 'stringz';
export default class CharacterCounter extends PureComponent { export const CharacterCounter = ({ text, max }) => {
const diff = max - length(text);
static propTypes = { if (diff < 0) {
text: PropTypes.string.isRequired, return <span className='character-counter character-counter--over'>{diff}</span>;
max: PropTypes.number.isRequired,
};
checkRemainingText (diff) {
if (diff < 0) {
return <span className='character-counter character-counter--over'>{diff}</span>;
}
return <span className='character-counter'>{diff}</span>;
} }
render () { return <span className='character-counter'>{diff}</span>;
const diff = this.props.max - length(this.props.text); };
return this.checkRemainingText(diff);
}
} CharacterCounter.propTypes = {
text: PropTypes.string.isRequired,
max: PropTypes.number.isRequired,
};

View File

@ -10,35 +10,40 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz'; import { length } from 'stringz';
import { maxChars } from 'flavours/glitch/initial_state';
import { isMobile } from 'flavours/glitch/is_mobile';
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router'; import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
import AutosuggestInput from '../../../components/autosuggest_input'; import AutosuggestInput from '../../../components/autosuggest_input';
import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import { Button } from '../../../components/button';
import { maxChars } from '../../../initial_state';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import OptionsContainer from '../containers/options_container'; import LanguageDropdown from '../containers/language_dropdown_container';
import PollFormContainer from '../containers/poll_form_container'; import PollButtonContainer from '../containers/poll_button_container';
import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import UploadButtonContainer from '../containers/upload_button_container';
import UploadFormContainer from '../containers/upload_form_container'; import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container'; import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter'; import { countableText } from '../util/counter';
import CharacterCounter from './character_counter'; import { CharacterCounter } from './character_counter';
import Publisher from './publisher'; import { ContentTypeButton } from './content_type_button';
import TextareaIcons from './textarea_icons'; import { EditIndicator } from './edit_indicator';
import { FederationButton } from './federation_button';
import { NavigationBar } from './navigation_bar';
import { PollForm } from "./poll_form";
import { ReplyIndicator } from './reply_indicator';
import { SecondaryPrivacyButton } from './secondary_privacy_button';
import { ThreadModeButton } from './thread_mode_button';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
missingDescriptionMessage: { spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning (optional)' },
id: 'confirmations.missing_media_description.message', publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.', saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Update' },
}, reply: { id: 'compose_form.reply', defaultMessage: 'Reply' },
missingDescriptionConfirm: {
id: 'confirmations.missing_media_description.confirm',
defaultMessage: 'Send anyway',
},
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
}); });
class ComposeForm extends ImmutablePureComponent { class ComposeForm extends ImmutablePureComponent {
@ -47,11 +52,14 @@ class ComposeForm extends ImmutablePureComponent {
text: PropTypes.string.isRequired, text: PropTypes.string.isRequired,
suggestions: ImmutablePropTypes.list, suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool, spoiler: PropTypes.bool,
spoilerAlwaysOn: PropTypes.bool,
privacy: PropTypes.string, privacy: PropTypes.string,
sideArm: PropTypes.string,
spoilerText: PropTypes.string, spoilerText: PropTypes.string,
focusDate: PropTypes.instanceOf(Date), focusDate: PropTypes.instanceOf(Date),
caretPosition: PropTypes.number, caretPosition: PropTypes.number,
preselectDate: PropTypes.instanceOf(Date), preselectDate: PropTypes.instanceOf(Date),
preselectOnReply: PropTypes.bool,
isSubmitting: PropTypes.bool, isSubmitting: PropTypes.bool,
isChangingUpload: PropTypes.bool, isChangingUpload: PropTypes.bool,
isEditing: PropTypes.bool, isEditing: PropTypes.bool,
@ -64,26 +72,20 @@ class ComposeForm extends ImmutablePureComponent {
onChangeSpoilerText: PropTypes.func.isRequired, onChangeSpoilerText: PropTypes.func.isRequired,
onPaste: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool, autoFocus: PropTypes.bool,
withoutNavigation: PropTypes.bool,
anyMedia: PropTypes.bool, anyMedia: PropTypes.bool,
media: ImmutablePropTypes.list,
mediaDescriptionConfirmation: PropTypes.bool,
onMediaDescriptionConfirm: PropTypes.func.isRequired,
isInReply: PropTypes.bool, isInReply: PropTypes.bool,
singleColumn: PropTypes.bool, singleColumn: PropTypes.bool,
lang: PropTypes.string, lang: PropTypes.string,
advancedOptions: ImmutablePropTypes.map,
media: ImmutablePropTypes.list,
sideArm: PropTypes.string,
sensitive: PropTypes.bool,
spoilersAlwaysOn: PropTypes.bool,
mediaDescriptionConfirmation: PropTypes.bool,
preselectOnReply: PropTypes.bool,
onChangeSpoilerness: PropTypes.func.isRequired,
onChangeVisibility: PropTypes.func.isRequired,
onMediaDescriptionConfirm: PropTypes.func.isRequired,
...WithOptionalRouterPropTypes ...WithOptionalRouterPropTypes
}; };
static defaultProps = { static defaultProps = {
showSearch: false, autoFocus: false,
}; };
state = { state = {
@ -101,30 +103,27 @@ class ComposeForm extends ImmutablePureComponent {
handleKeyDown = (e) => { handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit(); this.handleSubmit(e);
} }
if (e.keyCode === 13 && e.altKey) { if (e.keyCode === 13 && e.altKey) {
this.handleSecondarySubmit(); this.handleSecondarySubmit(e);
} }
}; };
getFulltextForCharacterCounting = () => { getFulltextForCharacterCounting = () => {
return [ return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join('');
this.props.spoiler? this.props.spoilerText: '',
countableText(this.props.text),
this.props.advancedOptions && this.props.advancedOptions.get('do_not_federate') ? ' 👁️' : '',
].join('');
}; };
canSubmit = () => { canSubmit = () => {
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
const fulltext = this.getFulltextForCharacterCounting(); const fulltext = this.getFulltextForCharacterCounting();
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (!fulltext.trim().length && !anyMedia)); return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (isOnlyWhitespace && !anyMedia));
}; };
handleSubmit = (e, overriddenVisibility = null) => { handleSubmit = (e, overridePrivacy = null) => {
if (this.props.text !== this.textareaRef.current.value) { if (this.props.text !== this.textareaRef.current.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly) // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text // Update the state to match the current text
@ -142,19 +141,15 @@ class ComposeForm extends ImmutablePureComponent {
// Submit unless there are media with missing descriptions // Submit unless there are media with missing descriptions
if (this.props.mediaDescriptionConfirmation && this.props.media && this.props.media.some(item => !item.get('description'))) { if (this.props.mediaDescriptionConfirmation && this.props.media && this.props.media.some(item => !item.get('description'))) {
const firstWithoutDescription = this.props.media.find(item => !item.get('description')); const firstWithoutDescription = this.props.media.find(item => !item.get('description'));
this.props.onMediaDescriptionConfirm(this.props.history || null, firstWithoutDescription.get('id'), overriddenVisibility); this.props.onMediaDescriptionConfirm(this.props.history || null, firstWithoutDescription.get('id'), overridePrivacy);
} else { } else {
if (overriddenVisibility) { this.props.onSubmit(this.props.history || null, overridePrivacy);
this.props.onChangeVisibility(overriddenVisibility);
}
this.props.onSubmit(this.props.history || null);
} }
}; };
// Handles the secondary submit button. handleSecondarySubmit = (e) => {
handleSecondarySubmit = () => {
const { sideArm } = this.props; const { sideArm } = this.props;
this.handleSubmit(null, sideArm === 'none' ? null : sideArm); this.handleSubmit(e, sideArm === 'none' ? null : sideArm);
}; };
onSuggestionsClearRequested = () => { onSuggestionsClearRequested = () => {
@ -224,7 +219,6 @@ class ComposeForm extends ImmutablePureComponent {
Promise.resolve().then(() => { Promise.resolve().then(() => {
this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd); this.textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
this.textareaRef.current.focus(); this.textareaRef.current.focus();
if (!this.props.singleColumn) this.textareaRef.current.scrollIntoView();
this.setState({ highlighted: true }); this.setState({ highlighted: true });
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700); this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
}).catch(console.error); }).catch(console.error);
@ -248,103 +242,109 @@ class ComposeForm extends ImmutablePureComponent {
}; };
handleEmojiPick = (data) => { handleEmojiPick = (data) => {
const position = this.textareaRef.current.selectionStart; const { text } = this.props;
const position = this.textareaRef.current.selectionStart;
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
this.props.onPickEmoji(position, data); this.props.onPickEmoji(position, data, needsSpace);
}; };
render () { render () {
const { const { intl, onPaste, autoFocus, withoutNavigation } = this.props;
intl,
advancedOptions,
isSubmitting,
onChangeSpoilerness,
onPaste,
privacy,
sensitive,
showSearch,
sideArm,
spoilersAlwaysOn,
isEditing,
} = this.props;
const { highlighted } = this.state; const { highlighted } = this.state;
const disabled = this.props.isSubmitting; const disabled = this.props.isSubmitting;
return ( return (
<form className='compose-form' onSubmit={this.handleSubmit}> <form className='compose-form' onSubmit={this.handleSubmit}>
<ReplyIndicator />
{!withoutNavigation && <NavigationBar />}
<WarningContainer /> <WarningContainer />
<ReplyIndicatorContainer /> <div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
<div className='compose-form__scrollable'>
<EditIndicator />
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}> {this.props.spoiler && (
<AutosuggestInput <div className='spoiler-input'>
placeholder={intl.formatMessage(messages.spoiler_placeholder)} <div className='spoiler-input__border' />
value={this.props.spoilerText}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
disabled={!this.props.spoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='spoiler-input__input'
lang={this.props.lang}
autoFocus={false}
spellCheck
/>
</div>
<div className={classNames('compose-form__highlightable', { active: highlighted })}> <AutosuggestInput
<AutosuggestTextarea placeholder={intl.formatMessage(messages.spoiler_placeholder)}
ref={this.textareaRef} value={this.props.spoilerText}
placeholder={intl.formatMessage(messages.placeholder)} disabled={disabled}
disabled={disabled} onChange={this.handleChangeSpoilerText}
value={this.props.text} onKeyDown={this.handleKeyDown}
onChange={this.handleChange} ref={this.setSpoilerText}
suggestions={this.props.suggestions} suggestions={this.props.suggestions}
onFocus={this.handleFocus} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onKeyDown={this.handleKeyDown} onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionSelected={this.onSpoilerSuggestionSelected}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} searchTokens={[':']}
onSuggestionSelected={this.onSuggestionSelected} id='cw-spoiler-input'
onPaste={onPaste} className='spoiler-input__input'
autoFocus={!showSearch && !isMobile(window.innerWidth)} lang={this.props.lang}
lang={this.props.lang} spellCheck
> />
<TextareaIcons advancedOptions={advancedOptions} />
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
</AutosuggestTextarea>
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
<div className='compose-form__buttons-wrapper'> <div className='spoiler-input__border' />
<OptionsContainer </div>
advancedOptions={advancedOptions} )}
disabled={isSubmitting}
onToggleSpoiler={this.props.spoilersAlwaysOn ? null : onChangeSpoilerness} <AutosuggestTextarea
onUpload={onPaste} ref={this.textareaRef}
isEditing={isEditing} placeholder={intl.formatMessage(messages.placeholder)}
sensitive={sensitive || (spoilersAlwaysOn && this.props.spoilerText && this.props.spoilerText.length > 0)} disabled={disabled}
spoiler={spoilersAlwaysOn ? (this.props.spoilerText && this.props.spoilerText.length > 0) : this.props.spoiler} value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={autoFocus}
lang={this.props.lang}
/> />
<div className='character-counter__wrapper'> </div>
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
<UploadFormContainer />
<PollForm />
<div className='compose-form__footer'>
<div className='compose-form__dropdowns'>
<PrivacyDropdownContainer disabled={this.props.isEditing} />
<LanguageDropdown />
</div>
<div className='compose-form__actions'>
<div className='compose-form__buttons'>
<UploadButtonContainer />
<PollButtonContainer />
{!this.props.spoilerAlwaysOn && <SpoilerButtonContainer />}
<ContentTypeButton />
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
<FederationButton />
<ThreadModeButton />
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
</div>
<div className='compose-form__submit'>
<SecondaryPrivacyButton
disabled={!this.canSubmit()}
privacy={this.props.sideArm}
isEditing={this.props.isEditing}
onClick={this.handleSecondarySubmit}
/>
<Button
type='submit'
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
disabled={!this.canSubmit()}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
<Publisher
disabled={!this.canSubmit()}
isEditing={isEditing}
onSecondarySubmit={this.handleSecondarySubmit}
privacy={privacy}
sideArm={sideArm}
/>
</form> </form>
); );
} }

View File

@ -0,0 +1,66 @@
import { useCallback } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import CodeIcon from '@/material-icons/400-24px/code.svg?react';
import DescriptionIcon from '@/material-icons/400-24px/description.svg?react';
import MarkdownIcon from '@/material-icons/400-24px/markdown.svg?react';
import { changeComposeContentType } from 'flavours/glitch/actions/compose';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { DropdownIconButton } from './dropdown_icon_button';
const messages = defineMessages({
change_content_type: { id: 'compose.content-type.change', defaultMessage: 'Change advanced formatting options' },
plain_text_label: { id: 'compose.content-type.plain', defaultMessage: 'Plain text' },
plain_text_meta: { id: 'compose.content-type.plain_meta', defaultMessage: 'Write with no advanced formatting' },
markdown_label: { id: 'compose.content-type.markdown', defaultMessage: 'Markdown' },
markdown_meta: { id: 'compose.content-type.markdown_meta', defaultMessage: 'Format your posts using Markdown' },
html_label: { id: 'compose.content-type.html', defaultMessage: 'HTML' },
html_meta: { id: 'compose.content-type.html_meta', defaultMessage: 'Format your posts using HTML' },
});
export const ContentTypeButton = () => {
const intl = useIntl();
const showButton = useAppSelector((state) => state.getIn(['local_settings', 'show_content_type_choice']));
const contentType = useAppSelector((state) => state.getIn(['compose', 'content_type']));
const dispatch = useAppDispatch();
const handleChange = useCallback((value) => {
dispatch(changeComposeContentType(value));
}, [dispatch]);
if (!showButton) {
return null;
}
const options = [
{ icon: 'file-text', iconComponent: DescriptionIcon, value: 'text/plain', text: intl.formatMessage(messages.plain_text_label), meta: intl.formatMessage(messages.plain_text_meta) },
{ icon: 'arrow-circle-down', iconComponent: MarkdownIcon, value: 'text/markdown', text: intl.formatMessage(messages.markdown_label), meta: intl.formatMessage(messages.markdown_meta) },
{ icon: 'code', iconComponent: CodeIcon, value: 'text/html', text: intl.formatMessage(messages.html_label), meta: intl.formatMessage(messages.html_meta) },
];
const icon = {
'text/plain': 'file-text',
'text/markdown': 'arrow-circle-down',
'text/html': 'code',
}[contentType];
const iconComponent = {
'text/plain': DescriptionIcon,
'text/markdown': MarkdownIcon,
'text/html': CodeIcon,
}[contentType];
return (
<DropdownIconButton
icon={icon}
iconComponent={iconComponent}
onChange={handleChange}
options={options}
title={intl.formatMessage(messages.change_content_type)}
value={contentType}
/>
);
};

View File

@ -1,243 +0,0 @@
// Package imports.
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
// Components.
import { IconButton } from 'flavours/glitch/components/icon_button';
import DropdownMenu from './dropdown_menu';
// The component.
export default class ComposerOptionsDropdown extends PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
disabled: PropTypes.bool,
icon: PropTypes.string,
iconComponent: PropTypes.func,
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.string,
iconComponent: PropTypes.func,
meta: PropTypes.string,
name: PropTypes.string.isRequired,
text: PropTypes.string,
})).isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
title: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
container: PropTypes.func,
renderItemContents: PropTypes.func,
closeOnChange: PropTypes.bool,
};
static defaultProps = {
closeOnChange: true,
};
state = {
open: false,
openedViaKeyboard: undefined,
placement: 'bottom',
};
// Toggles opening and closing the dropdown.
handleToggle = ({ type }) => {
const { onModalOpen } = this.props;
const { open } = this.state;
if (this.props.isUserTouching && this.props.isUserTouching()) {
if (open) {
this.props.onModalClose();
} else {
const modal = this.handleMakeModal();
if (modal && onModalOpen) {
onModalOpen(modal);
}
}
} else {
if (open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !open, openedViaKeyboard: type !== 'click' });
}
};
handleKeyDown = (e) => {
switch (e.key) {
case 'Escape':
this.handleClose();
break;
}
};
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
};
handleKeyPress = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleToggle(e);
e.stopPropagation();
e.preventDefault();
break;
}
};
handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: false });
};
handleItemClick = (e) => {
const {
items,
onChange,
onModalClose,
closeOnChange,
} = this.props;
const i = Number(e.currentTarget.getAttribute('data-index'));
const { name } = items[i];
e.preventDefault(); // Prevents focus from changing
if (closeOnChange) onModalClose();
onChange(name);
};
// Creates an action modal object.
handleMakeModal = () => {
const {
items,
onChange,
onModalOpen,
onModalClose,
value,
} = this.props;
// Required props.
if (!(onChange && onModalOpen && onModalClose && items)) {
return null;
}
// The object.
return {
renderItemContents: this.props.renderItemContents,
onClick: this.handleItemClick,
actions: items.map(
({
name,
...rest
}) => ({
...rest,
active: value && name === value,
name,
}),
),
};
};
setTargetRef = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
};
// Rendering.
render () {
const {
disabled,
title,
icon,
iconComponent,
items,
onChange,
value,
container,
renderItemContents,
closeOnChange,
} = this.props;
const { open, placement } = this.state;
return (
<div
className={classNames('privacy-dropdown', placement, { active: open })}
onKeyDown={this.handleKeyDown}
ref={this.setTargetRef}
>
<IconButton
active={open}
className='privacy-dropdown__value-icon'
disabled={disabled}
icon={icon}
iconComponent={iconComponent}
inverted
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
size={18}
style={{
height: null,
lineHeight: '27px',
}}
title={title}
/>
<Overlay
containerPadding={20}
placement={placement}
show={open}
flip
target={this.findTarget}
container={container}
popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}
>
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<DropdownMenu
items={items}
renderItemContents={renderItemContents}
onChange={onChange}
onClose={this.handleClose}
value={value}
openedViaKeyboard={this.state.openedViaKeyboard}
closeOnChange={closeOnChange}
/>
</div>
</div>
)}
</Overlay>
</div>
);
}
}

View File

@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import { useCallback, useState, useRef } from 'react';
import Overlay from 'react-overlays/Overlay';
import { IconButton } from 'flavours/glitch/components/icon_button';
import DropdownMenu from './dropdown_menu';
export const DropdownIconButton = ({ value, disabled, icon, onChange, iconComponent, title, options }) => {
const containerRef = useRef(null);
const [activeElement, setActiveElement] = useState(null);
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState('bottom');
const handleToggle = useCallback(() => {
if (open && activeElement) {
activeElement.focus({ preventScroll: true });
setActiveElement(null);
}
setOpen(!open);
}, [open, setOpen, activeElement, setActiveElement]);
const handleClose = useCallback(() => {
if (open && activeElement) {
activeElement.focus({ preventScroll: true });
setActiveElement(null);
}
setOpen(false);
}, [open, setOpen, activeElement, setActiveElement]);
const handleOverlayEnter = useCallback((state) => {
setPlacement(state.placement);
}, [setPlacement]);
return (
<div ref={containerRef}>
<IconButton
disabled={disabled}
icon={icon}
onClick={handleToggle}
iconComponent={iconComponent}
title={title}
active={open}
size={18}
inverted
/>
<Overlay show={open} offset={[5, 5]} placement={placement} flip target={containerRef} popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}>
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<DropdownMenu
items={options}
value={value}
onClose={handleClose}
onChange={onChange}
/>
</div>
</div>
)}
</Overlay>
</div>
);
};
DropdownIconButton.propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
icon: PropTypes.string,
onChange: PropTypes.func.isRequired,
iconComponent: PropTypes.func.isRequired,
options: PropTypes.array.isRequired,
title: PropTypes.string.isRequired,
};

View File

@ -1,4 +1,3 @@
// Package imports.
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
@ -6,101 +5,34 @@ import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
// Components. import { Icon } from 'flavours/glitch/components/icon';
import { Icon } from 'flavours/glitch/components/icon';
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
// The component. // copied from PrivacyDropdown; will require refactor with upstream down the line
export default class ComposerOptionsDropdownContent extends PureComponent { class DropdownMenu extends PureComponent {
static propTypes = { static propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.string,
iconComponent: PropTypes.func,
meta: PropTypes.node,
name: PropTypes.string.isRequired,
text: PropTypes.node,
})),
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
style: PropTypes.object, style: PropTypes.object,
value: PropTypes.string, items: PropTypes.array.isRequired,
renderItemContents: PropTypes.func, value: PropTypes.string.isRequired,
openedViaKeyboard: PropTypes.bool, onClose: PropTypes.func.isRequired,
closeOnChange: PropTypes.bool, onChange: PropTypes.func.isRequired,
}; };
static defaultProps = { handleDocumentClick = e => {
style: {},
closeOnChange: true,
};
state = {
value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
};
// When the document is clicked elsewhere, we close the dropdown.
handleDocumentClick = (e) => {
if (this.node && !this.node.contains(e.target)) { if (this.node && !this.node.contains(e.target)) {
this.props.onClose(); this.props.onClose();
e.stopPropagation(); e.stopPropagation();
} }
}; };
// Stores our node in `this.node`. handleKeyDown = e => {
setRef = (node) => {
this.node = node;
};
// On mounting, we add our listeners.
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) {
this.focusedItem.focus({ preventScroll: true });
} else {
this.node.firstChild.focus({ preventScroll: true });
}
}
// On unmounting, we remove our listeners.
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
handleClick = (e) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const {
onChange,
onClose,
closeOnChange,
items,
} = this.props;
const { name } = items[i];
e.preventDefault(); // Prevents change in focus on click
if (closeOnChange) {
onClose();
}
onChange(name);
};
// Handle changes differently whether the dropdown is a list of options or actions
handleChange = (name) => {
if (this.props.value) {
this.props.onChange(name);
} else {
this.setState({ value: name });
}
};
handleKeyDown = (e) => {
const index = Number(e.currentTarget.getAttribute('data-index'));
const { items } = this.props; const { items } = this.props;
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => {
return (item.value === value);
});
let element = null; let element = null;
switch(e.key) { switch(e.key) {
@ -108,7 +40,6 @@ export default class ComposerOptionsDropdownContent extends PureComponent {
this.props.onClose(); this.props.onClose();
break; break;
case 'Enter': case 'Enter':
case ' ':
this.handleClick(e); this.handleClick(e);
break; break;
case 'ArrowDown': case 'ArrowDown':
@ -134,72 +65,61 @@ export default class ComposerOptionsDropdownContent extends PureComponent {
if (element) { if (element) {
element.focus(); element.focus();
this.handleChange(items[Number(element.getAttribute('data-index'))].name); this.props.onChange(element.getAttribute('data-index'));
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
}; };
handleClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
};
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
};
setFocusRef = c => { setFocusRef = c => {
this.focusedItem = c; this.focusedItem = c;
}; };
renderItem = (item, i) => {
const { name, icon, iconComponent, meta, text } = item;
const active = (name === (this.props.value || this.state.value));
const computedClass = classNames('privacy-dropdown__option', { active });
let contents = this.props.renderItemContents && this.props.renderItemContents(item, i);
if (!contents) {
contents = (
<>
{icon && (
<div className='privacy-dropdown__option__icon'>
<Icon className='icon' id={icon} icon={iconComponent} />
</div>
)}
<div className='privacy-dropdown__option__content'>
<strong>{text}</strong>
{meta}
</div>
</>
);
}
return (
<div
className={computedClass}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
role='option'
aria-selected={active}
tabIndex={0}
key={name}
data-index={i}
ref={active ? this.setFocusRef : null}
>
{contents}
</div>
);
};
// Rendering.
render () { render () {
const { const { style, items, value } = this.props;
items,
style,
} = this.props;
// The result.
return ( return (
<div style={{ ...style }} role='listbox' ref={this.setRef}> <div style={{ ...style }} role='listbox' ref={this.setRef}>
{!!items && items.map((item, i) => this.renderItem(item, i))} {items.map(item => (
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
<Icon id={item.icon} icon={item.iconComponent} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
))}
</div> </div>
); );
} }
} }
export default DropdownMenu;

View File

@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useDispatch, useSelector } from 'react-redux';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { Permalink } from 'flavours/glitch/components/permalink';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
export const EditIndicator = () => {
const intl = useIntl();
const dispatch = useDispatch();
const id = useSelector(state => state.getIn(['compose', 'id']));
const status = useSelector(state => state.getIn(['statuses', id]));
const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
const handleCancelClick = useCallback(() => {
dispatch(cancelReplyCompose());
}, [dispatch]);
if (!status) {
return null;
}
const content = { __html: status.get('contentHtml') };
return (
<div className='edit-indicator'>
<div className='edit-indicator__header'>
<div className='edit-indicator__display-name'>
<Permalink href={account.get('url')} to={`/@${account.get('acct')}`}>@{account.get('acct')}</Permalink>
·
<Permalink href={status.get('url')} to={`/@${account.get('acct')}/${status.get('id')}`}><RelativeTimestamp timestamp={status.get('created_at')} /></Permalink>
</div>
<div className='edit-indicator__cancel'>
<IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={handleCancelClick} inverted />
</div>
</div>
<div className='edit-indicator__content translate' dangerouslySetInnerHTML={content} />
{(status.get('poll') || status.get('media_attachments').size > 0) && (
<div className='edit-indicator__attachments'>
{status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
{status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
</div>
)}
</div>
);
};

View File

@ -10,6 +10,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import MoodIcon from '@/material-icons/400-24px/mood.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { useSystemEmojiFont } from 'flavours/glitch/initial_state'; import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
import { assetHost } from 'flavours/glitch/utils/config'; import { assetHost } from 'flavours/glitch/utils/config';
@ -323,7 +325,6 @@ class EmojiPickerDropdown extends PureComponent {
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired, skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
}; };
state = { state = {
@ -381,23 +382,24 @@ class EmojiPickerDropdown extends PureComponent {
}; };
render () { render () {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props; const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const { active, loading } = this.state; const { active, loading } = this.state;
return ( return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}>
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> <IconButton
{button || <img title={title}
className={classNames('emojione', { 'pulse-loading': active && loading })} aria-expanded={active}
alt='🙂' active={active}
src={`${assetHost}/emoji/1f642.svg`} iconComponent={MoodIcon}
/>} onClick={this.onToggle}
</div> inverted
/>
<Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}> <Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
{({ props, placement })=> ( {({ props, placement })=> (
<div {...props} style={{ ...props.style, width: 299 }}> <div {...props} style={{ ...props.style }}>
<div className={`dropdown-animation ${placement}`}> <div className={`dropdown-animation ${placement}`}>
<EmojiPickerMenu <EmojiPickerMenu
custom_emojis={this.props.custom_emojis} custom_emojis={this.props.custom_emojis}

View File

@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import ShareOffIcon from '@/material-icons/400-24px/share_off.svg?react';
import { changeComposeAdvancedOption } from 'flavours/glitch/actions/compose';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { DropdownIconButton } from './dropdown_icon_button';
const messages = defineMessages({
change_federation_settings: { id: 'compose.change_federation', defaultMessage: 'Change federation settings' },
local_only_label: { id: 'federation.local_only.short', defaultMessage: 'Local-only' },
local_only_meta: { id: 'federation.local_only.long', defaultMessage: 'Prevent this post from reaching other servers' },
federated_label: { id: 'federation.federated.short', defaultMessage: 'Federated' },
federated_meta: { id: 'federation.federated.long', defaultMessage: 'Allow this post to reach other servers' },
});
export const FederationButton = () => {
const intl = useIntl();
const isEditing = useAppSelector((state) => state.getIn(['compose', 'id']) !== null);
const do_not_federate = useAppSelector((state) => state.getIn(['compose', 'advanced_options', 'do_not_federate']));
const dispatch = useAppDispatch();
const handleChange = useCallback((value) => {
dispatch(changeComposeAdvancedOption('do_not_federate', value === 'local-only'));
}, [dispatch]);
const options = [
{ icon: 'link', iconComponent: ShareIcon, value: 'federated', text: intl.formatMessage(messages.federated_label), meta: intl.formatMessage(messages.federated_meta) },
{ icon: 'link-slash', iconComponent: ShareOffIcon, value: 'local-only', text: intl.formatMessage(messages.local_only_label), meta: intl.formatMessage(messages.local_only_meta) },
];
return (
<DropdownIconButton
disabled={isEditing}
icon={do_not_federate ? 'link-slash' : 'link'}
iconComponent={do_not_federate ? ShareOffIcon : ShareIcon}
onChange={handleChange}
options={options}
title={intl.formatMessage(messages.change_federation_settings)}
value={do_not_federate ? 'local-only' : 'federated'}
/>
);
};

View File

@ -1,149 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import { signOutLink } from 'flavours/glitch/utils/backend_links';
import { conditionalRender } from 'flavours/glitch/utils/react_helpers';
const messages = defineMessages({
community: {
defaultMessage: 'Local timeline',
id: 'navigation_bar.community_timeline',
},
home_timeline: {
defaultMessage: 'Home',
id: 'tabs_bar.home',
},
logout: {
defaultMessage: 'Logout',
id: 'navigation_bar.logout',
},
notifications: {
defaultMessage: 'Notifications',
id: 'tabs_bar.notifications',
},
public: {
defaultMessage: 'Federated timeline',
id: 'navigation_bar.public_timeline',
},
settings: {
defaultMessage: 'App settings',
id: 'navigation_bar.app_settings',
},
start: {
defaultMessage: 'Getting started',
id: 'getting_started.heading',
},
});
class Header extends ImmutablePureComponent {
static propTypes = {
columns: ImmutablePropTypes.list,
unreadNotifications: PropTypes.number,
showNotificationsBadge: PropTypes.bool,
intl: PropTypes.object,
onSettingsClick: PropTypes.func,
onLogout: PropTypes.func.isRequired,
};
handleLogoutClick = e => {
e.preventDefault();
e.stopPropagation();
this.props.onLogout();
return false;
};
render () {
const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
// Only renders the component if the column isn't being shown.
const renderForColumn = conditionalRender.bind(null,
columnId => !columns || !columns.some(
column => column.get('id') === columnId,
),
);
// The result.
return (
<nav className='drawer__header'>
<Link
aria-label={intl.formatMessage(messages.start)}
title={intl.formatMessage(messages.start)}
to='/getting-started'
className='drawer__tab'
><Icon id='bars' icon={MenuIcon} /></Link>
{renderForColumn('HOME', (
<Link
aria-label={intl.formatMessage(messages.home_timeline)}
title={intl.formatMessage(messages.home_timeline)}
to='/home'
className='drawer__tab'
><Icon id='home' icon={HomeIcon} /></Link>
))}
{renderForColumn('NOTIFICATIONS', (
<Link
aria-label={intl.formatMessage(messages.notifications)}
title={intl.formatMessage(messages.notifications)}
to='/notifications'
className='drawer__tab'
>
<span className='icon-badge-wrapper'>
<Icon id='bell' icon={NotificationsIcon} />
{ showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
</span>
</Link>
))}
{renderForColumn('COMMUNITY', (
<Link
aria-label={intl.formatMessage(messages.community)}
title={intl.formatMessage(messages.community)}
to='/public/local'
className='drawer__tab'
><Icon id='users' icon={PeopleIcon} /></Link>
))}
{renderForColumn('PUBLIC', (
<Link
aria-label={intl.formatMessage(messages.public)}
title={intl.formatMessage(messages.public)}
to='/public'
className='drawer__tab'
><Icon id='globe' icon={PublicIcon} /></Link>
))}
<a
aria-label={intl.formatMessage(messages.settings)}
onClick={onSettingsClick}
href='/settings/preferences'
title={intl.formatMessage(messages.settings)}
className='drawer__tab'
><Icon id='cogs' icon={ManufacturingIcon} /></a>
<a
aria-label={intl.formatMessage(messages.logout)}
onClick={this.handleLogoutClick}
href={signOutLink}
title={intl.formatMessage(messages.logout)}
className='drawer__tab'
><Icon id='sign-out' icon={LogoutIcon} /></a>
</nav>
);
}
}
export default injectIntl(Header);

View File

@ -9,10 +9,11 @@ import { supportsPassiveEvents } from 'detect-passive-events';
import fuzzysort from 'fuzzysort'; import fuzzysort from 'fuzzysort';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import TranslateIcon from '@/material-icons/400-24px/translate.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state'; import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
import { loupeIcon, deleteIcon } from 'flavours/glitch/utils/icons';
import TextIconButton from './text_icon_button';
const messages = defineMessages({ const messages = defineMessages({
changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' }, changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
@ -231,7 +232,7 @@ class LanguageDropdownMenu extends PureComponent {
<div ref={this.setRef}> <div ref={this.setRef}>
<div className='emoji-mart-search'> <div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} /> <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
<button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button> <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}><Icon icon={!isSearching ? SearchIcon : CancelIcon} /></button>
</div> </div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}> <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
@ -297,20 +298,24 @@ class LanguageDropdown extends PureComponent {
render () { render () {
const { value, intl, frequentlyUsedLanguages } = this.props; const { value, intl, frequentlyUsedLanguages } = this.props;
const { open, placement } = this.state; const { open, placement } = this.state;
const current = preloadedLanguages.find(lang => lang[0] === value) ?? [];
return ( return (
<div className={classNames('privacy-dropdown', placement, { active: open })}> <div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<div className='privacy-dropdown__value' ref={this.setTargetRef} > <button
<TextIconButton type='button'
className='privacy-dropdown__value-icon' title={intl.formatMessage(messages.changeLanguage)}
label={value && value.toUpperCase()} aria-expanded={open}
title={intl.formatMessage(messages.changeLanguage)} onClick={this.handleToggle}
active={open} onMouseDown={this.handleMouseDown}
onClick={this.handleToggle} onKeyDown={this.handleButtonKeyDown}
/> className={classNames('dropdown-button', { active: open })}
</div> >
<Icon icon={TranslateIcon} />
<span className='dropdown-button__label'>{current[2] ?? value}</span>
</button>
<Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}> <Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement }) => ( {({ props, placement }) => (
<div {...props}> <div {...props}>
<div className={`dropdown-animation language-dropdown__dropdown ${placement}`} > <div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >

View File

@ -1,54 +1,36 @@
import PropTypes from 'prop-types'; import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes'; import { useSelector, useDispatch } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Permalink } from 'flavours/glitch/components/permalink'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { profileLink } from 'flavours/glitch/utils/backend_links'; import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
import Account from 'flavours/glitch/components/account';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { me } from 'flavours/glitch/initial_state';
import { Avatar } from '../../../components/avatar'; import { ActionBar } from './action_bar';
import ActionBar from './action_bar';
export default class NavigationBar extends ImmutablePureComponent { const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
static propTypes = { export const NavigationBar = () => {
account: ImmutablePropTypes.record.isRequired, const dispatch = useDispatch();
onLogout: PropTypes.func.isRequired, const intl = useIntl();
onClose: PropTypes.func, const account = useSelector(state => state.getIn(['accounts', me]));
}; const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
render () { const handleCancelClick = useCallback(() => {
const username = this.props.account.get('acct'); dispatch(cancelReplyCompose());
return ( }, [dispatch]);
<div className='navigation-bar'>
<Permalink className='avatar' href={this.props.account.get('url')} to={`/@${username}`}>
<span style={{ display: 'none' }}>{username}</span>
<Avatar account={this.props.account} size={46} />
</Permalink>
<div className='navigation-bar__profile'> return (
<span> <div className='navigation-bar'>
<Permalink className='acct' href={this.props.account.get('url')} to={`/@${username}`}> <Account account={account} minimal />
<strong className='navigation-bar__profile-account'>@{username}</strong> {isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
</Permalink> </div>
</span> );
};
{ profileLink !== undefined && (
<a
href={profileLink}
className='navigation-bar__profile-edit'
><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
)}
</div>
<div className='navigation-bar__actions'>
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
</div>
</div>
);
}
}

View File

@ -1,330 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import Toggle from 'react-toggle';
import AttachFileIcon from '@/material-icons/400-24px/attach_file.svg?react';
import BrushIcon from '@/material-icons/400-24px/brush.svg?react';
import CodeIcon from '@/material-icons/400-24px/code.svg?react';
import DescriptionIcon from '@/material-icons/400-24px/description.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import MarkdownIcon from '@/material-icons/400-24px/markdown.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { pollLimits } from 'flavours/glitch/initial_state';
import DropdownContainer from '../containers/dropdown_container';
import LanguageDropdown from '../containers/language_dropdown_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import TextIconButton from './text_icon_button';
const messages = defineMessages({
advanced_options_icon_title: {
defaultMessage: 'Advanced options',
id: 'advanced_options.icon_title',
},
attach: {
defaultMessage: 'Attach...',
id: 'compose.attach',
},
content_type: {
defaultMessage: 'Content type',
id: 'content-type.change',
},
doodle: {
defaultMessage: 'Draw something',
id: 'compose.attach.doodle',
},
html: {
defaultMessage: 'HTML',
id: 'compose.content-type.html',
},
local_only_long: {
defaultMessage: 'Do not post to other instances',
id: 'advanced_options.local-only.long',
},
local_only_short: {
defaultMessage: 'Local-only',
id: 'advanced_options.local-only.short',
},
markdown: {
defaultMessage: 'Markdown',
id: 'compose.content-type.markdown',
},
plain: {
defaultMessage: 'Plain text',
id: 'compose.content-type.plain',
},
spoiler: {
defaultMessage: 'Hide text behind warning',
id: 'compose_form.spoiler',
},
threaded_mode_long: {
defaultMessage: 'Automatically opens a reply on posting',
id: 'advanced_options.threaded_mode.long',
},
threaded_mode_short: {
defaultMessage: 'Threaded mode',
id: 'advanced_options.threaded_mode.short',
},
upload: {
defaultMessage: 'Upload a file',
id: 'compose.attach.upload',
},
add_poll: {
defaultMessage: 'Add a poll',
id: 'poll_button.add_poll',
},
remove_poll: {
defaultMessage: 'Remove poll',
id: 'poll_button.remove_poll',
},
});
const mapStateToProps = (state, { name }) => ({
checked: state.getIn(['compose', 'advanced_options', name]),
});
class ToggleOptionImpl extends ImmutablePureComponent {
static propTypes = {
name: PropTypes.string.isRequired,
checked: PropTypes.bool,
onChangeAdvancedOption: PropTypes.func.isRequired,
};
handleChange = () => {
this.props.onChangeAdvancedOption(this.props.name);
};
render() {
const { meta, text, checked } = this.props;
return (
<>
<div className='privacy-dropdown__option__icon'>
<Toggle checked={checked} onChange={this.handleChange} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{text}</strong>
{meta}
</div>
</>
);
}
}
const ToggleOption = connect(mapStateToProps)(ToggleOptionImpl);
class ComposerOptions extends ImmutablePureComponent {
static propTypes = {
acceptContentTypes: PropTypes.string,
advancedOptions: ImmutablePropTypes.map,
disabled: PropTypes.bool,
allowMedia: PropTypes.bool,
allowPoll: PropTypes.bool,
hasPoll: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChangeAdvancedOption: PropTypes.func.isRequired,
onChangeContentType: PropTypes.func.isRequired,
onTogglePoll: PropTypes.func.isRequired,
onDoodleOpen: PropTypes.func.isRequired,
onToggleSpoiler: PropTypes.func,
onUpload: PropTypes.func.isRequired,
contentType: PropTypes.string,
resetFileKey: PropTypes.number,
spoiler: PropTypes.bool,
showContentTypeChoice: PropTypes.bool,
isEditing: PropTypes.bool,
};
handleChangeFiles = ({ target: { files } }) => {
const { onUpload } = this.props;
if (files.length) {
onUpload(files);
}
};
handleClickAttach = (name) => {
const { fileElement } = this;
const { onDoodleOpen } = this.props;
switch (name) {
case 'upload':
if (fileElement) {
fileElement.click();
}
return;
case 'doodle':
onDoodleOpen();
return;
}
};
handleRefFileElement = (fileElement) => {
this.fileElement = fileElement;
};
renderToggleItemContents = (item) => {
const { onChangeAdvancedOption } = this.props;
const { name, meta, text } = item;
return <ToggleOption name={name} text={text} meta={meta} onChangeAdvancedOption={onChangeAdvancedOption} />;
};
render () {
const {
acceptContentTypes,
advancedOptions,
contentType,
disabled,
allowMedia,
allowPoll,
hasPoll,
onChangeAdvancedOption,
onChangeContentType,
onTogglePoll,
onToggleSpoiler,
resetFileKey,
spoiler,
showContentTypeChoice,
isEditing,
intl: { formatMessage },
} = this.props;
const contentTypeItems = {
plain: {
icon: 'file-text',
iconComponent: DescriptionIcon,
name: 'text/plain',
text: formatMessage(messages.plain),
},
html: {
icon: 'code',
iconComponent: CodeIcon,
name: 'text/html',
text: formatMessage(messages.html),
},
markdown: {
icon: 'arrow-circle-down',
iconComponent: MarkdownIcon,
name: 'text/markdown',
text: formatMessage(messages.markdown),
},
};
// The result.
return (
<div className='compose-form__buttons'>
<input
accept={acceptContentTypes}
disabled={disabled || !allowMedia}
key={resetFileKey}
onChange={this.handleChangeFiles}
ref={this.handleRefFileElement}
type='file'
multiple
style={{ display: 'none' }}
/>
<DropdownContainer
disabled={disabled || !allowMedia}
icon='paperclip'
iconComponent={AttachFileIcon}
items={[
{
icon: 'cloud-upload',
iconComponent: UploadFileIcon,
name: 'upload',
text: formatMessage(messages.upload),
},
{
icon: 'paint-brush',
iconComponent: BrushIcon,
name: 'doodle',
text: formatMessage(messages.doodle),
},
]}
onChange={this.handleClickAttach}
title={formatMessage(messages.attach)}
/>
{!!pollLimits && (
<IconButton
active={hasPoll}
disabled={disabled || !allowPoll}
icon='tasks'
iconComponent={InsertChartIcon}
inverted
onClick={onTogglePoll}
size={18}
style={{
height: null,
lineHeight: null,
}}
title={formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
/>
)}
<PrivacyDropdownContainer disabled={disabled || isEditing} />
{showContentTypeChoice && (
<DropdownContainer
disabled={disabled}
icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon}
iconComponent={(contentTypeItems[contentType.split('/')[1]] || {}).iconComponent}
items={[
contentTypeItems.plain,
contentTypeItems.html,
contentTypeItems.markdown,
]}
onChange={onChangeContentType}
title={formatMessage(messages.content_type)}
value={contentType}
/>
)}
{onToggleSpoiler && (
<TextIconButton
active={spoiler}
ariaControls='cw-spoiler-input'
label='CW'
onClick={onToggleSpoiler}
title={formatMessage(messages.spoiler)}
/>
)}
<LanguageDropdown />
<DropdownContainer
disabled={disabled || isEditing}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
items={advancedOptions ? [
{
meta: formatMessage(messages.local_only_long),
name: 'do_not_federate',
text: formatMessage(messages.local_only_short),
},
{
meta: formatMessage(messages.threaded_mode_long),
name: 'threaded_mode',
text: formatMessage(messages.threaded_mode_short),
},
] : null}
onChange={onChangeAdvancedOption}
renderItemContents={this.renderToggleItemContents}
title={formatMessage(messages.advanced_options_icon_title)}
closeOnChange={false}
/>
</div>
);
}
}
export default injectIntl(ComposerOptions);

View File

@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' },
remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' },
});
const iconStyle = {
height: null,
lineHeight: '27px',
};
class PollButton extends PureComponent {
static propTypes = {
disabled: PropTypes.bool,
active: PropTypes.bool,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleClick = () => {
this.props.onClick();
};
render () {
const { intl, active, disabled } = this.props;
return (
<div className='compose-form__poll-button'>
<IconButton
icon='tasks'
iconComponent={BarChart4BarsIcon}
title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)}
disabled={disabled}
onClick={this.handleClick}
className={`compose-form__poll-button-icon ${active ? 'active' : ''}`}
size={18}
inverted
style={iconStyle}
/>
</div>
);
}
}
export default injectIntl(PollButton);

View File

@ -1,179 +1,163 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { useCallback } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import { useDispatch, useSelector } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import {
import AddIcon from '@/material-icons/400-24px/add.svg?react'; changePollSettings,
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; changePollOption,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
} from 'flavours/glitch/actions/compose';
import AutosuggestInput from 'flavours/glitch/components/autosuggest_input'; import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { pollLimits } from 'flavours/glitch/initial_state'; import { pollLimits } from 'flavours/glitch/initial_state';
const messages = defineMessages({ const messages = defineMessages({
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' }, option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Option {number}' },
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' }, add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add option' },
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' }, remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this option' },
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' }, duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll length' },
single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' }, type: { id: 'compose_form.poll.type', defaultMessage: 'Style' },
multiple_choices: { id: 'compose_form.poll.multiple_choices', defaultMessage: 'Allow multiple choices' }, switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
singleChoice: { id: 'compose_form.poll.single', defaultMessage: 'Pick one' },
multipleChoice: { id: 'compose_form.poll.multiple', defaultMessage: 'Multiple choice' },
}); });
class OptionIntl extends PureComponent { const Select = ({ label, options, value, onChange }) => {
return (
<label className='compose-form__poll__select'>
<span className='compose-form__poll__select__label'>{label}</span>
static propTypes = { <select className='compose-form__poll__select__value' value={value} onChange={onChange}>
title: PropTypes.string.isRequired, {options.map((option, i) => (
lang: PropTypes.string, <option key={i} value={option.value}>{option.label}</option>
index: PropTypes.number.isRequired, ))}
isPollMultiple: PropTypes.bool, </select>
autoFocus: PropTypes.bool, </label>
onChange: PropTypes.func.isRequired, );
onRemove: PropTypes.func.isRequired, };
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleOptionTitleChange = e => { Select.propTypes = {
this.props.onChange(this.props.index, e.target.value); label: PropTypes.node,
}; value: PropTypes.any,
onChange: PropTypes.func,
options: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any,
})),
};
handleOptionRemove = () => { const Option = ({ multipleChoice, index, title, autoFocus }) => {
this.props.onRemove(this.props.index); const intl = useIntl();
}; const dispatch = useDispatch();
const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
const lang = useSelector(state => state.getIn(['compose', 'language']));
onSuggestionsClearRequested = () => { const handleChange = useCallback(({ target: { value } }) => {
this.props.onClearSuggestions(); dispatch(changePollOption(index, value));
}; }, [dispatch, index]);
onSuggestionsFetchRequested = (token) => { const handleSuggestionsFetchRequested = useCallback(token => {
this.props.onFetchSuggestions(token); dispatch(fetchComposeSuggestions(token));
}; }, [dispatch]);
onSuggestionSelected = (tokenStart, token, value) => { const handleSuggestionsClearRequested = useCallback(() => {
this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]); dispatch(clearComposeSuggestions());
}; }, [dispatch]);
render () { const handleSuggestionSelected = useCallback((tokenStart, token, value) => {
const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props; dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index]));
}, [dispatch, index]);
return ( return (
<li> <label className={classNames('poll__option editable', { empty: index > 1 && title.length === 0 })}>
<label className='poll__option editable'> <span className={classNames('poll__input', { checkbox: multipleChoice })} />
<span className={classNames('poll__input', { checkbox: isPollMultiple })} />
<AutosuggestInput <AutosuggestInput
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
maxLength={pollLimits.max_option_chars} maxLength={pollLimits.max_option_chars}
value={title} value={title}
lang={lang} lang={lang}
spellCheck spellCheck
onChange={this.handleOptionTitleChange} onChange={handleChange}
suggestions={this.props.suggestions} suggestions={suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionsClearRequested={handleSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected} onSuggestionSelected={handleSuggestionSelected}
searchTokens={[':']} searchTokens={[':']}
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
</label> </label>
);
};
<div className='poll__cancel'> Option.propTypes = {
<IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' iconComponent={CloseIcon} onClick={this.handleOptionRemove} /> title: PropTypes.string.isRequired,
</div> index: PropTypes.number.isRequired,
</li> multipleChoice: PropTypes.bool,
); autoFocus: PropTypes.bool,
};
export const PollForm = () => {
const intl = useIntl();
const dispatch = useDispatch();
const poll = useSelector(state => state.getIn(['compose', 'poll']));
const options = poll?.get('options');
const expiresIn = poll?.get('expires_in');
const isMultiple = poll?.get('multiple');
const handleDurationChange = useCallback(({ target: { value } }) => {
dispatch(changePollSettings(value, isMultiple));
}, [dispatch, isMultiple]);
const handleTypeChange = useCallback(({ target: { value } }) => {
dispatch(changePollSettings(expiresIn, value === 'true'));
}, [dispatch, expiresIn]);
if (poll === null) {
return null;
} }
} return (
<div className='compose-form__poll'>
{options.map((title, i) => (
<Option
title={title}
key={i}
index={i}
multipleChoice={isMultiple}
autoFocus={i === 0}
/>
))}
const Option = injectIntl(OptionIntl); <div className='compose-form__poll__footer'>
<Select label={intl.formatMessage(messages.duration)} options={[
{ value: 300, label: intl.formatMessage(messages.minutes, { number: 5 })},
{ value: 1800, label: intl.formatMessage(messages.minutes, { number: 30 })},
{ value: 3600, label: intl.formatMessage(messages.hours, { number: 1 })},
{ value: 21600, label: intl.formatMessage(messages.hours, { number: 6 })},
{ value: 43200, label: intl.formatMessage(messages.hours, { number: 12 })},
{ value: 86400, label: intl.formatMessage(messages.days, { number: 1 })},
{ value: 259200, label: intl.formatMessage(messages.days, { number: 3 })},
{ value: 604800, label: intl.formatMessage(messages.days, { number: 7 })},
]} value={expiresIn} onChange={handleDurationChange} />
class PollForm extends ImmutablePureComponent { <div className='compose-form__poll__footer__sep' />
static propTypes = { <Select label={intl.formatMessage(messages.type)} options={[
options: ImmutablePropTypes.list, { value: false, label: intl.formatMessage(messages.singleChoice) },
lang: PropTypes.string, { value: true, label: intl.formatMessage(messages.multipleChoice) },
expiresIn: PropTypes.number, ]} value={isMultiple} onChange={handleTypeChange} />
isMultiple: PropTypes.bool,
onChangeOption: PropTypes.func.isRequired,
onAddOption: PropTypes.func.isRequired,
onRemoveOption: PropTypes.func.isRequired,
onChangeSettings: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleAddOption = () => {
this.props.onAddOption('');
};
handleSelectDuration = e => {
this.props.onChangeSettings(e.target.value, this.props.isMultiple);
};
handleSelectMultiple = e => {
this.props.onChangeSettings(this.props.expiresIn, e.target.value === 'true');
};
render () {
const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
if (!options) {
return null;
}
const autoFocusIndex = options.indexOf('');
return (
<div className='compose-form__poll-wrapper'>
<ul>
{options.map((title, i) => <Option title={title} lang={lang} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
{options.size < pollLimits.max_options && (
<label className='poll__text editable'>
<span className={classNames('poll__input')} style={{ opacity: 0 }} />
<button className='button button-secondary' onClick={this.handleAddOption} type='button'><Icon id='plus' icon={AddIcon} /> <FormattedMessage {...messages.add_option} /></button>
</label>
)}
</ul>
<div className='poll__footer'>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select value={isMultiple ? 'true' : 'false'} onChange={this.handleSelectMultiple}>
<option value='false'>{intl.formatMessage(messages.single_choice)}</option>
<option value='true'>{intl.formatMessage(messages.multiple_choices)}</option>
</select>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select value={expiresIn} onChange={this.handleSelectDuration}>
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
<option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
</select>
</div>
</div> </div>
); </div>
} );
};
}
export default injectIntl(PollForm);

View File

@ -3,25 +3,151 @@ import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import classNames from 'classnames';
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import Dropdown from './dropdown'; import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({ const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' }, public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' }, unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' }, private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' }, direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, change_privacy: { id: 'privacy.change', defaultMessage: 'Change post privacy' },
unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
}); });
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
class PrivacyDropdownMenu extends PureComponent {
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
e.stopPropagation();
}
};
handleKeyDown = e => {
const { items } = this.props;
const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => {
return (item.value === value);
});
let element = null;
switch(e.key) {
case 'Escape':
this.props.onClose();
break;
case 'Enter':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1] || this.node.firstChild;
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1] || this.node.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = this.node.childNodes[index - 1] || this.node.lastChild;
} else {
element = this.node.childNodes[index + 1] || this.node.firstChild;
}
break;
case 'Home':
element = this.node.firstChild;
break;
case 'End':
element = this.node.lastChild;
break;
}
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
};
handleClick = e => {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
};
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
};
setFocusRef = c => {
this.focusedItem = c;
};
render () {
const { style, items, value } = this.props;
return (
<div style={{ ...style }} role='listbox' ref={this.setRef}>
{items.map(item => (
<div role='option' tabIndex={0} key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
<Icon id={item.icon} icon={item.iconComponent} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
{item.extra && (
<div className='privacy-dropdown__option__additional' title={item.extra}>
<Icon id='info-circle' icon={InfoIcon} />
</div>
)}
</div>
))}
</div>
);
}
}
class PrivacyDropdown extends PureComponent { class PrivacyDropdown extends PureComponent {
static propTypes = { static propTypes = {
@ -36,62 +162,118 @@ class PrivacyDropdown extends PureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
render () { state = {
const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, isUserTouching, intl: { formatMessage } } = this.props; open: false,
placement: 'bottom',
};
// We predefine our privacy items so that we can easily pick the handleToggle = () => {
// dropdown icon later. if (this.state.open && this.activeElement) {
const privacyItems = { this.activeElement.focus({ preventScroll: true });
direct: {
icon: 'envelope',
iconComponent: MailIcon,
meta: formatMessage(messages.direct_long),
name: 'direct',
text: formatMessage(messages.direct_short),
},
private: {
icon: 'lock',
iconComponent: LockIcon,
meta: formatMessage(messages.private_long),
name: 'private',
text: formatMessage(messages.private_short),
},
public: {
icon: 'globe',
iconComponent: PublicIcon,
meta: formatMessage(messages.public_long),
name: 'public',
text: formatMessage(messages.public_short),
},
unlisted: {
icon: 'unlock',
iconComponent: LockOpenIcon,
meta: formatMessage(messages.unlisted_long),
name: 'unlisted',
text: formatMessage(messages.unlisted_short),
},
};
const items = [privacyItems.public, privacyItems.unlisted, privacyItems.private];
if (!noDirect) {
items.push(privacyItems.direct);
} }
this.setState({ open: !this.state.open });
};
handleKeyDown = e => {
switch(e.key) {
case 'Escape':
this.handleClose();
break;
}
};
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
};
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
};
handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: false });
};
handleChange = value => {
this.props.onChange(value);
};
UNSAFE_componentWillMount () {
const { intl: { formatMessage } } = this.props;
this.options = [
{ icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'unlock', iconComponent: QuietTimeIcon, value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long), extra: formatMessage(messages.unlisted_extra) },
{ icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
];
if (!this.props.noDirect) {
this.options.push(
{ icon: 'at', iconComponent: AlternateEmailIcon, value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
);
}
}
setTargetRef = c => {
this.target = c;
};
findTarget = () => {
return this.target;
};
handleOverlayEnter = (state) => {
this.setState({ placement: state.placement });
};
render () {
const { value, container, disabled, intl } = this.props;
const { open, placement } = this.state;
const valueOption = this.options.find(item => item.value === value);
return ( return (
<Dropdown <div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
disabled={disabled} <button
icon={(privacyItems[value] || {}).icon} type='button'
iconComponent={(privacyItems[value] || {}).iconComponent} title={intl.formatMessage(messages.change_privacy)}
items={items} aria-expanded={open}
onChange={onChange} onClick={this.handleToggle}
isUserTouching={isUserTouching} onMouseDown={this.handleMouseDown}
onModalClose={onModalClose} onKeyDown={this.handleButtonKeyDown}
onModalOpen={onModalOpen} disabled={disabled}
title={formatMessage(messages.change_privacy)} className={classNames('dropdown-button', { active: open })}
container={container} >
value={value} <Icon id={valueOption.icon} icon={valueOption.iconComponent} />
/> <span className='dropdown-button__label'>{valueOption.text}</span>
</button>
<Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement }) => (
<div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
<PrivacyDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</div>
</div>
)}
</Overlay>
</div>
); );
} }

View File

@ -1,114 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import { Button } from 'flavours/glitch/components/button';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({
publish: {
defaultMessage: 'Publish',
id: 'compose_form.publish',
},
publishLoud: {
defaultMessage: '{publish}!',
id: 'compose_form.publish_loud',
},
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
});
class Publisher extends ImmutablePureComponent {
static propTypes = {
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onSecondarySubmit: PropTypes.func,
privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
isEditing: PropTypes.bool,
};
render () {
const { disabled, intl, onSecondarySubmit, privacy, sideArm, isEditing } = this.props;
const privacyIcons = {
direct: {
id: 'envelope',
icon: MailIcon,
},
private: {
id: 'lock',
icon: LockIcon,
},
public: {
id: 'globe',
icon: PublicIcon,
},
unlisted: {
id: 'unlock',
icon: LockOpenIcon,
},
};
let publishText;
if (isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (privacy === 'private' || privacy === 'direct') {
const icon = privacyIcons[privacy];
publishText = (
<span>
<Icon {...icon} /> {intl.formatMessage(messages.publish)}
</span>
);
} else {
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
const privacyNames = {
public: messages.public,
unlisted: messages.unlisted,
private: messages.private,
direct: messages.direct,
};
return (
<div className='compose-form__publish'>
{sideArm && !isEditing && sideArm !== 'none' && (
<div className='compose-form__publish-button-wrapper'>
<Button
className='side_arm'
disabled={disabled}
onClick={onSecondarySubmit}
style={{ padding: null }}
text={<Icon {...privacyIcons[sideArm]} />}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage(privacyNames[sideArm])}`}
/>
</div>
)}
<div className='compose-form__publish-button-wrapper'>
<Button
className='primary'
type='submit'
text={publishText}
title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage(privacyNames[privacy])}`}
disabled={disabled}
/>
</div>
</div>
);
}
}
export default injectIntl(Publisher);

View File

@ -1,75 +1,47 @@
import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl'; import { useSelector } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import ImmutablePureComponent from 'react-immutable-pure-component'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { Avatar } from 'flavours/glitch/components/avatar';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { Icon } from 'flavours/glitch/components/icon';
import { Permalink } from 'flavours/glitch/components/permalink';
export const ReplyIndicator = () => {
const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to']));
const status = useSelector(state => state.getIn(['statuses', inReplyToId]));
const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; if (!status) {
import AttachmentList from 'flavours/glitch/components/attachment_list'; return null;
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router'; }
import { Avatar } from '../../../components/avatar'; const content = { __html: status.get('contentHtml') };
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({ return (
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, <div className='reply-indicator'>
}); <div className='reply-indicator__line' />
class ReplyIndicator extends ImmutablePureComponent { <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'>
<Avatar account={account} size={46} />
</Permalink>
static propTypes = { <div className='reply-indicator__main'>
status: ImmutablePropTypes.map, <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
onCancel: PropTypes.func.isRequired, <DisplayName account={account} />
intl: PropTypes.object.isRequired, </Permalink>
...WithOptionalRouterPropTypes,
};
handleClick = () => {
this.props.onCancel();
};
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.history?.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
}
};
render () {
const { status, intl } = this.props;
if (!status) {
return null;
}
const content = { __html: status.get('contentHtml') };
return (
<div className='reply-indicator'>
<div className='reply-indicator__header'>
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={this.handleClick} inverted /></div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' target='_blank' rel='noopener noreferrer'>
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
<DisplayName account={status.get('account')} inline />
</a>
</div>
<div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} /> <div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} />
{status.get('media_attachments').size > 0 && ( {(status.get('poll') || status.get('media_attachments').size > 0) && (
<AttachmentList <div className='reply-indicator__attachments'>
compact {status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
media={status.get('media_attachments')} {status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
/> </div>
)} )}
</div> </div>
); </div>
} );
};
}
export default withOptionalRouter(injectIntl(ReplyIndicator));

View File

@ -8,7 +8,6 @@ import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react'; import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import SearchIcon from '@/material-icons/400-24px/search.svg?react';
@ -186,9 +185,9 @@ class Search extends PureComponent {
}; };
handleURLClick = () => { handleURLClick = () => {
const { onOpenURL, history } = this.props; const { value, onOpenURL, history } = this.props;
onOpenURL(history); onOpenURL(value, history);
this._unfocus(); this._unfocus();
}; };
@ -331,7 +330,7 @@ class Search extends PureComponent {
type='text' type='text'
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)} placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)} aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
value={value || ''} value={value}
onChange={this.handleChange} onChange={this.handleChange}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus} onFocus={this.handleFocus}
@ -339,8 +338,8 @@ class Search extends PureComponent {
/> />
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}> <div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} /> <Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} />
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} /> <Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
</div> </div>
<div className='search__popout'> <div className='search__popout'>

View File

@ -13,7 +13,6 @@ import { Icon } from 'flavours/glitch/components/icon';
import { LoadMore } from 'flavours/glitch/components/load_more'; import { LoadMore } from 'flavours/glitch/components/load_more';
import { SearchSection } from 'flavours/glitch/features/explore/components/search_section'; import { SearchSection } from 'flavours/glitch/features/explore/components/search_section';
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
import AccountContainer from '../../../containers/account_container'; import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container'; import StatusContainer from '../../../containers/status_container';
@ -77,10 +76,10 @@ class SearchResults extends ImmutablePureComponent {
return ( return (
<div className='search-results'> <div className='search-results'>
<header className='search-results__header'> <div className='search-results__header'>
<Icon id='search' icon={SearchIcon} /> <Icon id='search' icon={SearchIcon} />
<FormattedMessage id='explore.search_results' defaultMessage='Search results' /> <FormattedMessage id='explore.search_results' defaultMessage='Search results' />
</header> </div>
{accounts} {accounts}
{hashtags} {hashtags}

View File

@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import { useIntl, defineMessages } from 'react-intl';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MailIcon from '@/material-icons/400-24px/mail.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import { Button } from 'flavours/glitch/components/button';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
});
export const SecondaryPrivacyButton = ({ disabled, privacy, isEditing, onClick }) => {
const intl = useIntl();
if (isEditing || !privacy || privacy === 'none') {
return null;
}
const privacyProps = {
direct: { icon: 'envelope', iconComponent: MailIcon, title: messages.direct },
private: { icon: 'lock', iconComponent: LockIcon, title: messages.private },
public: { icon: 'globe', iconComponent: PublicIcon, title: messages.public },
unlisted: { icon: 'unlock', iconComponent: QuietTimeIcon, title: messages.unlisted },
};
return (
<Button className='secondary-post-button' disabled={disabled} onClick={onClick} title={intl.formatMessage(privacyProps[privacy].title)}>
<Icon id={privacyProps[privacy].id} icon={privacyProps[privacy].iconComponent} />
</Button>
);
};
SecondaryPrivacyButton.propTypes = {
disabled: PropTypes.bool,
privacy: PropTypes.string,
isEditing: PropTypes.bool,
onClick: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,58 @@
import { useCallback } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
const messages = defineMessages({
marked: {
id: 'compose_form.sensitive.marked',
defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
},
unmarked: {
id: 'compose_form.sensitive.unmarked',
defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
},
});
export const SensitiveButton = () => {
const intl = useIntl();
const spoilersAlwaysOn = useAppSelector((state) => state.getIn(['local_settings', 'always_show_spoilers_field']));
const spoilerText = useAppSelector((state) => state.getIn(['compose', 'spoiler_text']));
const sensitive = useAppSelector((state) => state.getIn(['compose', 'sensitive']));
const spoiler = useAppSelector((state) => state.getIn(['compose', 'spoiler']));
const mediaCount = useAppSelector((state) => state.getIn(['compose', 'media_attachments']).size);
const disabled = spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler;
const active = sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0);
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
dispatch(changeComposeSensitivity());
}, [dispatch]);
return (
<div className='compose-form__sensitive-button'>
<label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}>
<input
name='mark-sensitive'
type='checkbox'
checked={active}
onChange={handleClick}
disabled={disabled}
/>
<FormattedMessage
id='compose_form.sensitive.hide'
defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}'
values={{ count: mediaCount }}
/>
</label>
</div>
);
};

View File

@ -1,59 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ForumIcon from '@/material-icons/400-24px/forum.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({
localOnly: {
defaultMessage: 'This post is local-only',
id: 'advanced_options.local-only.tooltip',
},
threadedMode: {
defaultMessage: 'Threaded mode enabled',
id: 'advanced_options.threaded_mode.tooltip',
},
});
// We use an array of tuples here instead of an object because it
// preserves order.
const iconMap = [
['do_not_federate', 'home', HomeIcon, messages.localOnly],
['threaded_mode', 'comments', ForumIcon, messages.threadedMode],
];
class TextareaIcons extends ImmutablePureComponent {
static propTypes = {
advancedOptions: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
};
render () {
const { advancedOptions, intl } = this.props;
return (
<div className='compose-form__textarea-icons'>
{advancedOptions && iconMap.map(
([key, icon, iconComponent, message]) => advancedOptions.get(key) && (
<span
className='textarea_icon'
key={key}
title={intl.formatMessage(message)}
>
<Icon id={icon} icon={iconComponent} />
</span>
),
)}
</div>
);
}
}
export default injectIntl(TextareaIcons);

View File

@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import QuickreplyIcon from '@/material-icons/400-24px/quickreply.svg?react';
import { changeComposeAdvancedOption } from 'flavours/glitch/actions/compose';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
const messages = defineMessages({
enable_threaded_mode: { id: 'compose.enable_threaded_mode', defaultMessage: 'Enable threaded more' },
disable_threaded_mode: { id: 'compose.disable_threaded_mode', defaultMessage: 'Disable threaded more' },
});
export const ThreadModeButton = () => {
const intl = useIntl();
const isEditing = useAppSelector((state) => state.getIn(['compose', 'id']) !== null);
const active = useAppSelector((state) => state.getIn(['compose', 'advanced_options', 'threaded_mode']));
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
dispatch(changeComposeAdvancedOption('threaded_mode', !active));
}, [active, dispatch]);
const title = intl.formatMessage(active ? messages.disable_threaded_mode : messages.enable_threaded_mode);
return (
<IconButton
disabled={isEditing}
icon=''
onClick={handleClick}
iconComponent={QuickreplyIcon}
title={title}
active={active}
size={18}
inverted
/>
);
};

View File

@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
@ -9,16 +11,17 @@ import spring from 'react-motion/lib/spring';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react'; import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { Blurhash } from 'flavours/glitch/components/blurhash';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import Motion from '../../ui/util/optional_motion'; import Motion from '../../ui/util/optional_motion';
export default class Upload extends ImmutablePureComponent { export default class Upload extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
sensitive: PropTypes.bool,
onUndo: PropTypes.func.isRequired, onUndo: PropTypes.func.isRequired,
onOpenFocalPoint: PropTypes.func.isRequired, onOpenFocalPoint: PropTypes.func.isRequired,
}; };
@ -34,7 +37,7 @@ export default class Upload extends ImmutablePureComponent {
}; };
render () { render () {
const { media } = this.props; const { media, sensitive } = this.props;
if (!media) { if (!media) {
return null; return null;
@ -44,22 +47,26 @@ export default class Upload extends ImmutablePureComponent {
const focusY = media.getIn(['meta', 'focus', 'y']); const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100; const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100; const y = ((focusY / -2) + .5) * 100;
const missingDescription = (media.get('description') || '').length === 0;
return ( return (
<div className='compose-form__upload'> <div className='compose-form__upload'>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => ( {({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> <div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, backgroundPosition: `${x}% ${y}%` }}>
{sensitive && <Blurhash
hash={media.get('blurhash')}
className='compose-form__upload__preview'
/>}
<div className='compose-form__upload__actions'> <div className='compose-form__upload__actions'>
<button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> <button type='button' className='icon-button compose-form__upload__delete' onClick={this.handleUndoClick}><Icon icon={CloseIcon} /></button>
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button> <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
</div> </div>
{(media.get('description') || '').length === 0 && ( <div className='compose-form__upload__warning'>
<div className='compose-form__upload__warning'> <button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={this.handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' icon={InfoIcon} /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button> </div>
</div>
)}
</div> </div>
)} )}
</Motion> </Motion>

View File

@ -0,0 +1,81 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
});
const makeMapStateToProps = () => {
const mapStateToProps = state => ({
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
});
return mapStateToProps;
};
const iconStyle = {
height: null,
lineHeight: '27px',
};
class UploadButton extends ImmutablePureComponent {
static propTypes = {
disabled: PropTypes.bool,
onSelectFile: PropTypes.func.isRequired,
style: PropTypes.object,
resetFileKey: PropTypes.number,
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
intl: PropTypes.object.isRequired,
};
handleChange = (e) => {
if (e.target.files.length > 0) {
this.props.onSelectFile(e.target.files);
}
};
handleClick = () => {
this.fileElement.click();
};
setRef = (c) => {
this.fileElement = c;
};
render () {
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
const message = intl.formatMessage(messages.upload);
return (
<div className='compose-form__upload-button'>
<IconButton icon='paperclip' iconComponent={PhotoLibraryIcon} title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
<label>
<span style={{ display: 'none' }}>{message}</span>
<input
key={resetFileKey}
ref={this.setRef}
type='file'
multiple
accept={acceptContentTypes.toArray().join(',')}
onChange={this.handleChange}
disabled={disabled}
style={{ display: 'none' }}
/>
</label>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(UploadButton));

View File

@ -1,10 +1,11 @@
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import UploadContainer from '../containers/upload_container'; import UploadContainer from '../containers/upload_container';
import UploadProgressContainer from '../containers/upload_progress_container'; import UploadProgressContainer from '../containers/upload_progress_container';
import { SensitiveButton } from './sensitive_button';
export default class UploadForm extends ImmutablePureComponent { export default class UploadForm extends ImmutablePureComponent {
static propTypes = { static propTypes = {
@ -15,17 +16,19 @@ export default class UploadForm extends ImmutablePureComponent {
const { mediaIds } = this.props; const { mediaIds } = this.props;
return ( return (
<div className='compose-form__upload-wrapper'> <>
<UploadProgressContainer /> <UploadProgressContainer />
<div className='compose-form__uploads-wrapper'> {mediaIds.size > 0 && (
{mediaIds.map(id => ( <div className='compose-form__uploads'>
<UploadContainer id={id} key={id} /> {mediaIds.map(id => (
))} <UploadContainer id={id} key={id} />
</div> ))}
</div>
)}
{!mediaIds.isEmpty() && <SensitiveButtonContainer />} {!mediaIds.isEmpty() && <SensitiveButton />}
</div> </>
); );
} }

View File

@ -35,9 +35,7 @@ export default class UploadProgress extends PureComponent {
return ( return (
<div className='upload-progress'> <div className='upload-progress'>
<div className='upload-progress__icon'> <Icon id='upload' icon={UploadFileIcon} />
<Icon id='upload' icon={UploadFileIcon} />
</div>
<div className='upload-progress__message'> <div className='upload-progress__message'>
{message} {message}

View File

@ -11,8 +11,6 @@ import {
fetchComposeSuggestions, fetchComposeSuggestions,
selectComposeSuggestion, selectComposeSuggestion,
changeComposeSpoilerText, changeComposeSpoilerText,
changeComposeSpoilerness,
changeComposeVisibility,
insertEmojiCompose, insertEmojiCompose,
uploadCompose, uploadCompose,
} from '../../../actions/compose'; } from '../../../actions/compose';
@ -58,11 +56,13 @@ const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']), text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']), suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['local_settings', 'always_show_spoilers_field']) || state.getIn(['compose', 'spoiler']), spoiler: state.getIn(['local_settings', 'always_show_spoilers_field']) || state.getIn(['compose', 'spoiler']),
spoilerAlwaysOn: state.getIn(['local_settings', 'always_show_spoilers_field']),
spoilerText: state.getIn(['compose', 'spoiler_text']), spoilerText: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']), privacy: state.getIn(['compose', 'privacy']),
focusDate: state.getIn(['compose', 'focusDate']), focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']), caretPosition: state.getIn(['compose', 'caretPosition']),
preselectDate: state.getIn(['compose', 'preselectDate']), preselectDate: state.getIn(['compose', 'preselectDate']),
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
isSubmitting: state.getIn(['compose', 'is_submitting']), isSubmitting: state.getIn(['compose', 'is_submitting']),
isEditing: state.getIn(['compose', 'id']) !== null, isEditing: state.getIn(['compose', 'id']) !== null,
isChangingUpload: state.getIn(['compose', 'is_changing_upload']), isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
@ -70,14 +70,9 @@ const mapStateToProps = state => ({
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
isInReply: state.getIn(['compose', 'in_reply_to']) !== null, isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
lang: state.getIn(['compose', 'language']), lang: state.getIn(['compose', 'language']),
advancedOptions: state.getIn(['compose', 'advanced_options']),
media: state.getIn(['compose', 'media_attachments']),
sideArm: sideArmPrivacy(state), sideArm: sideArmPrivacy(state),
sensitive: state.getIn(['compose', 'sensitive']), media: state.getIn(['compose', 'media_attachments']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
spoilersAlwaysOn: state.getIn(['local_settings', 'always_show_spoilers_field']),
mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']), mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
}); });
const mapDispatchToProps = (dispatch, { intl }) => ({ const mapDispatchToProps = (dispatch, { intl }) => ({
@ -86,8 +81,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(changeCompose(text)); dispatch(changeCompose(text));
}, },
onSubmit (router) { onSubmit (router, overridePrivacy = null) {
dispatch(submitCompose(router)); dispatch(submitCompose(router, overridePrivacy));
}, },
onClearSuggestions () { onClearSuggestions () {
@ -102,37 +97,26 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(selectComposeSuggestion(position, token, suggestion, path)); dispatch(selectComposeSuggestion(position, token, suggestion, path));
}, },
onChangeSpoilerText (text) { onChangeSpoilerText (checked) {
dispatch(changeComposeSpoilerText(text)); dispatch(changeComposeSpoilerText(checked));
}, },
onPaste (files) { onPaste (files) {
dispatch(uploadCompose(files)); dispatch(uploadCompose(files));
}, },
onPickEmoji (position, emoji) { onPickEmoji (position, data, needsSpace) {
dispatch(insertEmojiCompose(position, emoji)); dispatch(insertEmojiCompose(position, data, needsSpace));
}, },
onChangeSpoilerness() { onMediaDescriptionConfirm (routerHistory, mediaId, overridePrivacy = null) {
dispatch(changeComposeSpoilerness());
},
onChangeVisibility(value) {
dispatch(changeComposeVisibility(value));
},
onMediaDescriptionConfirm(routerHistory, mediaId, overriddenVisibility = null) {
dispatch(openModal({ dispatch(openModal({
modalType: 'CONFIRM', modalType: 'CONFIRM',
modalProps: { modalProps: {
message: intl.formatMessage(messages.missingDescriptionMessage), message: intl.formatMessage(messages.missingDescriptionMessage),
confirm: intl.formatMessage(messages.missingDescriptionConfirm), confirm: intl.formatMessage(messages.missingDescriptionConfirm),
onConfirm: () => { onConfirm: () => {
if (overriddenVisibility) { dispatch(submitCompose(routerHistory, overridePrivacy));
dispatch(changeComposeVisibility(overriddenVisibility));
}
dispatch(submitCompose(routerHistory));
}, },
secondary: intl.formatMessage(messages.missingDescriptionEdit), secondary: intl.formatMessage(messages.missingDescriptionEdit),
onSecondary: () => dispatch(openModal({ onSecondary: () => dispatch(openModal({

View File

@ -1,14 +0,0 @@
import { connect } from 'react-redux';
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
import { isUserTouching } from 'flavours/glitch/is_mobile';
import Dropdown from '../components/dropdown';
const mapDispatchToProps = dispatch => ({
isUserTouching,
onModalOpen: props => dispatch(openModal({ modalType: 'ACTIONS', modalProps: props })),
onModalClose: () => dispatch(closeModal({ modalType: undefined, ignoreFocus: false })),
});
export default connect(null, mapDispatchToProps)(Dropdown);

View File

@ -1,42 +0,0 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'flavours/glitch/actions/modal';
import { logOut } from 'flavours/glitch/utils/log_out';
import Header from '../components/header';
const messages = defineMessages({
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});
const mapStateToProps = state => {
return {
columns: state.getIn(['settings', 'columns']),
unreadNotifications: state.getIn(['notifications', 'unread']),
showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
};
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onSettingsClick (e) {
e.preventDefault();
e.stopPropagation();
dispatch(openModal({ modalType: 'SETTINGS', modalProps: {} }));
},
onLogout () {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header));

View File

@ -1,36 +0,0 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'flavours/glitch/actions/modal';
import { logOut } from 'flavours/glitch/utils/log_out';
import { me } from '../../../initial_state';
import NavigationBar from '../components/navigation_bar';
const messages = defineMessages({
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onLogout () {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));

View File

@ -1,56 +0,0 @@
import { connect } from 'react-redux';
import {
changeComposeAdvancedOption,
changeComposeContentType,
addPoll,
removePoll,
} from 'flavours/glitch/actions/compose';
import { openModal } from 'flavours/glitch/actions/modal';
import Options from '../components/options';
function mapStateToProps (state) {
const poll = state.getIn(['compose', 'poll']);
const media = state.getIn(['compose', 'media_attachments']);
const pending_media = state.getIn(['compose', 'pending_media_attachments']);
return {
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
hasPoll: !!poll,
allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4),
allowPoll: !(media && !!media.size),
showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']),
contentType: state.getIn(['compose', 'content_type']),
};
}
const mapDispatchToProps = (dispatch) => ({
onChangeAdvancedOption(option, value) {
dispatch(changeComposeAdvancedOption(option, value));
},
onChangeContentType(value) {
dispatch(changeComposeContentType(value));
},
onTogglePoll() {
dispatch((_, getState) => {
if (getState().getIn(['compose', 'poll'])) {
dispatch(removePoll());
} else {
dispatch(addPoll());
}
});
},
onDoodleOpen() {
dispatch(openModal({
modalType: 'DOODLE',
modalProps: { noEsc: true, noClose: true },
}));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Options);

View File

@ -0,0 +1,25 @@
import { connect } from 'react-redux';
import { addPoll, removePoll } from '../../../actions/compose';
import PollButton from '../components/poll_button';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0),
active: state.getIn(['compose', 'poll']) !== null,
});
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch((_, getState) => {
if (getState().getIn(['compose', 'poll'])) {
dispatch(removePoll());
} else {
dispatch(addPoll());
}
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PollButton);

View File

@ -1,53 +0,0 @@
import { connect } from 'react-redux';
import {
addPollOption,
removePollOption,
changePollOption,
changePollSettings,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
} from '../../../actions/compose';
import PollForm from '../components/poll_form';
const mapStateToProps = state => ({
suggestions: state.getIn(['compose', 'suggestions']),
options: state.getIn(['compose', 'poll', 'options']),
lang: state.getIn(['compose', 'language']),
expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
isMultiple: state.getIn(['compose', 'poll', 'multiple']),
});
const mapDispatchToProps = dispatch => ({
onAddOption(title) {
dispatch(addPollOption(title));
},
onRemoveOption(index) {
dispatch(removePollOption(index));
},
onChangeOption(index, title) {
dispatch(changePollOption(index, title));
},
onChangeSettings(expiresIn, isMultiple) {
dispatch(changePollSettings(expiresIn, isMultiple));
},
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions (token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected (position, token, accountId, path) {
dispatch(selectComposeSuggestion(position, token, accountId, path));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PollForm);

View File

@ -1,36 +0,0 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
import ReplyIndicator from '../components/reply_indicator';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = state => {
let statusId = state.getIn(['compose', 'id'], null);
let editing = true;
if (statusId === null) {
statusId = state.getIn(['compose', 'in_reply_to']);
editing = false;
}
return {
status: getStatus(state, { id: statusId }),
editing,
};
};
return mapStateToProps;
};
const mapDispatchToProps = dispatch => ({
onCancel () {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

View File

@ -42,8 +42,8 @@ const mapDispatchToProps = dispatch => ({
dispatch(showSearch()); dispatch(showSearch());
}, },
onOpenURL (routerHistory) { onOpenURL (q, routerHistory) {
dispatch(openURL(routerHistory)); dispatch(openURL(q, routerHistory));
}, },
onClickSearchResult (q, type) { onClickSearchResult (q, type) {

View File

@ -1,77 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
const messages = defineMessages({
marked: {
id: 'compose_form.sensitive.marked',
defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
},
unmarked: {
id: 'compose_form.sensitive.unmarked',
defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
},
});
const mapStateToProps = state => {
const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
const spoilerText = state.getIn(['compose', 'spoiler_text']);
return {
active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0),
disabled: state.getIn(['compose', 'spoiler']),
mediaCount: state.getIn(['compose', 'media_attachments']).size,
};
};
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch(changeComposeSensitivity());
},
});
class SensitiveButton extends PureComponent {
static propTypes = {
active: PropTypes.bool,
disabled: PropTypes.bool,
mediaCount: PropTypes.number,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { active, disabled, mediaCount, onClick, intl } = this.props;
return (
<div className='compose-form__sensitive-button'>
<label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}>
<input
name='mark-sensitive'
type='checkbox'
checked={active}
onChange={onClick}
disabled={disabled}
/>
<FormattedMessage
id='compose_form.sensitive.hide'
defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}'
values={{ count: mediaCount }}
/>
</label>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));

View File

@ -0,0 +1,32 @@
import { injectIntl, defineMessages } from 'react-intl';
import { connect } from 'react-redux';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { changeComposeSpoilerness } from '../../../actions/compose';
const messages = defineMessages({
marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' },
unmarked: { id: 'compose_form.spoiler.unmarked', defaultMessage: 'Text is not hidden' },
});
const mapStateToProps = (state, { intl }) => ({
iconComponent: WarningIcon,
title: intl.formatMessage(state.getIn(['compose', 'spoiler']) ? messages.marked : messages.unmarked),
active: state.getIn(['compose', 'spoiler']),
ariaControls: 'cw-spoiler-input',
size: 18,
inverted: true,
});
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch(changeComposeSpoilerness());
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(IconButton));

View File

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import { uploadCompose } from '../../../actions/compose';
import UploadButton from '../components/upload_button';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
});
const mapDispatchToProps = dispatch => ({
onSelectFile (files) {
dispatch(uploadCompose(files));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);

View File

@ -5,6 +5,7 @@ import Upload from '../components/upload';
const mapStateToProps = (state, { id }) => ({ const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
sensitive: state.getIn(['compose', 'sensitive']),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

View File

@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { me } from 'flavours/glitch/initial_state'; import { me } from 'flavours/glitch/initial_state';
import { profileLink, privacyPolicyLink } from 'flavours/glitch/utils/backend_links';
import { HASHTAG_PATTERN_REGEX } from 'flavours/glitch/utils/hashtags'; import { HASHTAG_PATTERN_REGEX } from 'flavours/glitch/utils/hashtags';
import Warning from '../components/warning'; import Warning from '../components/warning';
@ -18,7 +17,7 @@ const mapStateToProps = state => ({
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
if (needsLockWarning) { if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
} }
if (hashtagWarning) { if (hashtagWarning) {
@ -28,7 +27,7 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
if (directMessageWarning) { if (directMessageWarning) {
const message = ( const message = (
<span> <span>
<FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> {!!privacyPolicyLink && <a href={privacyPolicyLink} target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>} <FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
</span> </span>
); );

View File

@ -3,93 +3,180 @@ import { PureComponent } from 'react';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { mountCompose, unmountCompose, cycleElefriendCompose } from 'flavours/glitch/actions/compose'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing-fill.svg?react';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import { openModal } from 'flavours/glitch/actions/modal';
import Column from 'flavours/glitch/components/column'; import Column from 'flavours/glitch/components/column';
import { Icon } from 'flavours/glitch/components/icon';
import glitchedElephant1 from 'flavours/glitch/images/mbstobon-ui-0.png';
import glitchedElephant2 from 'flavours/glitch/images/mbstobon-ui-1.png';
import glitchedElephant3 from 'flavours/glitch/images/mbstobon-ui-2.png';
import { logOut } from 'flavours/glitch/utils/log_out';
import elephantUIPlane from '../../../../images/elephant_ui_plane.svg';
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
import { mascot } from '../../initial_state'; import { mascot } from '../../initial_state';
import { isMobile } from '../../is_mobile';
import Motion from '../ui/util/optional_motion'; import Motion from '../ui/util/optional_motion';
import ComposeFormContainer from './containers/compose_form_container'; import ComposeFormContainer from './containers/compose_form_container';
import HeaderContainer from './containers/header_container';
import NavigationContainer from './containers/navigation_container';
import SearchContainer from './containers/search_container'; import SearchContainer from './containers/search_container';
import SearchResultsContainer from './containers/search_results_container'; import SearchResultsContainer from './containers/search_results_container';
const messages = defineMessages({ const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' }, compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
}); });
const mapStateToProps = (state, ownProps) => ({ const mapStateToProps = (state, ownProps) => ({
elefriend: state.getIn(['compose', 'elefriend']), columns: state.getIn(['settings', 'columns']),
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false, showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
unreadNotifications: state.getIn(['notifications', 'unread']),
showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
}); });
const mapDispatchToProps = (dispatch) => ({ // ~4% chance you'll end up with an unexpected friend
onClickElefriend () { // glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z
dispatch(cycleElefriendCompose()); const glitchProbability = 1 - 0.0420215528;
}, const totalElefriends = 3;
onMount () {
dispatch(mountCompose());
},
onUnmount () {
dispatch(unmountCompose());
},
});
class Compose extends PureComponent { class Compose extends PureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired,
columns: ImmutablePropTypes.list.isRequired,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
showSearch: PropTypes.bool, showSearch: PropTypes.bool,
elefriend: PropTypes.number, unreadNotifications: PropTypes.number,
onClickElefriend: PropTypes.func, showNotificationsBadge: PropTypes.bool,
onMount: PropTypes.func,
onUnmount: PropTypes.func,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
state = {
elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends,
};
componentDidMount () { componentDidMount () {
this.props.onMount(); const { dispatch } = this.props;
dispatch(mountCompose());
} }
componentWillUnmount () { componentWillUnmount () {
this.props.onUnmount(); const { dispatch } = this.props;
dispatch(unmountCompose());
} }
handleLogoutClick = e => {
const { dispatch, intl } = this.props;
e.preventDefault();
e.stopPropagation();
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
return false;
};
handleSettingsClick = e => {
const { dispatch } = this.props;
e.preventDefault();
e.stopPropagation();
dispatch(openModal({ modalType: 'SETTINGS', modalProps: {} }));
};
onFocus = () => {
this.props.dispatch(changeComposing(true));
};
onBlur = () => {
this.props.dispatch(changeComposing(false));
};
cycleElefriend = () => {
this.setState((state) => ({ elefriend: (state.elefriend + 1) % totalElefriends }));
};
render () { render () {
const { const { multiColumn, showSearch, showNotificationsBadge, unreadNotifications, intl } = this.props;
elefriend,
intl, const elefriend = [glitchedElephant1, glitchedElephant2, glitchedElephant3, elephantUIPlane][this.state.elefriend];
multiColumn,
onClickElefriend,
showSearch,
} = this.props;
const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
if (multiColumn) { if (multiColumn) {
return ( const { columns } = this.props;
<div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
<HeaderContainer />
{multiColumn && <SearchContainer />} return (
<div className='drawer' role='region' aria-label={intl.formatMessage(messages.compose)}>
<nav className='drawer__header'>
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><Icon id='bars' icon={MenuIcon} /></Link>
{!columns.some(column => column.get('id') === 'HOME') && (
<Link to='/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><Icon id='home' icon={HomeIcon} /></Link>
)}
{!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
<Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}>
<span className='icon-badge-wrapper'>
<Icon id='bell' icon={NotificationsIcon} />
{showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
</span>
</Link>
)}
{!columns.some(column => column.get('id') === 'COMMUNITY') && (
<Link to='/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><Icon id='users' icon={PeopleIcon} /></Link>
)}
{!columns.some(column => column.get('id') === 'PUBLIC') && (
<Link to='/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' icon={PublicIcon} /></Link>
)}
<a
onClick={this.handleSettingsClick}
href='/settings/preferences'
className='drawer__tab'
title={intl.formatMessage(messages.settings)}
aria-label={intl.formatMessage(messages.settings)}
>
<Icon id='cogs' icon={ManufacturingIcon} />
</a>
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
</nav>
{multiColumn && <SearchContainer /> }
<div className='drawer__pager'> <div className='drawer__pager'>
<div className='drawer__inner'> <div className='drawer__inner' onFocus={this.onFocus}>
<NavigationContainer /> <ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} />
<ComposeFormContainer /> {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is not a feature but a visual easter egg */}
<div className='drawer__inner__mastodon' onClick={this.cycleElefriend}>
<div className='drawer__inner__mastodon'> <img alt='' draggable='false' src={mascot || elefriend} />
{mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
</div> </div>
</div> </div>
@ -106,8 +193,7 @@ class Compose extends PureComponent {
} }
return ( return (
<Column> <Column onFocus={this.onFocus}>
<NavigationContainer />
<ComposeFormContainer /> <ComposeFormContainer />
<Helmet> <Helmet>
@ -119,4 +205,4 @@ class Compose extends PureComponent {
} }
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Compose)); export default connect(mapStateToProps)(injectIntl(Compose));

View File

@ -32,7 +32,7 @@ import { preferencesLink } from 'flavours/glitch/utils/backend_links';
import { me, showTrends } from '../../initial_state'; import { me, showTrends } from '../../initial_state';
import NavigationBar from '../compose/components/navigation_bar'; import { NavigationBar } from '../compose/components/navigation_bar';
import ColumnLink from '../ui/components/column_link'; import ColumnLink from '../ui/components/column_link';
import ColumnSubheading from '../ui/components/column_subheading'; import ColumnSubheading from '../ui/components/column_subheading';

View File

@ -10,9 +10,9 @@ import ExpandLessIcon from '@/material-icons/400-24px/expand_less.svg?react';
import ImageIcon from '@/material-icons/400-24px/image.svg?react'; import ImageIcon from '@/material-icons/400-24px/image.svg?react';
import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react'; import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react'; import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { preferencesLink } from 'flavours/glitch/utils/backend_links'; import { preferencesLink } from 'flavours/glitch/utils/backend_links';
import LocalSettingsNavigationItem from './item'; import LocalSettingsNavigationItem from './item';
const messages = defineMessages({ const messages = defineMessages({
@ -60,7 +60,8 @@ class LocalSettingsNavigation extends PureComponent {
active={index === 2} active={index === 2}
index={2} index={2}
onNavigate={onNavigate} onNavigate={onNavigate}
textIcon='CW' icon='warning'
iconComponent={WarningIcon}
title={intl.formatMessage(messages.content_warnings)} title={intl.formatMessage(messages.content_warnings)}
/> />
<LocalSettingsNavigationItem <LocalSettingsNavigationItem

View File

@ -13,7 +13,6 @@ export default class LocalSettingsPage extends PureComponent {
className: PropTypes.string, className: PropTypes.string,
href: PropTypes.string, href: PropTypes.string,
icon: PropTypes.string, icon: PropTypes.string,
textIcon: PropTypes.string,
iconComponent: PropTypes.func, iconComponent: PropTypes.func,
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
onNavigate: PropTypes.func, onNavigate: PropTypes.func,
@ -36,7 +35,6 @@ export default class LocalSettingsPage extends PureComponent {
href, href,
icon, icon,
iconComponent, iconComponent,
textIcon,
onNavigate, onNavigate,
title, title,
} = this.props; } = this.props;
@ -45,7 +43,7 @@ export default class LocalSettingsPage extends PureComponent {
active, active,
}, className); }, className);
const iconElem = icon ? <Icon id={icon} icon={iconComponent} /> : (textIcon ? <span className='text-icon-button'>{textIcon}</span> : null); const iconElem = icon ? <Icon id={icon} icon={iconComponent} /> : null;
if (href) return ( if (href) return (
<a <a

View File

@ -28,9 +28,9 @@ const messages = defineMessages({
pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' }, pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' },
pop_in_right: { id: 'settings.pop_in_right', defaultMessage: 'Right' }, pop_in_right: { id: 'settings.pop_in_right', defaultMessage: 'Right' },
public: { id: 'privacy.public.short', defaultMessage: 'Public' }, public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, private: { id: 'privacy.private.short', defaultMessage: 'Followers' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, direct: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
}); });
class LocalSettingsPage extends PureComponent { class LocalSettingsPage extends PureComponent {

View File

@ -1,21 +1,15 @@
import { PureComponent } from 'react'; import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container';
import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container';
import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container';
import ComposeFormContainer from '../../compose/containers/compose_form_container'; const Compose = () => (
import LoadingBarContainer from '../../ui/containers/loading_bar_container'; <>
import ModalContainer from '../../ui/containers/modal_container'; <ComposeFormContainer autoFocus withoutNavigation />
import NotificationsContainer from '../../ui/containers/notifications_container'; <NotificationsContainer />
<ModalContainer />
<LoadingBarContainer className='loading-bar' />
</>
);
export default class Compose extends PureComponent { export default Compose;
render () {
return (
<div>
<ComposeFormContainer autoFocus />
<NotificationsContainer />
<ModalContainer />
<LoadingBarContainer className='loading-bar' />
</div>
);
}
}

View File

@ -6,7 +6,6 @@ import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose'; import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
import ServerBanner from 'flavours/glitch/components/server_banner'; import ServerBanner from 'flavours/glitch/components/server_banner';
import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container'; import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
import NavigationContainer from 'flavours/glitch/features/compose/containers/navigation_container';
import SearchContainer from 'flavours/glitch/features/compose/containers/search_container'; import SearchContainer from 'flavours/glitch/features/compose/containers/search_container';
import LinkFooter from './link_footer'; import LinkFooter from './link_footer';
@ -46,10 +45,7 @@ class ComposePanel extends PureComponent {
)} )}
{signedIn && ( {signedIn && (
<> <ComposeFormContainer singleColumn />
<NavigationContainer />
<ComposeFormContainer singleColumn />
</>
)} )}
<LinkFooter /> <LinkFooter />

View File

@ -21,7 +21,7 @@ import { Button } from 'flavours/glitch/components/button';
import { GIFV } from 'flavours/glitch/components/gifv'; import { GIFV } from 'flavours/glitch/components/gifv';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import Audio from 'flavours/glitch/features/audio'; import Audio from 'flavours/glitch/features/audio';
import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter'; import { CharacterCounter } from 'flavours/glitch/features/compose/components/character_counter';
import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress'; import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components'; import { Tesseract as fetchTesseract } from 'flavours/glitch/features/ui/util/async-components';
import { me } from 'flavours/glitch/initial_state'; import { me } from 'flavours/glitch/initial_state';

View File

@ -108,7 +108,6 @@ class MuteModal extends PureComponent {
<div> <div>
<span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select value={muteDuration} onChange={this.changeMuteDuration}> <select value={muteDuration} onChange={this.changeMuteDuration}>
<option value={0}>{intl.formatMessage(messages.indefinite)}</option> <option value={0}>{intl.formatMessage(messages.indefinite)}</option>
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>

View File

@ -5,13 +5,6 @@
"account.follows_you": "Follows you", "account.follows_you": "Follows you",
"account.suspended_disclaimer_full": "This user has been suspended by a moderator.", "account.suspended_disclaimer_full": "This user has been suspended by a moderator.",
"account.view_full_profile": "View full profile", "account.view_full_profile": "View full profile",
"advanced_options.icon_title": "Advanced options",
"advanced_options.local-only.long": "Do not post to other instances",
"advanced_options.local-only.short": "Local-only",
"advanced_options.local-only.tooltip": "This post is local-only",
"advanced_options.threaded_mode.long": "Automatically opens a reply on posting",
"advanced_options.threaded_mode.short": "Threaded mode",
"advanced_options.threaded_mode.tooltip": "Threaded mode enabled",
"boost_modal.missing_description": "This toot contains some media without description", "boost_modal.missing_description": "This toot contains some media without description",
"column.favourited_by": "Favourited by", "column.favourited_by": "Favourited by",
"column.heading": "Misc", "column.heading": "Misc",
@ -21,15 +14,19 @@
"column_subheading.lists": "Lists", "column_subheading.lists": "Lists",
"column_subheading.navigation": "Navigation", "column_subheading.navigation": "Navigation",
"community.column_settings.allow_local_only": "Show local-only toots", "community.column_settings.allow_local_only": "Show local-only toots",
"compose.attach": "Attach...", "compose.change_federation": "Change federation settings",
"compose.attach.doodle": "Draw something", "compose.content-type.change": "Change advanced formatting options",
"compose.attach.upload": "Upload a file",
"compose.content-type.html": "HTML", "compose.content-type.html": "HTML",
"compose.content-type.html_meta": "Format your posts using HTML",
"compose.content-type.markdown": "Markdown", "compose.content-type.markdown": "Markdown",
"compose.content-type.markdown_meta": "Format your posts using Markdown",
"compose.content-type.plain": "Plain text", "compose.content-type.plain": "Plain text",
"compose_form.poll.multiple_choices": "Allow multiple choices", "compose.content-type.plain_meta": "Write with no advanced formatting",
"compose_form.poll.single_choice": "Allow one choice", "compose.disable_threaded_mode": "Disable threaded more",
"compose_form.spoiler": "Hide text behind warning", "compose.enable_threaded_mode": "Enable threaded more",
"compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}",
"compose_form.sensitive.marked": "{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}",
"compose_form.sensitive.unmarked": "{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}",
"confirmation_modal.do_not_ask_again": "Do not ask for confirmation again", "confirmation_modal.do_not_ask_again": "Do not ask for confirmation again",
"confirmations.deprecated_settings.confirm": "Use Mastodon preferences", "confirmations.deprecated_settings.confirm": "Use Mastodon preferences",
"confirmations.deprecated_settings.message": "Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:", "confirmations.deprecated_settings.message": "Some of the glitch-soc device-specific {app_settings} you are using have been replaced by Mastodon {preferences} and will be overriden:",
@ -40,10 +37,13 @@
"confirmations.unfilter.confirm": "Show", "confirmations.unfilter.confirm": "Show",
"confirmations.unfilter.edit_filter": "Edit filter", "confirmations.unfilter.edit_filter": "Edit filter",
"confirmations.unfilter.filters": "Matching {count, plural, one {filter} other {filters}}", "confirmations.unfilter.filters": "Matching {count, plural, one {filter} other {filters}}",
"content-type.change": "Content type",
"direct.group_by_conversations": "Group by conversation", "direct.group_by_conversations": "Group by conversation",
"endorsed_accounts_editor.endorsed_accounts": "Featured accounts", "endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
"favourite_modal.combo": "You can press {combo} to skip this next time", "favourite_modal.combo": "You can press {combo} to skip this next time",
"federation.federated.long": "Allow this post to reach other servers",
"federation.federated.short": "Federated",
"federation.local_only.long": "Prevent this post from reaching other servers",
"federation.local_only.short": "Local-only",
"firehose.column_settings.allow_local_only": "Show local-only posts in \"All\"", "firehose.column_settings.allow_local_only": "Show local-only posts in \"All\"",
"home.column_settings.advanced": "Advanced", "home.column_settings.advanced": "Advanced",
"home.column_settings.filter_regex": "Filter out by regular expressions", "home.column_settings.filter_regex": "Filter out by regular expressions",

View File

@ -10,10 +10,12 @@ function loaded() {
if (mountNode) { if (mountNode) {
const attr = mountNode.getAttribute('data-props'); const attr = mountNode.getAttribute('data-props');
if(!attr) return;
if (!attr) return;
const props = JSON.parse(attr); const props = JSON.parse(attr);
const root = createRoot(mountNode); const root = createRoot(mountNode);
root.render(<ComposeContainer {...props} />); root.render(<ComposeContainer {...props} />);
} }
} }

View File

@ -4,7 +4,6 @@ import {
COMPOSE_MOUNT, COMPOSE_MOUNT,
COMPOSE_UNMOUNT, COMPOSE_UNMOUNT,
COMPOSE_CHANGE, COMPOSE_CHANGE,
COMPOSE_CYCLE_ELEFRIEND,
COMPOSE_REPLY, COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL, COMPOSE_REPLY_CANCEL,
COMPOSE_DIRECT, COMPOSE_DIRECT,
@ -34,6 +33,7 @@ import {
COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE,
COMPOSE_VISIBILITY_CHANGE, COMPOSE_VISIBILITY_CHANGE,
COMPOSE_LANGUAGE_CHANGE, COMPOSE_LANGUAGE_CHANGE,
COMPOSE_COMPOSING_CHANGE,
COMPOSE_CONTENT_TYPE_CHANGE, COMPOSE_CONTENT_TYPE_CHANGE,
COMPOSE_EMOJI_INSERT, COMPOSE_EMOJI_INSERT,
COMPOSE_UPLOAD_CHANGE_REQUEST, COMPOSE_UPLOAD_CHANGE_REQUEST,
@ -43,9 +43,7 @@ import {
COMPOSE_RESET, COMPOSE_RESET,
COMPOSE_POLL_ADD, COMPOSE_POLL_ADD,
COMPOSE_POLL_REMOVE, COMPOSE_POLL_REMOVE,
COMPOSE_POLL_OPTION_ADD,
COMPOSE_POLL_OPTION_CHANGE, COMPOSE_POLL_OPTION_CHANGE,
COMPOSE_POLL_OPTION_REMOVE,
COMPOSE_POLL_SETTINGS_CHANGE, COMPOSE_POLL_SETTINGS_CHANGE,
INIT_MEDIA_EDIT_MODAL, INIT_MEDIA_EDIT_MODAL,
COMPOSE_CHANGE_MEDIA_DESCRIPTION, COMPOSE_CHANGE_MEDIA_DESCRIPTION,
@ -56,19 +54,13 @@ import {
import { REDRAFT } from '../actions/statuses'; import { REDRAFT } from '../actions/statuses';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { me, defaultContentType } from '../initial_state'; import { me, defaultContentType, pollLimits } from '../initial_state';
import { recoverHashtags } from '../utils/hashtag'; import { recoverHashtags } from '../utils/hashtag';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
import { overwrite } from '../utils/js_helpers'; import { overwrite } from '../utils/js_helpers';
import { privacyPreference } from '../utils/privacy_preference'; import { privacyPreference } from '../utils/privacy_preference';
import { uuid } from '../uuid'; import { uuid } from '../uuid';
const totalElefriends = 3;
// ~4% chance you'll end up with an unexpected friend
// glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z
const glitchProbability = 1 - 0.0420215528;
const initialState = ImmutableMap({ const initialState = ImmutableMap({
mounted: 0, mounted: 0,
advanced_options: ImmutableMap({ advanced_options: ImmutableMap({
@ -76,7 +68,6 @@ const initialState = ImmutableMap({
threaded_mode: false, threaded_mode: false,
}), }),
sensitive: false, sensitive: false,
elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends,
spoiler: false, spoiler: false,
spoiler_text: '', spoiler_text: '',
privacy: null, privacy: null,
@ -87,9 +78,10 @@ const initialState = ImmutableMap({
caretPosition: null, caretPosition: null,
preselectDate: null, preselectDate: null,
in_reply_to: null, in_reply_to: null,
is_composing: false,
is_submitting: false, is_submitting: false,
is_uploading: false,
is_changing_upload: false, is_changing_upload: false,
is_uploading: false,
progress: 0, progress: 0,
isUploadingThumbnail: false, isUploadingThumbnail: false,
thumbnailProgress: 0, thumbnailProgress: 0,
@ -252,7 +244,7 @@ function removeMedia(state, mediaId) {
const insertSuggestion = (state, position, token, completion, path) => { const insertSuggestion = (state, position, token, completion, path) => {
return state.withMutations(map => { return state.withMutations(map => {
map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`); map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
map.set('suggestion_token', null); map.set('suggestion_token', null);
map.set('suggestions', ImmutableList()); map.set('suggestions', ImmutableList());
if (path.length === 1 && path[0] === 'text') { if (path.length === 1 && path[0] === 'text') {
@ -294,14 +286,15 @@ const sortHashtagsByUse = (state, tags) => {
return sorted; return sorted;
}; };
const insertEmoji = (state, position, emojiData) => { const insertEmoji = (state, position, emojiData, needsSpace) => {
const emoji = emojiData.native; const oldText = state.get('text');
const emoji = needsSpace ? ' ' + emojiData.native : emojiData.native;
return state.withMutations(map => { return state.merge({
map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`); text: `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`,
map.set('focusDate', new Date()); focusDate: new Date(),
map.set('caretPosition', position + emoji.length + 1); caretPosition: position + emoji.length + 1,
map.set('idempotencyKey', uuid()); idempotencyKey: uuid(),
}); });
}; };
@ -363,6 +356,18 @@ const updateSuggestionTags = (state, token) => {
}); });
}; };
const updatePoll = (state, index, value) => state.updateIn(['poll', 'options'], options => {
const tmp = options.set(index, value).filterNot(x => x.trim().length === 0);
if (tmp.size === 0) {
return tmp.push('').push('');
} else if (tmp.size < pollLimits.max_options) {
return tmp.push('');
}
return tmp;
});
export default function compose(state = initialState, action) { export default function compose(state = initialState, action) {
switch(action.type) { switch(action.type) {
case STORE_HYDRATE: case STORE_HYDRATE:
@ -370,7 +375,9 @@ export default function compose(state = initialState, action) {
case COMPOSE_MOUNT: case COMPOSE_MOUNT:
return state.set('mounted', state.get('mounted') + 1); return state.set('mounted', state.get('mounted') + 1);
case COMPOSE_UNMOUNT: case COMPOSE_UNMOUNT:
return state.set('mounted', Math.max(state.get('mounted') - 1, 0)); return state
.set('mounted', Math.max(state.get('mounted') - 1, 0))
.set('is_composing', false);
case COMPOSE_ADVANCED_OPTIONS_CHANGE: case COMPOSE_ADVANCED_OPTIONS_CHANGE:
return state return state
.set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value))) .set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value)))
@ -408,9 +415,8 @@ export default function compose(state = initialState, action) {
return state return state
.set('text', action.text) .set('text', action.text)
.set('idempotencyKey', uuid()); .set('idempotencyKey', uuid());
case COMPOSE_CYCLE_ELEFRIEND: case COMPOSE_COMPOSING_CHANGE:
return state return state.set('is_composing', action.value);
.set('elefriend', (state.get('elefriend') + 1) % totalElefriends);
case COMPOSE_REPLY: case COMPOSE_REPLY:
return state.withMutations(map => { return state.withMutations(map => {
map.set('id', null); map.set('id', null);
@ -553,7 +559,7 @@ export default function compose(state = initialState, action) {
return state; return state;
} }
case COMPOSE_EMOJI_INSERT: case COMPOSE_EMOJI_INSERT:
return insertEmoji(state, action.position, action.emoji); return insertEmoji(state, action.position, action.emoji, action.needsSpace);
case COMPOSE_UPLOAD_CHANGE_SUCCESS: case COMPOSE_UPLOAD_CHANGE_SUCCESS:
return state return state
.set('is_changing_upload', false) .set('is_changing_upload', false)
@ -642,12 +648,8 @@ export default function compose(state = initialState, action) {
return state.set('poll', initialPoll); return state.set('poll', initialPoll);
case COMPOSE_POLL_REMOVE: case COMPOSE_POLL_REMOVE:
return state.set('poll', null); return state.set('poll', null);
case COMPOSE_POLL_OPTION_ADD:
return state.updateIn(['poll', 'options'], options => options.push(action.title));
case COMPOSE_POLL_OPTION_CHANGE: case COMPOSE_POLL_OPTION_CHANGE:
return state.setIn(['poll', 'options', action.index], action.title); return updatePoll(state, action.index, action.title);
case COMPOSE_POLL_OPTION_REMOVE:
return state.updateIn(['poll', 'options'], options => options.delete(action.index));
case COMPOSE_POLL_SETTINGS_CHANGE: case COMPOSE_POLL_SETTINGS_CHANGE:
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
case COMPOSE_LANGUAGE_CHANGE: case COMPOSE_LANGUAGE_CHANGE:

View File

@ -24,13 +24,14 @@
outline: 0; outline: 0;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
border: 0;
box-shadow: none; box-shadow: none;
font-family: inherit; font-family: inherit;
background: $ui-base-color; background: $ui-base-color;
color: $darker-text-color; color: $darker-text-color;
border-radius: 4px; border-radius: 4px;
font-size: 14px; border: 1px solid lighten($ui-base-color, 8%);
font-size: 17px;
line-height: normal;
margin: 0; margin: 0;
} }

View File

@ -1336,6 +1336,9 @@ a.sparkline {
&__label { &__label {
padding: 15px; padding: 15px;
display: flex;
gap: 8px;
align-items: center;
} }
&__rules { &__rules {
@ -1346,6 +1349,9 @@ a.sparkline {
&__rule { &__rule {
cursor: pointer; cursor: pointer;
padding: 15px; padding: 15px;
display: flex;
gap: 8px;
align-items: center;
} }
} }

View File

@ -8,7 +8,7 @@
body { body {
font-family: $font-sans-serif, sans-serif; font-family: $font-sans-serif, sans-serif;
background: darken($ui-base-color, 7%); background: darken($ui-base-color, 8%);
font-size: 13px; font-size: 13px;
line-height: 18px; line-height: 18px;
font-weight: 400; font-weight: 400;

File diff suppressed because it is too large Load Diff

View File

@ -40,13 +40,12 @@
.compose-form { .compose-form {
width: 400px; width: 400px;
margin: 0 auto; margin: 0 auto;
padding: 20px 0; padding: 10px 0;
margin-top: 40px; padding-bottom: 20px;
box-sizing: border-box; box-sizing: border-box;
@media screen and (width <= 400px) { @media screen and (width <= 400px) {
width: 100%; width: 100%;
margin-top: 0;
padding: 20px; padding: 20px;
} }
} }
@ -56,13 +55,15 @@
width: 400px; width: 400px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
font-size: 13px; align-items: center;
line-height: 18px; gap: 10px;
font-size: 14px;
line-height: 20px;
box-sizing: border-box; box-sizing: border-box;
padding: 20px 0; padding: 20px 0;
margin-top: 40px; margin-top: 40px;
margin-bottom: 10px; margin-bottom: 10px;
border-bottom: 1px solid $ui-base-color; border-bottom: 1px solid lighten($ui-base-color, 8%);
@media screen and (width <= 440px) { @media screen and (width <= 440px) {
width: 100%; width: 100%;
@ -73,6 +74,7 @@
.avatar { .avatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
flex: 0 0 auto;
@include avatar-size(40px); @include avatar-size(40px);
margin-inline-end: 10px; margin-inline-end: 10px;
@ -90,13 +92,14 @@
.name { .name {
flex: 1 1 auto; flex: 1 1 auto;
color: $secondary-text-color; color: $secondary-text-color;
width: calc(100% - 90px);
.username { .username {
display: block; display: block;
font-weight: 500; font-size: 16px;
line-height: 24px;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
color: $primary-text-color;
} }
} }
@ -104,7 +107,7 @@
display: block; display: block;
font-size: 32px; font-size: 32px;
line-height: 40px; line-height: 40px;
margin-inline-start: 10px; flex: 0 0 auto;
} }
} }

View File

@ -1,20 +1,7 @@
.compose-form {
.compose-form__modifiers {
.compose-form__upload {
&-description {
input {
&::placeholder {
opacity: 1;
}
}
}
}
}
}
.status__content a, .status__content a,
.link-footer a,
.reply-indicator__content a, .reply-indicator__content a,
.edit-indicator__content a,
.link-footer a,
.status__content__read-more-button, .status__content__read-more-button,
.status__content__translate-button { .status__content__translate-button {
text-decoration: underline; text-decoration: underline;
@ -42,7 +29,9 @@
} }
} }
.status__content a { .status__content a,
.reply-indicator__content a,
.edit-indicator__content a {
color: $highlight-text-color; color: $highlight-text-color;
} }
@ -50,24 +39,10 @@
color: $darker-text-color; color: $darker-text-color;
} }
.compose-form__poll-wrapper .button.button-secondary, .report-dialog-modal__textarea::placeholder {
.compose-form .autosuggest-textarea__textarea::placeholder,
.compose-form .spoiler-input__input::placeholder,
.report-dialog-modal__textarea::placeholder,
.language-dropdown__dropdown__results__item__common-name,
.compose-form .icon-button {
color: $inverted-text-color; color: $inverted-text-color;
} }
.text-icon-button.active {
color: $ui-highlight-color;
}
.language-dropdown__dropdown__results__item.active {
background: $ui-highlight-color;
font-weight: 500;
}
.link-button:disabled { .link-button:disabled {
cursor: not-allowed; cursor: not-allowed;

View File

@ -1,7 +1,6 @@
.emoji-mart { .emoji-mart {
font-size: 13px; font-size: 13px;
display: inline-block; display: inline-block;
color: $inverted-text-color;
&, &,
* { * {
@ -15,13 +14,13 @@
} }
.emoji-mart-bar { .emoji-mart-bar {
border: 0 solid darken($ui-secondary-color, 8%); border: 0 solid var(--dropdown-border-color);
&:first-child { &:first-child {
border-bottom-width: 1px; border-bottom-width: 1px;
border-top-left-radius: 5px; border-top-left-radius: 5px;
border-top-right-radius: 5px; border-top-right-radius: 5px;
background: $ui-secondary-color; background: var(--dropdown-border-color);
} }
&:last-child { &:last-child {
@ -36,7 +35,6 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 0 6px; padding: 0 6px;
color: $lighter-text-color;
line-height: 0; line-height: 0;
} }
@ -50,9 +48,10 @@
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
border: 0; border: 0;
color: $darker-text-color;
&:hover { &:hover {
color: darken($lighter-text-color, 4%); color: lighten($darker-text-color, 4%);
} }
} }
@ -60,7 +59,7 @@
color: $highlight-text-color; color: $highlight-text-color;
&:hover { &:hover {
color: darken($highlight-text-color, 4%); color: lighten($highlight-text-color, 4%);
} }
.emoji-mart-anchor-bar { .emoji-mart-anchor-bar {
@ -95,7 +94,7 @@
height: 270px; height: 270px;
max-height: 35vh; max-height: 35vh;
padding: 0 6px 6px; padding: 0 6px 6px;
background: $simple-background-color; background: var(--dropdown-background-color);
will-change: transform; will-change: transform;
&::-webkit-scrollbar-track:hover, &::-webkit-scrollbar-track:hover,
@ -107,7 +106,7 @@
.emoji-mart-search { .emoji-mart-search {
padding: 10px; padding: 10px;
padding-inline-end: 45px; padding-inline-end: 45px;
background: $simple-background-color; background: var(--dropdown-background-color);
position: relative; position: relative;
input { input {
@ -118,9 +117,9 @@
font-family: inherit; font-family: inherit;
display: block; display: block;
width: 100%; width: 100%;
background: rgba($ui-secondary-color, 0.3); background: $ui-base-color;
color: $inverted-text-color; color: $darker-text-color;
border: 1px solid $ui-secondary-color; border: 1px solid lighten($ui-base-color, 8%);
border-radius: 4px; border-radius: 4px;
&::-moz-focus-inner { &::-moz-focus-inner {
@ -155,11 +154,10 @@
&:disabled { &:disabled {
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
opacity: 0.3;
} }
svg { svg {
fill: $action-button-color; fill: $darker-text-color;
} }
} }
@ -180,7 +178,7 @@
inset-inline-start: 0; inset-inline-start: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba($ui-secondary-color, 0.7); background-color: var(--dropdown-border-color);
border-radius: 100%; border-radius: 100%;
} }
} }
@ -197,7 +195,7 @@
width: 100%; width: 100%;
font-weight: 500; font-weight: 500;
padding: 5px 6px; padding: 5px 6px;
background: $simple-background-color; background: var(--dropdown-background-color);
} }
} }
@ -241,7 +239,7 @@
.emoji-mart-no-results { .emoji-mart-no-results {
font-size: 14px; font-size: 14px;
color: $light-text-color; color: $dark-text-color;
text-align: center; text-align: center;
padding: 5px 6px; padding: 5px 6px;
padding-top: 70px; padding-top: 70px;

View File

@ -145,10 +145,6 @@ html {
} }
} }
.compose-form__autosuggest-wrapper,
.poll__option input[type='text'],
.compose-form .spoiler-input__input,
.compose-form__poll-wrapper select,
.search__input, .search__input,
.setting-text, .setting-text,
.report-dialog-modal__textarea, .report-dialog-modal__textarea,
@ -172,28 +168,11 @@ html {
border-bottom: 0; border-bottom: 0;
} }
.compose-form__poll-wrapper select {
background: $simple-background-color
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>")
no-repeat right 8px center / auto 16px;
}
.compose-form__poll-wrapper,
.compose-form__poll-wrapper .poll__footer {
border-top-color: lighten($ui-base-color, 8%);
}
.notification__filter-bar { .notification__filter-bar {
border: 1px solid lighten($ui-base-color, 8%); border: 1px solid lighten($ui-base-color, 8%);
border-top: 0; border-top: 0;
} }
.compose-form .compose-form__buttons-wrapper {
background: $ui-base-color;
border: 1px solid lighten($ui-base-color, 8%);
border-top: 0;
}
.drawer__header, .drawer__header,
.drawer__inner { .drawer__inner {
background: $white; background: $white;
@ -206,52 +185,6 @@ html {
no-repeat bottom / 100% auto; no-repeat bottom / 100% auto;
} }
// Change the colors used in compose-form
.compose-form {
.compose-form__modifiers {
.compose-form__upload__actions .icon-button,
.compose-form__upload__warning .icon-button {
color: lighten($white, 7%);
&:active,
&:focus,
&:hover {
color: $white;
}
}
}
.compose-form__buttons-wrapper {
background: darken($ui-base-color, 6%);
}
.autosuggest-textarea__suggestions {
background: darken($ui-base-color, 6%);
}
.autosuggest-textarea__suggestions__item {
&:hover,
&:focus,
&:active,
&.selected {
background: lighten($ui-base-color, 4%);
}
}
}
.emoji-mart-bar {
border-color: lighten($ui-base-color, 4%);
&:first-child {
background: darken($ui-base-color, 6%);
}
}
.emoji-mart-search input {
background: rgba($ui-base-color, 0.3);
border-color: $ui-base-color;
}
.upload-progress__backdrop { .upload-progress__backdrop {
background: $ui-base-color; background: $ui-base-color;
} }
@ -283,46 +216,11 @@ html {
background: $ui-base-color; background: $ui-base-color;
} }
.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {
color: $white;
}
.account-gallery__item a { .account-gallery__item a {
background-color: $ui-base-color; background-color: $ui-base-color;
} }
// Change the colors used in the dropdown menu
.dropdown-menu {
background: $white;
&__arrow::before {
background-color: $white;
}
&__item {
color: $darker-text-color;
&--dangerous {
color: $error-value-color;
}
a,
button {
background: $white;
}
}
}
// Change the text colors on inverted background // Change the text colors on inverted background
.privacy-dropdown__option.active,
.privacy-dropdown__option:hover,
.privacy-dropdown__option.active .privacy-dropdown__option__content,
.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
.privacy-dropdown__option:hover .privacy-dropdown__option__content,
.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,
.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:active,
.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:focus,
.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:hover,
.actions-modal ul li:not(:empty) a.active, .actions-modal ul li:not(:empty) a.active,
.actions-modal ul li:not(:empty) a.active button, .actions-modal ul li:not(:empty) a.active button,
.actions-modal ul li:not(:empty) a:active, .actions-modal ul li:not(:empty) a:active,
@ -331,7 +229,6 @@ html {
.actions-modal ul li:not(:empty) a:focus button, .actions-modal ul li:not(:empty) a:focus button,
.actions-modal ul li:not(:empty) a:hover, .actions-modal ul li:not(:empty) a:hover,
.actions-modal ul li:not(:empty) a:hover button, .actions-modal ul li:not(:empty) a:hover button,
.language-dropdown__dropdown__results__item.active,
.admin-wrapper .sidebar ul .simple-navigation-active-leaf a, .admin-wrapper .sidebar ul .simple-navigation-active-leaf a,
.simple_form .block-button, .simple_form .block-button,
.simple_form .button, .simple_form .button,
@ -339,19 +236,6 @@ html {
color: $white; color: $white;
} }
.language-dropdown__dropdown__results__item
.language-dropdown__dropdown__results__item__common-name {
color: lighten($ui-base-color, 8%);
}
.language-dropdown__dropdown__results__item.active
.language-dropdown__dropdown__results__item__common-name {
color: darken($ui-base-color, 12%);
}
.dropdown-menu__separator,
.dropdown-menu__item.edited-timestamp__history__item,
.dropdown-menu__container__header,
.compare-history-modal .report-modal__target, .compare-history-modal .report-modal__target,
.report-dialog-modal .poll__option.dialog-option { .report-dialog-modal .poll__option.dialog-option {
border-bottom-color: lighten($ui-base-color, 4%); border-bottom-color: lighten($ui-base-color, 4%);
@ -385,10 +269,7 @@ html {
.reactions-bar__item:hover, .reactions-bar__item:hover,
.reactions-bar__item:focus, .reactions-bar__item:focus,
.reactions-bar__item:active, .reactions-bar__item:active {
.language-dropdown__dropdown__results__item:hover,
.language-dropdown__dropdown__results__item:focus,
.language-dropdown__dropdown__results__item:active {
background-color: $ui-base-color; background-color: $ui-base-color;
} }
@ -631,11 +512,6 @@ html {
} }
} }
.reply-indicator {
background: transparent;
border: 1px solid lighten($ui-base-color, 8%);
}
.status__content, .status__content,
.reply-indicator__content { .reply-indicator__content {
a { a {
@ -745,3 +621,30 @@ html {
filter: contrast(75%) brightness(75%) !important; filter: contrast(75%) brightness(75%) !important;
} }
} }
.compose-form__actions .icon-button.active,
.dropdown-button.active,
.privacy-dropdown__option.active,
.privacy-dropdown__option:focus,
.language-dropdown__dropdown__results__item:focus,
.language-dropdown__dropdown__results__item.active,
.privacy-dropdown__option:focus .privacy-dropdown__option__content,
.privacy-dropdown__option:focus .privacy-dropdown__option__content strong,
.privacy-dropdown__option.active .privacy-dropdown__option__content,
.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
.language-dropdown__dropdown__results__item:focus
.language-dropdown__dropdown__results__item__common-name,
.language-dropdown__dropdown__results__item.active
.language-dropdown__dropdown__results__item__common-name {
color: $white;
}
.compose-form .spoiler-input__input {
color: lighten($ui-highlight-color, 8%);
}
.compose-form .autosuggest-textarea__textarea,
.compose-form__highlightable,
.poll__option input[type='text'] {
background: darken($ui-base-color, 10%);
}

View File

@ -34,7 +34,7 @@ $ui-button-tertiary-border-color: $blurple-500 !default;
$primary-text-color: $black !default; $primary-text-color: $black !default;
$darker-text-color: $classic-base-color !default; $darker-text-color: $classic-base-color !default;
$highlight-text-color: darken($ui-highlight-color, 8%) !default; $highlight-text-color: $ui-highlight-color !default;
$dark-text-color: #444b5d; $dark-text-color: #444b5d;
$action-button-color: #606984; $action-button-color: #606984;
@ -55,3 +55,8 @@ $account-background-color: $white !default;
} }
$emojis-requiring-inversion: 'chains'; $emojis-requiring-inversion: 'chains';
.theme-mastodon-light {
--dropdown-border-color: #d9e1e8;
--dropdown-background-color: #fff;
}

View File

@ -1,5 +1,5 @@
.modal-layout { .modal-layout {
background: $ui-base-color background: darken($ui-base-color, 4%)
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-lighter-color)}33"/></svg>') url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-lighter-color)}33"/></svg>')
repeat-x bottom fixed; repeat-x bottom fixed;
display: flex; display: flex;

View File

@ -58,6 +58,8 @@
&__option { &__option {
position: relative; position: relative;
display: flex; display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 0; padding: 6px 0;
line-height: 18px; line-height: 18px;
cursor: default; cursor: default;
@ -84,16 +86,22 @@
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
font-size: 14px; font-size: 14px;
color: $inverted-text-color; color: $secondary-text-color;
outline: 0; outline: 0;
font-family: inherit; font-family: inherit;
background: $simple-background-color; background: $ui-base-color;
border: 1px solid darken($simple-background-color, 14%); border: 1px solid $darker-text-color;
border-radius: 4px; border-radius: 4px;
padding: 6px 10px; padding: 8px 12px;
&:focus { &:focus {
border-color: $highlight-text-color; border-color: $ui-highlight-color;
}
@media screen and (width <= 600px) {
font-size: 16px;
line-height: 24px;
letter-spacing: 0.5px;
} }
} }
@ -102,26 +110,20 @@
} }
&.editable { &.editable {
display: flex;
align-items: center; align-items: center;
overflow: visible; overflow: visible;
} }
} }
&__input { &__input {
display: inline-block; display: block;
position: relative; position: relative;
border: 1px solid $ui-primary-color; border: 1px solid $ui-primary-color;
box-sizing: border-box; box-sizing: border-box;
width: 18px; width: 17px;
height: 18px; height: 17px;
margin-inline-end: 10px;
top: -1px;
border-radius: 50%; border-radius: 50%;
vertical-align: middle; flex: 0 0 auto;
margin-top: auto;
margin-bottom: auto;
flex: 0 0 18px;
&.checkbox { &.checkbox {
border-radius: 4px; border-radius: 4px;
@ -165,6 +167,15 @@
} }
} }
&__option.editable &__input {
&:active,
&:focus,
&:hover {
border-color: $ui-primary-color;
border-width: 1px;
}
}
&__number { &__number {
display: inline-block; display: inline-block;
width: 45px; width: 45px;
@ -215,92 +226,6 @@
} }
} }
.compose-form__poll-wrapper {
border-top: 1px solid darken($simple-background-color, 8%);
overflow-x: hidden;
ul {
padding: 10px;
}
.poll__input {
&:active,
&:focus,
&:hover {
border-color: $ui-button-focus-background-color;
}
}
.poll__footer {
border-top: 1px solid darken($simple-background-color, 8%);
padding: 10px;
display: flex;
align-items: center;
button,
select {
width: 100%;
flex: 1 1 50%;
&:focus {
border-color: $highlight-text-color;
}
}
}
.button.button-secondary {
font-size: 14px;
font-weight: 400;
padding: 6px 10px;
height: auto;
line-height: inherit;
color: $action-button-color;
border-color: $action-button-color;
margin-inline-end: 5px;
&:hover,
&:focus,
&.active {
border-color: $action-button-color;
background-color: $action-button-color;
color: $ui-button-color;
}
}
li {
display: flex;
align-items: center;
.poll__option {
flex: 0 0 auto;
width: calc(100% - (23px + 6px));
margin-inline-end: 6px;
}
}
select {
appearance: none;
box-sizing: border-box;
font-size: 14px;
color: $inverted-text-color;
display: inline-block;
width: auto;
outline: 0;
font-family: inherit;
background: $simple-background-color
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>")
no-repeat right 8px center / auto 16px;
border: 1px solid darken($simple-background-color, 14%);
border-radius: 4px;
padding: 6px 10px;
padding-inline-end: 30px;
}
.icon-button.disabled {
color: darken($simple-background-color, 14%);
}
}
.muted .poll { .muted .poll {
color: $dark-text-color; color: $dark-text-color;

View File

@ -0,0 +1,25 @@
<svg width="5" height="80" viewBox="0 0 5 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_253_1286)">
<rect width="5" height="80" fill="url(#paint0_linear_253_1286)"/>
<line x1="-0.860365" y1="6.80136" x2="10.6078" y2="-1.22871" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="14.8314" x2="10.6078" y2="6.80132" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="22.8615" x2="10.6078" y2="14.8314" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="30.8916" x2="10.6078" y2="22.8615" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="38.9216" x2="10.6078" y2="30.8915" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="46.9517" x2="10.6078" y2="38.9216" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="54.9818" x2="10.6078" y2="46.9517" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="63.0118" x2="10.6078" y2="54.9817" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="71.0419" x2="10.6078" y2="63.0118" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="79.072" x2="10.6078" y2="71.0419" stroke="black" stroke-width="3"/>
<line x1="-0.860365" y1="87.102" x2="10.6078" y2="79.072" stroke="black" stroke-width="3"/>
</g>
<defs>
<linearGradient id="paint0_linear_253_1286" x1="2.5" y1="0" x2="2.5" y2="80" gradientUnits="userSpaceOnUse">
<stop stop-color="#FEC84B"/>
<stop offset="1" stop-color="#F79009"/>
</linearGradient>
<clipPath id="clip0_253_1286">
<rect width="5" height="80" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -9,7 +9,11 @@ exports[`<AutosuggestEmoji /> renders emoji with custom url 1`] = `
className="emojione" className="emojione"
src="http://example.com/emoji.png" src="http://example.com/emoji.png"
/> />
:foobar: <div
className="autosuggest-emoji__name"
>
:foobar:
</div>
</div> </div>
`; `;
@ -22,6 +26,10 @@ exports[`<AutosuggestEmoji /> renders native emoji 1`] = `
className="emojione" className="emojione"
src="/emoji/1f499.svg" src="/emoji/1f499.svg"
/> />
:foobar: <div
className="autosuggest-emoji__name"
>
:foobar:
</div>
</div> </div>
`; `;

View File

@ -37,10 +37,10 @@ class Account extends ImmutablePureComponent {
static propTypes = { static propTypes = {
size: PropTypes.number, size: PropTypes.number,
account: ImmutablePropTypes.record, account: ImmutablePropTypes.record,
onFollow: PropTypes.func.isRequired, onFollow: PropTypes.func,
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func,
onMute: PropTypes.func.isRequired, onMute: PropTypes.func,
onMuteNotifications: PropTypes.func.isRequired, onMuteNotifications: PropTypes.func,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
minimal: PropTypes.bool, minimal: PropTypes.bool,

View File

@ -35,7 +35,7 @@ export default class AutosuggestEmoji extends PureComponent {
alt={emoji.native || emoji.colons} alt={emoji.native || emoji.colons}
/> />
{emoji.colons} <div className='autosuggest-emoji__name'>{emoji.colons}</div>
</div> </div>
); );
} }

View File

@ -1,5 +1,3 @@
import { FormattedMessage } from 'react-intl';
import { ShortNumber } from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
interface Props { interface Props {
@ -16,27 +14,18 @@ interface Props {
}; };
} }
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => { export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => (
const weeklyUses = tag.history && ( <div className='autosuggest-hashtag'>
<ShortNumber <div className='autosuggest-hashtag__name'>
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)} #<strong>{tag.name}</strong>
/>
);
return (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<FormattedMessage
id='autosuggest_hashtag.per_week'
defaultMessage='{count} per week'
values={{ count: weeklyUses }}
/>
</div>
)}
</div> </div>
);
}; {tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
</div>
)}
</div>
);

View File

@ -5,6 +5,8 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Overlay from 'react-overlays/Overlay';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestEmoji from './autosuggest_emoji';
@ -195,34 +197,37 @@ export default class AutosuggestInput extends ImmutablePureComponent {
return ( return (
<div className='autosuggest-input'> <div className='autosuggest-input'>
<label> <input
<span style={{ display: 'none' }}>{placeholder}</span> type='text'
ref={this.setInput}
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
dir='auto'
aria-autocomplete='list'
aria-label={placeholder}
id={id}
className={className}
maxLength={maxLength}
lang={lang}
spellCheck={spellCheck}
/>
<input <Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
type='text' {({ props }) => (
ref={this.setInput} <div {...props}>
disabled={disabled} <div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
placeholder={placeholder} {suggestions.map(this.renderSuggestion)}
autoFocus={autoFocus} </div>
value={value} </div>
onChange={this.onChange} )}
onKeyDown={this.onKeyDown} </Overlay>
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
dir='auto'
aria-autocomplete='list'
id={id}
className={className}
maxLength={maxLength}
lang={lang}
spellCheck={spellCheck}
/>
</label>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div> </div>
); );
} }

View File

@ -5,6 +5,7 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Overlay from 'react-overlays/Overlay';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
@ -52,7 +53,6 @@ const AutosuggestTextarea = forwardRef(({
onFocus, onFocus,
autoFocus = true, autoFocus = true,
lang, lang,
children,
}, textareaRef) => { }, textareaRef) => {
const [suggestionsHidden, setSuggestionsHidden] = useState(true); const [suggestionsHidden, setSuggestionsHidden] = useState(true);
@ -183,40 +183,38 @@ const AutosuggestTextarea = forwardRef(({
); );
}; };
return [ return (
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> <div className='autosuggest-textarea'>
<div className='autosuggest-textarea'> <Textarea
<label> ref={textareaRef}
<span style={{ display: 'none' }}>{placeholder}</span> className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onKeyUp={onKeyUp}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
dir='auto'
aria-autocomplete='list'
aria-label={placeholder}
lang={lang}
/>
<Textarea <Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
ref={textareaRef} {({ props }) => (
className='autosuggest-textarea__textarea' <div {...props}>
disabled={disabled} <div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
placeholder={placeholder} {suggestions.map(renderSuggestion)}
autoFocus={autoFocus} </div>
value={value} </div>
onChange={handleChange} )}
onKeyDown={handleKeyDown} </Overlay>
onKeyUp={onKeyUp} </div>
onFocus={handleFocus} );
onBlur={handleBlur}
onPaste={handlePaste}
dir='auto'
aria-autocomplete='list'
lang={lang}
/>
</label>
</div>
{children}
</div>,
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(renderSuggestion)}
</div>
</div>,
];
}); });
AutosuggestTextarea.propTypes = { AutosuggestTextarea.propTypes = {
@ -232,7 +230,6 @@ AutosuggestTextarea.propTypes = {
onKeyDown: PropTypes.func, onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired,
onFocus:PropTypes.func, onFocus:PropTypes.func,
children: PropTypes.node,
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
lang: PropTypes.string, lang: PropTypes.string,
}; };

View File

@ -165,7 +165,7 @@ class Dropdown extends PureComponent {
children: PropTypes.node, children: PropTypes.node,
icon: PropTypes.string, icon: PropTypes.string,
iconComponent: PropTypes.func, iconComponent: PropTypes.func,
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired, items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]),
loading: PropTypes.bool, loading: PropTypes.bool,
size: PropTypes.number, size: PropTypes.number,
title: PropTypes.string, title: PropTypes.string,

View File

@ -70,9 +70,9 @@ export const defaultMediaVisibility = (status) => {
const messages = defineMessages({ const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
}); });

View File

@ -2,8 +2,8 @@ import { defineMessages, useIntl } from 'react-intl';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import { Icon } from './icon'; import { Icon } from './icon';
@ -11,14 +11,17 @@ type Visibility = 'public' | 'unlisted' | 'private' | 'direct';
const messages = defineMessages({ const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, unlisted_short: {
id: 'privacy.unlisted.short',
defaultMessage: 'Quiet public',
},
private_short: { private_short: {
id: 'privacy.private.short', id: 'privacy.private.short',
defaultMessage: 'Followers only', defaultMessage: 'Followers',
}, },
direct_short: { direct_short: {
id: 'privacy.direct.short', id: 'privacy.direct.short',
defaultMessage: 'Mentioned people only', defaultMessage: 'Specific people',
}, },
}); });
@ -35,7 +38,7 @@ export const VisibilityIcon: React.FC<{ visibility: Visibility }> = ({
}, },
unlisted: { unlisted: {
icon: 'unlock', icon: 'unlock',
iconComponent: LockOpenIcon, iconComponent: QuietTimeIcon,
text: intl.formatMessage(messages.unlisted_short), text: intl.formatMessage(messages.unlisted_short),
}, },
private: { private: {

View File

@ -1,14 +1,12 @@
import { PureComponent } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { fetchCustomEmojis } from '../actions/custom_emojis'; import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
import { hydrateStore } from '../actions/store'; import { hydrateStore } from 'mastodon/actions/store';
import Compose from '../features/standalone/compose'; import { Router } from 'mastodon/components/router';
import initialState from '../initial_state'; import Compose from 'mastodon/features/standalone/compose';
import { IntlProvider } from '../locales'; import initialState from 'mastodon/initial_state';
import { store } from '../store'; import { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store';
if (initialState) { if (initialState) {
store.dispatch(hydrateStore(initialState)); store.dispatch(hydrateStore(initialState));
@ -16,16 +14,14 @@ if (initialState) {
store.dispatch(fetchCustomEmojis()); store.dispatch(fetchCustomEmojis());
export default class ComposeContainer extends PureComponent { const ComposeContainer = () => (
<IntlProvider>
<Provider store={store}>
<Router>
<Compose />
</Router>
</Provider>
</IntlProvider>
);
render () { export default ComposeContainer;
return (
<IntlProvider>
<Provider store={store}>
<Compose />
</Provider>
</IntlProvider>
);
}
}

View File

@ -1,13 +1,13 @@
import PropTypes from 'prop-types'; import { useCallback } from 'react';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes'; import { useDispatch } from 'react-redux';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { openModal } from 'mastodon/actions/modal';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { logOut } from 'mastodon/utils/log_out';
const messages = defineMessages({ const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
@ -23,51 +23,52 @@ const messages = defineMessages({
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' }, filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
}); });
class ActionBar extends PureComponent { export const ActionBar = () => {
const dispatch = useDispatch();
const intl = useIntl();
static propTypes = { const handleLogoutClick = useCallback(() => {
account: ImmutablePropTypes.record.isRequired, dispatch(openModal({
onLogout: PropTypes.func.isRequired, modalType: 'CONFIRM',
intl: PropTypes.object.isRequired, modalProps: {
}; message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
closeWhenConfirm: false,
onConfirm: () => logOut(),
},
}));
}, [dispatch, intl]);
handleLogout = () => { let menu = [];
this.props.onLogout();
};
render () { menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
const { intl } = this.props; menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.logout), action: handleLogoutClick });
let menu = []; return (
<DropdownMenuContainer
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); items={menu}
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }); icon='bars'
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); iconComponent={MoreHorizIcon}
menu.push(null); size={24}
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); direction='right'
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); />
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); );
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); };
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
return (
<div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'>
<DropdownMenuContainer items={menu} icon='bars' iconComponent={MenuIcon} size={24} direction='right' />
</div>
</div>
);
}
}
export default injectIntl(ActionBar);

View File

@ -15,7 +15,7 @@ export default class AutosuggestAccount extends ImmutablePureComponent {
return ( return (
<div className='autosuggest-account' title={account.get('acct')}> <div className='autosuggest-account' title={account.get('acct')}>
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div> <Avatar account={account} size={24} />
<DisplayName account={account} /> <DisplayName account={account} />
</div> </div>
); );

View File

@ -1,26 +1,18 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { length } from 'stringz'; import { length } from 'stringz';
export default class CharacterCounter extends PureComponent { export const CharacterCounter = ({ text, max }) => {
const diff = max - length(text);
static propTypes = { if (diff < 0) {
text: PropTypes.string.isRequired, return <span className='character-counter character-counter--over'>{diff}</span>;
max: PropTypes.number.isRequired,
};
checkRemainingText (diff) {
if (diff < 0) {
return <span className='character-counter character-counter--over'>{diff}</span>;
}
return <span className='character-counter'>{diff}</span>;
} }
render () { return <span className='character-counter'>{diff}</span>;
const diff = this.props.max - length(this.props.text); };
return this.checkRemainingText(diff);
}
} CharacterCounter.propTypes = {
text: PropTypes.string.isRequired,
max: PropTypes.number.isRequired,
};

View File

@ -10,8 +10,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz'; import { length } from 'stringz';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import { Icon } from 'mastodon/components/icon';
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router'; import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
import AutosuggestInput from '../../../components/autosuggest_input'; import AutosuggestInput from '../../../components/autosuggest_input';
@ -21,25 +19,27 @@ import { maxChars } from '../../../initial_state';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import LanguageDropdown from '../containers/language_dropdown_container'; import LanguageDropdown from '../containers/language_dropdown_container';
import PollButtonContainer from '../containers/poll_button_container'; import PollButtonContainer from '../containers/poll_button_container';
import PollFormContainer from '../containers/poll_form_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import SpoilerButtonContainer from '../containers/spoiler_button_container'; import SpoilerButtonContainer from '../containers/spoiler_button_container';
import UploadButtonContainer from '../containers/upload_button_container'; import UploadButtonContainer from '../containers/upload_button_container';
import UploadFormContainer from '../containers/upload_form_container'; import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container'; import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter'; import { countableText } from '../util/counter';
import CharacterCounter from './character_counter'; import { CharacterCounter } from './character_counter';
import { EditIndicator } from './edit_indicator';
import { NavigationBar } from './navigation_bar';
import { PollForm } from "./poll_form";
import { ReplyIndicator } from './reply_indicator';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning (optional)' },
publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }, publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Update' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' }, reply: { id: 'compose_form.reply', defaultMessage: 'Reply' },
}); });
class ComposeForm extends ImmutablePureComponent { class ComposeForm extends ImmutablePureComponent {
@ -66,6 +66,7 @@ class ComposeForm extends ImmutablePureComponent {
onPaste: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
withoutNavigation: PropTypes.bool,
anyMedia: PropTypes.bool, anyMedia: PropTypes.bool,
isInReply: PropTypes.bool, isInReply: PropTypes.bool,
singleColumn: PropTypes.bool, singleColumn: PropTypes.bool,
@ -224,93 +225,90 @@ class ComposeForm extends ImmutablePureComponent {
}; };
render () { render () {
const { intl, onPaste, autoFocus } = this.props; const { intl, onPaste, autoFocus, withoutNavigation } = this.props;
const { highlighted } = this.state; const { highlighted } = this.state;
const disabled = this.props.isSubmitting; const disabled = this.props.isSubmitting;
let publishText = '';
if (this.props.isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
publishText = <><Icon id='lock' icon={LockIcon} /> {intl.formatMessage(messages.publish)}</>;
} else {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
return ( return (
<form className='compose-form' onSubmit={this.handleSubmit}> <form className='compose-form' onSubmit={this.handleSubmit}>
<ReplyIndicator />
{!withoutNavigation && <NavigationBar />}
<WarningContainer /> <WarningContainer />
<ReplyIndicatorContainer /> <div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
<div className='compose-form__scrollable'>
<EditIndicator />
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}> {this.props.spoiler && (
<AutosuggestInput <div className='spoiler-input'>
placeholder={intl.formatMessage(messages.spoiler_placeholder)} <div className='spoiler-input__border' />
value={this.props.spoilerText}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
disabled={!this.props.spoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='spoiler-input__input'
lang={this.props.lang}
spellCheck
/>
</div>
<div className={classNames('compose-form__highlightable', { active: highlighted })}> <AutosuggestInput
<AutosuggestTextarea placeholder={intl.formatMessage(messages.spoiler_placeholder)}
ref={this.textareaRef} value={this.props.spoilerText}
placeholder={intl.formatMessage(messages.placeholder)} disabled={disabled}
disabled={disabled} onChange={this.handleChangeSpoilerText}
value={this.props.text} onKeyDown={this.handleKeyDown}
onChange={this.handleChange} ref={this.setSpoilerText}
suggestions={this.props.suggestions} suggestions={this.props.suggestions}
onFocus={this.handleFocus} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onKeyDown={this.handleKeyDown} onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionSelected={this.onSpoilerSuggestionSelected}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} searchTokens={[':']}
onSuggestionSelected={this.onSuggestionSelected} id='cw-spoiler-input'
onPaste={onPaste} className='spoiler-input__input'
autoFocus={autoFocus} lang={this.props.lang}
lang={this.props.lang} spellCheck
> />
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
</AutosuggestTextarea>
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
<div className='compose-form__buttons-wrapper'> <div className='spoiler-input__border' />
<div className='compose-form__buttons'> </div>
<UploadButtonContainer /> )}
<PollButtonContainer />
<AutosuggestTextarea
ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={autoFocus}
lang={this.props.lang}
/>
</div>
<UploadFormContainer />
<PollForm />
<div className='compose-form__footer'>
<div className='compose-form__dropdowns'>
<PrivacyDropdownContainer disabled={this.props.isEditing} /> <PrivacyDropdownContainer disabled={this.props.isEditing} />
<SpoilerButtonContainer />
<LanguageDropdown /> <LanguageDropdown />
</div> </div>
<div className='character-counter__wrapper'> <div className='compose-form__actions'>
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} /> <div className='compose-form__buttons'>
</div> <UploadButtonContainer />
</div> <PollButtonContainer />
</div> <SpoilerButtonContainer />
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
<CharacterCounter max={maxChars} text={this.getFulltextForCharacterCounting()} />
</div>
<div className='compose-form__publish'> <div className='compose-form__submit'>
<div className='compose-form__publish-button-wrapper'> <Button
<Button type='submit'
type='submit' text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
text={publishText} disabled={!this.canSubmit()}
disabled={!this.canSubmit()} />
block </div>
/> </div>
</div> </div>
</div> </div>
</form> </form>

View File

@ -0,0 +1,62 @@
import { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import BarChart4BarsIcon from 'mastodon/../material-icons/400-24px/bar_chart_4_bars.svg?react';
import CloseIcon from 'mastodon/../material-icons/400-24px/close.svg?react';
import PhotoLibraryIcon from 'mastodon/../material-icons/400-24px/photo_library.svg?react';
import { cancelReplyCompose } from 'mastodon/actions/compose';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
export const EditIndicator = () => {
const intl = useIntl();
const dispatch = useDispatch();
const id = useSelector(state => state.getIn(['compose', 'id']));
const status = useSelector(state => state.getIn(['statuses', id]));
const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
const handleCancelClick = useCallback(() => {
dispatch(cancelReplyCompose());
}, [dispatch]);
if (!status) {
return null;
}
const content = { __html: status.get('contentHtml') };
return (
<div className='edit-indicator'>
<div className='edit-indicator__header'>
<div className='edit-indicator__display-name'>
<Link to={`/@${account.get('acct')}`}>@{account.get('acct')}</Link>
·
<Link to={`/@${account.get('acct')}/${status.get('id')}`}><RelativeTimestamp timestamp={status.get('created_at')} /></Link>
</div>
<div className='edit-indicator__cancel'>
<IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={handleCancelClick} inverted />
</div>
</div>
<div className='edit-indicator__content translate' dangerouslySetInnerHTML={content} />
{(status.get('poll') || status.get('media_attachments').size > 0) && (
<div className='edit-indicator__attachments'>
{status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
{status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
</div>
)}
</div>
);
};

View File

@ -10,6 +10,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import MoodIcon from 'mastodon/../material-icons/400-24px/mood.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import { assetHost } from 'mastodon/utils/config'; import { assetHost } from 'mastodon/utils/config';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
@ -321,7 +323,6 @@ class EmojiPickerDropdown extends PureComponent {
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired, skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
}; };
state = { state = {
@ -379,23 +380,24 @@ class EmojiPickerDropdown extends PureComponent {
}; };
render () { render () {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props; const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji); const title = intl.formatMessage(messages.emoji);
const { active, loading } = this.state; const { active, loading } = this.state;
return ( return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}>
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> <IconButton
{button || <img title={title}
className={classNames('emojione', { 'pulse-loading': active && loading })} aria-expanded={active}
alt='🙂' active={active}
src={`${assetHost}/emoji/1f642.svg`} iconComponent={MoodIcon}
/>} onClick={this.onToggle}
</div> inverted
/>
<Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}> <Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
{({ props, placement })=> ( {({ props, placement })=> (
<div {...props} style={{ ...props.style, width: 299 }}> <div {...props} style={{ ...props.style }}>
<div className={`dropdown-animation ${placement}`}> <div className={`dropdown-animation ${placement}`}>
<EmojiPickerMenu <EmojiPickerMenu
custom_emojis={this.props.custom_emojis} custom_emojis={this.props.custom_emojis}

View File

@ -9,10 +9,11 @@ import { supportsPassiveEvents } from 'detect-passive-events';
import fuzzysort from 'fuzzysort'; import fuzzysort from 'fuzzysort';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import CancelIcon from 'mastodon/../material-icons/400-24px/cancel-fill.svg?react';
import SearchIcon from 'mastodon/../material-icons/400-24px/search.svg?react';
import TranslateIcon from 'mastodon/../material-icons/400-24px/translate.svg?react';
import { Icon } from 'mastodon/components/icon';
import { languages as preloadedLanguages } from 'mastodon/initial_state'; import { languages as preloadedLanguages } from 'mastodon/initial_state';
import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
import TextIconButton from './text_icon_button';
const messages = defineMessages({ const messages = defineMessages({
changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' }, changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
@ -231,7 +232,7 @@ class LanguageDropdownMenu extends PureComponent {
<div ref={this.setRef}> <div ref={this.setRef}>
<div className='emoji-mart-search'> <div className='emoji-mart-search'>
<input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} /> <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
<button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button> <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}><Icon icon={!isSearching ? SearchIcon : CancelIcon} /></button>
</div> </div>
<div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}> <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
@ -297,20 +298,24 @@ class LanguageDropdown extends PureComponent {
render () { render () {
const { value, intl, frequentlyUsedLanguages } = this.props; const { value, intl, frequentlyUsedLanguages } = this.props;
const { open, placement } = this.state; const { open, placement } = this.state;
const current = preloadedLanguages.find(lang => lang[0] === value) ?? [];
return ( return (
<div className={classNames('privacy-dropdown', placement, { active: open })}> <div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<div className='privacy-dropdown__value' ref={this.setTargetRef} > <button
<TextIconButton type='button'
className='privacy-dropdown__value-icon' title={intl.formatMessage(messages.changeLanguage)}
label={value && value.toUpperCase()} aria-expanded={open}
title={intl.formatMessage(messages.changeLanguage)} onClick={this.handleToggle}
active={open} onMouseDown={this.handleMouseDown}
onClick={this.handleToggle} onKeyDown={this.handleButtonKeyDown}
/> className={classNames('dropdown-button', { active: open })}
</div> >
<Icon icon={TranslateIcon} />
<span className='dropdown-button__label'>{current[2] ?? value}</span>
</button>
<Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}> <Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement }) => ( {({ props, placement }) => (
<div {...props}> <div {...props}>
<div className={`dropdown-animation language-dropdown__dropdown ${placement}`} > <div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >

View File

@ -1,50 +1,36 @@
import PropTypes from 'prop-types'; import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom'; import { useSelector, useDispatch } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes'; import CloseIcon from 'mastodon/../material-icons/400-24px/close.svg?react';
import ImmutablePureComponent from 'react-immutable-pure-component'; import { cancelReplyCompose } from 'mastodon/actions/compose';
import Account from 'mastodon/components/account';
import { IconButton } from 'mastodon/components/icon_button';
import { me } from 'mastodon/initial_state';
import { Avatar } from '../../../components/avatar'; import { ActionBar } from './action_bar';
import ActionBar from './action_bar';
export default class NavigationBar extends ImmutablePureComponent { const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
static propTypes = { export const NavigationBar = () => {
account: ImmutablePropTypes.record.isRequired, const dispatch = useDispatch();
onLogout: PropTypes.func.isRequired, const intl = useIntl();
onClose: PropTypes.func, const account = useSelector(state => state.getIn(['accounts', me]));
}; const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
render () { const handleCancelClick = useCallback(() => {
const username = this.props.account.get('acct'); dispatch(cancelReplyCompose());
return ( }, [dispatch]);
<div className='navigation-bar'>
<Link to={`/@${username}`}>
<span style={{ display: 'none' }}>{username}</span>
<Avatar account={this.props.account} size={46} />
</Link>
<div className='navigation-bar__profile'> return (
<span> <div className='navigation-bar'>
<Link to={`/@${username}`}> <Account account={account} minimal />
<strong className='navigation-bar__profile-account'>@{username}</strong> {isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
</Link> </div>
</span> );
};
<span>
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</span>
</div>
<div className='navigation-bar__actions'>
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
</div>
</div>
);
}
}

View File

@ -3,11 +3,10 @@ import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import { IconButton } from '../../../components/icon_button'; import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({ const messages = defineMessages({
add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' }, add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' },
remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' }, remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' },
@ -22,7 +21,6 @@ class PollButton extends PureComponent {
static propTypes = { static propTypes = {
disabled: PropTypes.bool, disabled: PropTypes.bool,
unavailable: PropTypes.bool,
active: PropTypes.bool, active: PropTypes.bool,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@ -33,17 +31,13 @@ class PollButton extends PureComponent {
}; };
render () { render () {
const { intl, active, unavailable, disabled } = this.props; const { intl, active, disabled } = this.props;
if (unavailable) {
return null;
}
return ( return (
<div className='compose-form__poll-button'> <div className='compose-form__poll-button'>
<IconButton <IconButton
icon='tasks' icon='tasks'
iconComponent={InsertChartIcon} iconComponent={BarChart4BarsIcon}
title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)} title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)}
disabled={disabled} disabled={disabled}
onClick={this.handleClick} onClick={this.handleClick}

View File

@ -1,189 +1,162 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { useCallback } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import { useDispatch, useSelector } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AddIcon from '@/material-icons/400-24px/add.svg?react'; import {
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; changePollSettings,
changePollOption,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
} from 'mastodon/actions/compose';
import AutosuggestInput from 'mastodon/components/autosuggest_input'; import AutosuggestInput from 'mastodon/components/autosuggest_input';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
const messages = defineMessages({ const messages = defineMessages({
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' }, option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Option {number}' },
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' }, add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add option' },
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' }, remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this option' },
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' }, duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll length' },
type: { id: 'compose_form.poll.type', defaultMessage: 'Style' },
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' }, switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' }, switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
singleChoice: { id: 'compose_form.poll.single', defaultMessage: 'Pick one' },
multipleChoice: { id: 'compose_form.poll.multiple', defaultMessage: 'Multiple choice' },
}); });
class OptionIntl extends PureComponent { const Select = ({ label, options, value, onChange }) => {
return (
<label className='compose-form__poll__select'>
<span className='compose-form__poll__select__label'>{label}</span>
static propTypes = { <select className='compose-form__poll__select__value' value={value} onChange={onChange}>
title: PropTypes.string.isRequired, {options.map((option, i) => (
lang: PropTypes.string, <option key={i} value={option.value}>{option.label}</option>
index: PropTypes.number.isRequired, ))}
isPollMultiple: PropTypes.bool, </select>
autoFocus: PropTypes.bool, </label>
onChange: PropTypes.func.isRequired, );
onRemove: PropTypes.func.isRequired, };
onToggleMultiple: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleOptionTitleChange = e => { Select.propTypes = {
this.props.onChange(this.props.index, e.target.value); label: PropTypes.node,
}; value: PropTypes.any,
onChange: PropTypes.func,
options: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.node,
value: PropTypes.any,
})),
};
handleOptionRemove = () => { const Option = ({ multipleChoice, index, title, autoFocus }) => {
this.props.onRemove(this.props.index); const intl = useIntl();
}; const dispatch = useDispatch();
const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
const lang = useSelector(state => state.getIn(['compose', 'language']));
const handleChange = useCallback(({ target: { value } }) => {
dispatch(changePollOption(index, value));
}, [dispatch, index]);
handleToggleMultiple = e => { const handleSuggestionsFetchRequested = useCallback(token => {
this.props.onToggleMultiple(); dispatch(fetchComposeSuggestions(token));
e.preventDefault(); }, [dispatch]);
e.stopPropagation();
};
handleCheckboxKeypress = e => { const handleSuggestionsClearRequested = useCallback(() => {
if (e.key === 'Enter' || e.key === ' ') { dispatch(clearComposeSuggestions());
this.handleToggleMultiple(e); }, [dispatch]);
}
};
onSuggestionsClearRequested = () => { const handleSuggestionSelected = useCallback((tokenStart, token, value) => {
this.props.onClearSuggestions(); dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index]));
}; }, [dispatch, index]);
onSuggestionsFetchRequested = (token) => { return (
this.props.onFetchSuggestions(token); <label className={classNames('poll__option editable', { empty: index > 1 && title.length === 0 })}>
}; <span className={classNames('poll__input', { checkbox: multipleChoice })} />
onSuggestionSelected = (tokenStart, token, value) => { <AutosuggestInput
this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]); placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
}; maxLength={100}
value={title}
lang={lang}
spellCheck
onChange={handleChange}
suggestions={suggestions}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
onSuggestionSelected={handleSuggestionSelected}
searchTokens={[':']}
autoFocus={autoFocus}
/>
</label>
);
};
render () { Option.propTypes = {
const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props; title: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
multipleChoice: PropTypes.bool,
autoFocus: PropTypes.bool,
};
return ( export const PollForm = () => {
<li> const intl = useIntl();
<label className='poll__option editable'> const dispatch = useDispatch();
<span const poll = useSelector(state => state.getIn(['compose', 'poll']));
className={classNames('poll__input', { checkbox: isPollMultiple })} const options = poll?.get('options');
onClick={this.handleToggleMultiple} const expiresIn = poll?.get('expires_in');
onKeyPress={this.handleCheckboxKeypress} const isMultiple = poll?.get('multiple');
role='button'
tabIndex={0}
title={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
aria-label={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
/>
<AutosuggestInput const handleDurationChange = useCallback(({ target: { value } }) => {
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} dispatch(changePollSettings(value, isMultiple));
maxLength={100} }, [dispatch, isMultiple]);
value={title}
lang={lang}
spellCheck
onChange={this.handleOptionTitleChange}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
searchTokens={[':']}
autoFocus={autoFocus}
/>
</label>
<div className='poll__cancel'> const handleTypeChange = useCallback(({ target: { value } }) => {
<IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' iconComponent={CloseIcon} onClick={this.handleOptionRemove} /> dispatch(changePollSettings(expiresIn, value === 'true'));
</div> }, [dispatch, expiresIn]);
</li>
); if (poll === null) {
return null;
} }
} return (
<div className='compose-form__poll'>
{options.map((title, i) => (
<Option
title={title}
key={i}
index={i}
multipleChoice={isMultiple}
autoFocus={i === 0}
/>
))}
const Option = injectIntl(OptionIntl); <div className='compose-form__poll__footer'>
<Select label={intl.formatMessage(messages.duration)} options={[
{ value: 300, label: intl.formatMessage(messages.minutes, { number: 5 })},
{ value: 1800, label: intl.formatMessage(messages.minutes, { number: 30 })},
{ value: 3600, label: intl.formatMessage(messages.hours, { number: 1 })},
{ value: 21600, label: intl.formatMessage(messages.hours, { number: 6 })},
{ value: 43200, label: intl.formatMessage(messages.hours, { number: 12 })},
{ value: 86400, label: intl.formatMessage(messages.days, { number: 1 })},
{ value: 259200, label: intl.formatMessage(messages.days, { number: 3 })},
{ value: 604800, label: intl.formatMessage(messages.days, { number: 7 })},
]} value={expiresIn} onChange={handleDurationChange} />
class PollForm extends ImmutablePureComponent { <div className='compose-form__poll__footer__sep' />
static propTypes = { <Select label={intl.formatMessage(messages.type)} options={[
options: ImmutablePropTypes.list, { value: false, label: intl.formatMessage(messages.singleChoice) },
lang: PropTypes.string, { value: true, label: intl.formatMessage(messages.multipleChoice) },
expiresIn: PropTypes.number, ]} value={isMultiple} onChange={handleTypeChange} />
isMultiple: PropTypes.bool,
onChangeOption: PropTypes.func.isRequired,
onAddOption: PropTypes.func.isRequired,
onRemoveOption: PropTypes.func.isRequired,
onChangeSettings: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleAddOption = () => {
this.props.onAddOption('');
};
handleSelectDuration = e => {
this.props.onChangeSettings(e.target.value, this.props.isMultiple);
};
handleToggleMultiple = () => {
this.props.onChangeSettings(this.props.expiresIn, !this.props.isMultiple);
};
render () {
const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
if (!options) {
return null;
}
const autoFocusIndex = options.indexOf('');
return (
<div className='compose-form__poll-wrapper'>
<ul>
{options.map((title, i) => <Option title={title} lang={lang} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} onToggleMultiple={this.handleToggleMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
</ul>
<div className='poll__footer'>
<button type='button' disabled={options.size >= 5} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' icon={AddIcon} /> <FormattedMessage {...messages.add_option} /></button>
{/* eslint-disable-next-line jsx-a11y/no-onchange */}
<select value={expiresIn} onChange={this.handleSelectDuration}>
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
<option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
</select>
</div>
</div> </div>
); </div>
} );
};
}
export default injectIntl(PollForm);

View File

@ -5,28 +5,27 @@ import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
import PublicIcon from '@/material-icons/400-24px/public.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({ const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' }, public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' }, unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' }, private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' }, direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, change_privacy: { id: 'privacy.change', defaultMessage: 'Change post privacy' },
unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
}); });
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
@ -135,6 +134,12 @@ class PrivacyDropdownMenu extends PureComponent {
<strong>{item.text}</strong> <strong>{item.text}</strong>
{item.meta} {item.meta}
</div> </div>
{item.extra && (
<div className='privacy-dropdown__option__additional' title={item.extra}>
<Icon id='info-circle' icon={InfoIcon} />
</div>
)}
</div> </div>
))} ))}
</div> </div>
@ -163,30 +168,11 @@ class PrivacyDropdown extends PureComponent {
}; };
handleToggle = () => { handleToggle = () => {
if (this.props.isUserTouching && this.props.isUserTouching()) { if (this.state.open && this.activeElement) {
if (this.state.open) { this.activeElement.focus({ preventScroll: true });
this.props.onModalClose();
} else {
this.props.onModalOpen({
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
onClick: this.handleModalActionClick,
});
}
} else {
if (this.state.open && this.activeElement) {
this.activeElement.focus({ preventScroll: true });
}
this.setState({ open: !this.state.open });
} }
};
handleModalActionClick = (e) => { this.setState({ open: !this.state.open });
e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose();
this.props.onChange(value);
}; };
handleKeyDown = e => { handleKeyDown = e => {
@ -228,7 +214,7 @@ class PrivacyDropdown extends PureComponent {
this.options = [ this.options = [
{ icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, { icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'unlock', iconComponent: LockOpenIcon, value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, { icon: 'unlock', iconComponent: QuietTimeIcon, value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long), extra: formatMessage(messages.unlisted_extra) },
{ icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, { icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
]; ];
@ -259,23 +245,21 @@ class PrivacyDropdown extends PureComponent {
return ( return (
<div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}> <div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
<IconButton <button
className='privacy-dropdown__value-icon' type='button'
icon={valueOption.icon}
iconComponent={valueOption.iconComponent}
title={intl.formatMessage(messages.change_privacy)} title={intl.formatMessage(messages.change_privacy)}
size={18} aria-expanded={open}
expanded={open}
active={open}
inverted
onClick={this.handleToggle} onClick={this.handleToggle}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown} onKeyDown={this.handleButtonKeyDown}
style={{ height: null, lineHeight: '27px' }}
disabled={disabled} disabled={disabled}
/> className={classNames('dropdown-button', { active: open })}
>
<Icon id={valueOption.icon} icon={valueOption.iconComponent} />
<span className='dropdown-button__label'>{valueOption.text}</span>
</button>
<Overlay show={open} placement={placement} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}> <Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement }) => ( {({ props, placement }) => (
<div {...props}> <div {...props}>
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}> <div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>

View File

@ -1,74 +1,48 @@
import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl'; import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import { useSelector } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import AttachmentList from 'mastodon/components/attachment_list'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router'; import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import { Avatar } from '../../../components/avatar'; export const ReplyIndicator = () => {
import { DisplayName } from '../../../components/display_name'; const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to']));
import { IconButton } from '../../../components/icon_button'; const status = useSelector(state => state.getIn(['statuses', inReplyToId]));
const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
const messages = defineMessages({ if (!status) {
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, return null;
}); }
class ReplyIndicator extends ImmutablePureComponent { const content = { __html: status.get('contentHtml') };
static propTypes = { return (
status: ImmutablePropTypes.map, <div className='reply-indicator'>
onCancel: PropTypes.func.isRequired, <div className='reply-indicator__line' />
intl: PropTypes.object.isRequired,
...WithOptionalRouterPropTypes,
};
handleClick = () => { <Link to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'>
this.props.onCancel(); <Avatar account={account} size={46} />
}; </Link>
handleAccountClick = (e) => { <div className='reply-indicator__main'>
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { <Link to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
e.preventDefault(); <DisplayName account={account} />
this.props.history?.push(`/@${this.props.status.getIn(['account', 'acct'])}`); </Link>
}
};
render () {
const { status, intl } = this.props;
if (!status) {
return null;
}
const content = { __html: status.get('contentHtml') };
return (
<div className='reply-indicator'>
<div className='reply-indicator__header'>
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={this.handleClick} inverted /></div>
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
<DisplayName account={status.get('account')} />
</a>
</div>
<div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} /> <div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} />
{status.get('media_attachments').size > 0 && ( {(status.get('poll') || status.get('media_attachments').size > 0) && (
<AttachmentList <div className='reply-indicator__attachments'>
compact {status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
media={status.get('media_attachments')} {status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
/> </div>
)} )}
</div> </div>
); </div>
} );
};
}
export default withOptionalRouter(injectIntl(ReplyIndicator));

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