diff options
Diffstat (limited to 'app/javascript')
29 files changed, 957 insertions, 194 deletions
diff --git a/app/javascript/images/mastodon-getting-started.png b/app/javascript/images/mastodon-getting-started.png index e05dd493f..8fe0df76a 100644 --- a/app/javascript/images/mastodon-getting-started.png +++ b/app/javascript/images/mastodon-getting-started.png Binary files differdiff --git a/app/javascript/mastodon/actions/local_settings.js b/app/javascript/mastodon/actions/local_settings.js new file mode 100644 index 000000000..742a1eec2 --- /dev/null +++ b/app/javascript/mastodon/actions/local_settings.js @@ -0,0 +1,20 @@ +export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE'; + +export function changeLocalSetting(key, value) { + return dispatch => { + dispatch({ + type: LOCAL_SETTING_CHANGE, + key, + value, + }); + + dispatch(saveLocalSettings()); + }; +}; + +export function saveLocalSettings() { + return (_, getState) => { + const localSettings = getState().get('localSettings').toJS(); + localStorage.setItem('mastodon-settings', JSON.stringify(localSettings)); + }; +}; diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index ac734f5ad..748283853 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -17,6 +17,7 @@ export default class IconButton extends React.PureComponent { disabled: PropTypes.bool, inverted: PropTypes.bool, animate: PropTypes.bool, + flip: PropTypes.bool, overlay: PropTypes.bool, }; @@ -69,7 +70,7 @@ export default class IconButton extends React.PureComponent { } return ( - <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> + <Motion defaultStyle={{ rotate: this.props.active ? (this.props.flip ? -180 : -360) : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? (this.props.flip ? -180 : -360) : 0, { stiffness: this.props.flip ? 60 : 120, damping: 7 }) : 0 }}> {({ rotate }) => <button aria-label={this.props.title} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index a837659c2..9e9e1c3c7 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -3,19 +3,24 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Avatar from './avatar'; import AvatarOverlay from './avatar_overlay'; -import RelativeTimestamp from './relative_timestamp'; import DisplayName from './display_name'; import MediaGallery from './media_gallery'; import VideoPlayer from './video_player'; import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; -import { FormattedMessage } from 'react-intl'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import emojify from '../emoji'; import escapeTextContentForBrowser from 'escape-html'; import ImmutablePureComponent from 'react-immutable-pure-component'; import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; -export default class Status extends ImmutablePureComponent { +const messages = defineMessages({ + collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, + uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, +}); + +class StatusUnextended extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -36,13 +41,16 @@ export default class Status extends ImmutablePureComponent { boostModal: PropTypes.bool, autoPlayGif: PropTypes.bool, muted: PropTypes.bool, + collapse: PropTypes.bool, intersectionObserverWrapper: PropTypes.object, + intl: PropTypes.object.isRequired, }; state = { isExpanded: false, isIntersecting: true, // assume intersecting until told otherwise isHidden: false, // set to true in requestIdleCallback to trigger un-render + isCollapsed: false, } // Avoid checking props that are functions (and whose equality will always @@ -55,9 +63,17 @@ export default class Status extends ImmutablePureComponent { 'boostModal', 'autoPlayGif', 'muted', + 'collapse', + ] + + updateOnStates = [ + 'isExpanded', + 'isCollapsed', ] - updateOnStates = ['isExpanded'] + componentWillReceiveProps (nextProps) { + if (nextProps.collapse !== this.props.collapse && nextProps.collapse !== undefined) this.setState({ isCollapsed: !!nextProps.collapse }); + } shouldComponentUpdate (nextProps, nextState) { if (!nextState.isIntersecting && nextState.isHidden) { @@ -74,7 +90,16 @@ export default class Status extends ImmutablePureComponent { return super.shouldComponentUpdate(nextProps, nextState); } + componentDidUpdate (prevProps, prevState) { + if (prevState.isCollapsed !== this.state.isCollapsed) this.saveHeight(); + } + componentDidMount () { + const node = this.node; + + if (this.props.collapse !== undefined) this.setState({ isCollapsed: !!this.props.collapse }); + else if (node.clientHeight > 400 && !(this.props.status.get('reblog', null) !== null && typeof this.props.status.get('reblog') === 'object')) this.setState({ isCollapsed: true }); + if (!this.props.intersectionObserverWrapper) { // TODO: enable IntersectionObserver optimization for notification statuses. // These are managed in notifications/index.js rather than status_list.js @@ -135,29 +160,38 @@ export default class Status extends ImmutablePureComponent { handleClick = () => { const { status } = this.props; - this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); + const { isCollapsed } = this.state; + if (isCollapsed) this.handleCollapsedClick(); + else this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); } handleAccountClick = (e) => { if (e.button === 0) { const id = Number(e.currentTarget.getAttribute('data-id')); e.preventDefault(); - this.context.router.history.push(`/accounts/${id}`); + if (this.state.isCollapsed) this.handleCollapsedClick(); + else this.context.router.history.push(`/accounts/${id}`); } } handleExpandedToggle = () => { - this.setState({ isExpanded: !this.state.isExpanded }); + this.setState({ isExpanded: !this.state.isExpanded, isCollapsed: false }); }; + handleCollapsedClick = () => { + this.setState({ isCollapsed: !this.state.isCollapsed, isExpanded: false }); + } + render () { let media = null; + let mediaType = null; + let thumb = null; let statusAvatar; // Exclude intersectionObserverWrapper from `other` variable // because intersection is managed in here. - const { status, account, intersectionObserverWrapper, ...other } = this.props; - const { isExpanded, isIntersecting, isHidden } = this.state; + const { status, account, intersectionObserverWrapper, intl, ...other } = this.props; + const { isExpanded, isIntersecting, isHidden, isCollapsed } = this.state; if (status === null) { return null; @@ -198,8 +232,12 @@ export default class Status extends ImmutablePureComponent { } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />; + mediaType = <i className='fa fa-fw fa-video-camera' aria-hidden='true' />; + if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0)) thumb = status.getIn(['media_attachments', 0]).get('preview_url'); } else { media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />; + mediaType = status.get('media_attachments').size > 1 ? <i className='fa fa-fw fa-th-large' aria-hidden='true' /> : <i className='fa fa-fw fa-picture-o' aria-hidden='true' />; + if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0)) thumb = status.getIn(['media_attachments', 0]).get('preview_url'); } } @@ -210,9 +248,20 @@ export default class Status extends ImmutablePureComponent { } return ( - <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}> + <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')} ${isCollapsed ? 'status-collapsed' : ''}`} data-id={status.get('id')} ref={this.handleRef} style={{ backgroundImage: thumb && isCollapsed ? 'url(' + thumb + ')' : 'none' }}> <div className='status__info'> - <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + + <div className='status__info__icons'> + {mediaType} + <IconButton + className='status__collapse-button' + animate flip + active={isCollapsed} + title={isCollapsed ? intl.formatMessage(messages.uncollapse) : intl.formatMessage(messages.collapse)} + icon='angle-double-up' + onClick={this.handleCollapsedClick} + /> + </div> <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'> <div className='status__avatar'> @@ -221,15 +270,21 @@ export default class Status extends ImmutablePureComponent { <DisplayName account={status.get('account')} /> </a> + </div> - <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} onHeightUpdate={this.saveHeight} /> + <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} collapsed={isCollapsed} onExpandedToggle={this.handleExpandedToggle} onHeightUpdate={this.saveHeight}> + + {isCollapsed ? null : media} - {media} + </StatusContent> - <StatusActionBar {...this.props} /> + {isCollapsed ? null : <StatusActionBar status={status} account={account} {...other} />} </div> ); } } + +const Status = injectIntl(StatusUnextended); +export default Status; diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index fd7c99054..6693548c7 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -5,6 +5,7 @@ import IconButton from './icon_button'; import DropdownMenu from './dropdown_menu'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import RelativeTimestamp from './relative_timestamp'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -144,6 +145,8 @@ export default class StatusActionBar extends ImmutablePureComponent { <div className='status__action-bar-dropdown'> <DropdownMenu items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' /> </div> + + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> </div> ); } diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 19bde01bd..bcbff5515 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -16,9 +16,11 @@ export default class StatusContent extends React.PureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, expanded: PropTypes.bool, + collapsed: PropTypes.bool, onExpandedToggle: PropTypes.func, onHeightUpdate: PropTypes.func, onClick: PropTypes.func, + children: PropTypes.element, }; state = { @@ -39,6 +41,7 @@ export default class StatusContent extends React.PureComponent { } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); } else { + link.addEventListener('click', this.onLinkClick.bind(this), false); link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener'); link.setAttribute('title', link.href); @@ -52,10 +55,18 @@ export default class StatusContent extends React.PureComponent { } } + onLinkClick = (e) => { + if (e.button === 0 && this.props.collapsed) { + e.preventDefault(); + if (this.props.onClick) this.props.onClick(); + } + } + onMentionClick = (mention, e) => { if (e.button === 0) { e.preventDefault(); - this.context.router.history.push(`/accounts/${mention.get('id')}`); + if (!this.props.collapsed) this.context.router.history.push(`/accounts/${mention.get('id')}`); + else if (this.props.onClick) this.props.onClick(); } } @@ -64,7 +75,8 @@ export default class StatusContent extends React.PureComponent { if (e.button === 0) { e.preventDefault(); - this.context.router.history.push(`/timelines/tag/${hashtag}`); + if (!this.props.collapsed) this.context.router.history.push(`/timelines/tag/${hashtag}`); + else if (this.props.onClick) this.props.onClick(); } } @@ -107,7 +119,7 @@ export default class StatusContent extends React.PureComponent { } render () { - const { status } = this.props; + const { status, children } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; @@ -144,7 +156,14 @@ export default class StatusContent extends React.PureComponent { {mentionsPlaceholder} - <div className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> + <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> + + <div style={directionStyle} dangerouslySetInnerHTML={content} /> + + {children} + + </div> + </div> ); } else if (this.props.onClick) { @@ -155,8 +174,10 @@ export default class StatusContent extends React.PureComponent { style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} - dangerouslySetInnerHTML={content} - /> + > + <div dangerouslySetInnerHTML={content} /> + {children} + </div> ); } else { return ( @@ -164,8 +185,12 @@ export default class StatusContent extends React.PureComponent { ref={this.setRef} className='status__content' style={directionStyle} - dangerouslySetInnerHTML={content} - /> + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + > + <div dangerouslySetInnerHTML={content} /> + {children} + </div> ); } } diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 3bd89902f..3468a7944 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -24,6 +24,11 @@ addLocaleData(localeData); const store = configureStore(); const initialState = JSON.parse(document.getElementById('initial-state').textContent); +try { + initialState.localSettings = JSON.parse(localStorage.getItem('mastodon-settings')); +} catch (e) { + initialState.localSettings = {}; +} store.dispatch(hydrateStore(initialState)); export default class Mastodon extends React.PureComponent { diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 3239b1085..89274d3d4 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -5,10 +5,9 @@ import emojify from '../../../emoji'; import escapeTextContentForBrowser from 'escape-html'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import IconButton from '../../../components/icon_button'; -import Motion from 'react-motion/lib/Motion'; -import spring from 'react-motion/lib/spring'; -import { connect } from 'react-redux'; +import Avatar from '../../../components/avatar'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { processBio } from '../util/bio_metadata'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, @@ -16,61 +15,6 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, }); -const makeMapStateToProps = () => { - const mapStateToProps = state => ({ - autoPlayGif: state.getIn(['meta', 'auto_play_gif']), - }); - - return mapStateToProps; -}; - -class Avatar extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.map.isRequired, - autoPlayGif: PropTypes.bool.isRequired, - }; - - state = { - isHovered: false, - }; - - handleMouseOver = () => { - if (this.state.isHovered) return; - this.setState({ isHovered: true }); - } - - handleMouseOut = () => { - if (!this.state.isHovered) return; - this.setState({ isHovered: false }); - } - - render () { - const { account, autoPlayGif } = this.props; - const { isHovered } = this.state; - - return ( - <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}> - {({ radius }) => - <a // eslint-disable-line jsx-a11y/anchor-has-content - href={account.get('url')} - className='account__header__avatar' - target='_blank' - rel='noopener' - style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }} - onMouseOver={this.handleMouseOver} - onMouseOut={this.handleMouseOut} - onFocus={this.handleMouseOver} - onBlur={this.handleMouseOut} - /> - } - </Motion> - ); - } - -} - -@connect(makeMapStateToProps) @injectIntl export default class Header extends ImmutablePureComponent { @@ -79,7 +23,6 @@ export default class Header extends ImmutablePureComponent { me: PropTypes.number.isRequired, onFollow: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, - autoPlayGif: PropTypes.bool.isRequired, }; render () { @@ -122,21 +65,41 @@ export default class Header extends ImmutablePureComponent { lockedIcon = <i className='fa fa-lock' />; } - const content = { __html: emojify(account.get('note')) }; - const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const { text, metadata } = processBio(account.get('note')); return ( - <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> - <div> - <Avatar account={account} autoPlayGif={this.props.autoPlayGif} /> - - <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> - <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span> - <div className='account__header__content' dangerouslySetInnerHTML={content} /> - - {info} - {actionBtn} + <div className='account__header__wrapper'> + <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> + <div> + <a href={account.get('url')} target='_blank' rel='noopener'> + <span className='account__header__avatar'><Avatar src={account.get('avatar')} animate size={90} /></span> + <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> + </a> + <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span> + <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} /> + + {info} + {actionBtn} + </div> </div> + + {metadata.length && ( + <table className='account__metadata'> + {(() => { + let data = []; + for (let i = 0; i < metadata.length; i++) { + data.push( + <tr key={i}> + <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th> + <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td> + </tr> + ); + } + return data; + })()} + </table> + ) || null} </div> ); } diff --git a/app/javascript/mastodon/features/account/util/bio_metadata.js b/app/javascript/mastodon/features/account/util/bio_metadata.js new file mode 100644 index 000000000..fc24549b6 --- /dev/null +++ b/app/javascript/mastodon/features/account/util/bio_metadata.js @@ -0,0 +1,295 @@ +/*********************************************************************\ + + To my lovely code maintainers, + + The syntax recognized by the Mastodon frontend for its bio metadata + feature is a subset of that provided by the YAML 1.2 specification. + In particular, Mastodon recognizes metadata which is provided as an + implicit YAML map, where each key-value pair takes up only a single + line (no multi-line values are permitted). To simplify the level of + processing required, Mastodon metadata frontmatter has been limited + to only allow those characters in the `c-printable` set, as defined + by the YAML 1.2 specification, instead of permitting those from the + `nb-json` characters inside double-quoted strings like YAML proper. + ¶ It is important to note that Mastodon only borrows the *syntax* + of YAML, not its semantics. This is to say, Mastodon won't make any + attempt to interpret the data it receives. `true` will not become a + boolean; `56` will not be interpreted as a number. Rather, each key + and every value will be read as a string, and as a string they will + remain. The order of the pairs is unchanged, and any duplicate keys + are preserved. However, YAML escape sequences will be replaced with + the proper interpretations according to the YAML 1.2 specification. + ¶ The implementation provided below interprets `<br>` as `\n` and + allows for an open <p> tag at the beginning of the bio. It replaces + the escaped character entities `'` and `"` with single or + double quotes, respectively, prior to processing. However, no other + escaped characters are replaced, not even those which might have an + impact on the syntax otherwise. These minor allowances are provided + because the Mastodon backend will insert these things automatically + into a bio before sending it through the API, so it is important we + account for them. Aside from this, the YAML frontmatter must be the + very first thing in the bio, leading with three consecutive hyphen- + minues (`---`), and ending with the same or, alternatively, instead + with three periods (`...`). No limits have been set with respect to + the number of characters permitted in the frontmatter, although one + should note that only limited space is provided for them in the UI. + ¶ The regular expression used to check the existence of, and then + process, the YAML frontmatter has been split into a number of small + components in the code below, in the vain hope that it will be much + easier to read and to maintain. I leave it to the future readers of + this code to determine the extent of my successes in this endeavor. + + Sending love + warmth eternal, + - kibigo [@kibi@glitch.social] + +\*********************************************************************/ + +/* CONVENIENCE FUNCTIONS */ + +const unirex = str => new RegExp(str, 'u'); +const rexstr = exp => '(?:' + exp.source + ')'; + +/* CHARACTER CLASSES */ + +const DOCUMENT_START = /^/; +const DOCUMENT_END = /$/; +const ALLOWED_CHAR = // `c-printable` in the YAML 1.2 spec. + /[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]/u; +const WHITE_SPACE = /[ \t]/; +const INDENTATION = / */; // Indentation must be only spaces. +const LINE_BREAK = /\r?\n|\r|<br\s*\/?>/; +const ESCAPE_CHAR = /[0abt\tnvfre "\/\\N_LP]/; +const HEXADECIMAL_CHARS = /[0-9a-fA-F]/; +const INDICATOR = /[-?:,[\]{}&#*!|>'"%@`]/; +const FLOW_CHAR = /[,[\]{}]/; + +/* NEGATED CHARACTER CLASSES */ + +const NOT_WHITE_SPACE = unirex('(?!' + rexstr(WHITE_SPACE) + ')[^]'); +const NOT_LINE_BREAK = unirex('(?!' + rexstr(LINE_BREAK) + ')[^]'); +const NOT_INDICATOR = unirex('(?!' + rexstr(INDICATOR) + ')[^]'); +const NOT_FLOW_CHAR = unirex('(?!' + rexstr(FLOW_CHAR) + ')[^]'); + +/* BASIC CONSTRUCTS */ + +const ANY_WHITE_SPACE = unirex(rexstr(WHITE_SPACE) + '*'); +const ANY_ALLOWED_CHARS = unirex(rexstr(ALLOWED_CHAR) + '*'); +const NEW_LINE = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) +); +const SOME_NEW_LINES = unirex( + '(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+' +); +const POSSIBLE_STARTS = unirex( + rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?' +); +const POSSIBLE_ENDS = unirex( + rexstr(SOME_NEW_LINES) + '|' + + rexstr(DOCUMENT_END) + '|' + + rexstr(/<\/p>/) +); +const CHARACTER_ESCAPE = unirex( + rexstr(/\\/) + + '(?:' + + rexstr(ESCAPE_CHAR) + '|' + + rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' + + rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' + + rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' + + ')' +); +const ESCAPED_CHAR = unirex( + rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' + + rexstr(CHARACTER_ESCAPE) +); +const ANY_ESCAPED_CHARS = unirex( + rexstr(ESCAPED_CHAR) + '*' +); +const ESCAPED_APOS = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/) +); +const ANY_ESCAPED_APOS = unirex( + rexstr(ESCAPED_APOS) + '*' +); +const FIRST_KEY_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' +); +const FIRST_VALUE_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + // Flow indicators are allowed in values. +); +const LATER_KEY_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' + + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +); +const LATER_VALUE_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + // Flow indicators are allowed in values. + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +); + +/* YAML CONSTRUCTS */ + +const YAML_START = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(/---/) +); +const YAML_END = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/) +); +const YAML_LOOKAHEAD = unirex( + '(?=' + + rexstr(YAML_START) + + rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) + + rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) + + ')' +); +const YAML_DOUBLE_QUOTE = unirex( + rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/) +); +const YAML_SINGLE_QUOTE = unirex( + rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/) +); +const YAML_SIMPLE_KEY = unirex( + rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*' +); +const YAML_SIMPLE_VALUE = unirex( + rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*' +); +const YAML_KEY = unirex( + rexstr(YAML_DOUBLE_QUOTE) + '|' + + rexstr(YAML_SINGLE_QUOTE) + '|' + + rexstr(YAML_SIMPLE_KEY) +); +const YAML_VALUE = unirex( + rexstr(YAML_DOUBLE_QUOTE) + '|' + + rexstr(YAML_SINGLE_QUOTE) + '|' + + rexstr(YAML_SIMPLE_VALUE) +); +const YAML_SEPARATOR = unirex( + rexstr(ANY_WHITE_SPACE) + + ':' + rexstr(WHITE_SPACE) + + rexstr(ANY_WHITE_SPACE) +); +const YAML_LINE = unirex( + '(' + rexstr(YAML_KEY) + ')' + + rexstr(YAML_SEPARATOR) + + '(' + rexstr(YAML_VALUE) + ')' +); + +/* FRONTMATTER REGEX */ + +const YAML_FRONTMATTER = unirex( + rexstr(POSSIBLE_STARTS) + + rexstr(YAML_LOOKAHEAD) + + rexstr(YAML_START) + rexstr(SOME_NEW_LINES) + + '(?:' + + '(' + rexstr(INDENTATION) + ')' + + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) + + '(?:' + + '\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) + + '){0,4}' + + ')?' + + rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) +); + +/* SEARCHES */ + +const FIND_YAML_LINES = unirex( + rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE) +); + +/* STRING PROCESSING */ + +function processString(str) { + switch (str.charAt(0)) { + case '"': + return str + .substring(1, str.length - 1) + .replace(/\\0/g, '\x00') + .replace(/\\a/g, '\x07') + .replace(/\\b/g, '\x08') + .replace(/\\t/g, '\x09') + .replace(/\\n/g, '\x0a') + .replace(/\\v/g, '\x0b') + .replace(/\\f/g, '\x0c') + .replace(/\\r/g, '\x0d') + .replace(/\\e/g, '\x1b') + .replace(/\\ /g, '\x20') + .replace(/\\"/g, '\x22') + .replace(/\\\//g, '\x2f') + .replace(/\\\\/g, '\x5c') + .replace(/\\N/g, '\x85') + .replace(/\\_/g, '\xa0') + .replace(/\\L/g, '\u2028') + .replace(/\\P/g, '\u2029') + .replace( + new RegExp( + unirex( + rexstr(/\\x/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{2})' + ), 'gu' + ), (_, n) => String.fromCodePoint('0x' + n) + ) + .replace( + new RegExp( + unirex( + rexstr(/\\u/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{4})' + ), 'gu' + ), (_, n) => String.fromCodePoint('0x' + n) + ) + .replace( + new RegExp( + unirex( + rexstr(/\\U/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{8})' + ), 'gu' + ), (_, n) => String.fromCodePoint('0x' + n) + ); + case '\'': + return str + .substring(1, str.length - 1) + .replace(/''/g, '\''); + default: + return str; + } +} + +/* BIO PROCESSING */ + +export function processBio(content) { + content = content.replace(/"/g, '"').replace(/'/g, '\''); + let result = { + text: content, + metadata: [], + }; + let yaml = content.match(YAML_FRONTMATTER); + if (!yaml) return result; + else yaml = yaml[0]; + let start = content.search(YAML_START); + let end = start + yaml.length - yaml.search(YAML_START); + result.text = content.substr(0, start) + content.substr(end); + let metadata = null; + let query = new RegExp(FIND_YAML_LINES, 'g'); + while ((metadata = query.exec(yaml))) { + result.metadata.push([ + processString(metadata[1]), + processString(metadata[2]), + ]); + } + return result; +} diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index 747fe4216..512167193 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -4,8 +4,9 @@ import NavigationContainer from './containers/navigation_container'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; +import { changeLocalSetting } from '../../actions/local_settings'; import Link from 'react-router-dom/Link'; -import { injectIntl, defineMessages } from 'react-intl'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; import SearchContainer from './containers/search_container'; import Motion from 'react-motion/lib/Motion'; import spring from 'react-motion/lib/spring'; @@ -21,6 +22,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + layout: state.getIn(['localSettings', 'layout']), }); @connect(mapStateToProps) @@ -32,6 +34,7 @@ export default class Compose extends React.PureComponent { multiColumn: PropTypes.bool, showSearch: PropTypes.bool, intl: PropTypes.object.isRequired, + layout: PropTypes.string, }; componentDidMount () { @@ -42,8 +45,14 @@ export default class Compose extends React.PureComponent { this.props.dispatch(unmountCompose()); } + onLayoutClick = (e) => { + const layout = e.currentTarget.getAttribute('data-mastodon-layout'); + this.props.dispatch(changeLocalSetting(['layout'], layout)); + e.preventDefault(); + } + render () { - const { multiColumn, showSearch, intl } = this.props; + const { multiColumn, showSearch, intl, layout } = this.props; let header = ''; @@ -59,6 +68,47 @@ export default class Compose extends React.PureComponent { ); } + let layoutContent = ''; + + switch (layout) { + case 'single': + layoutContent = ( + <div className='layout__selector'> + <p> + <FormattedMessage id='layout.current_is' defaultMessage='Your current layout is:' /> <b><FormattedMessage id='layout.mobile' defaultMessage='Mobile' /></b> + </p> + <p> + <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='auto'><FormattedMessage id='layout.auto' defaultMessage='Auto' /></a> • <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='multiple'><FormattedMessage id='layout.desktop' defaultMessage='Desktop' /></a> + </p> + </div> + ); + break; + case 'multiple': + layoutContent = ( + <div className='layout__selector'> + <p> + <FormattedMessage id='layout.current_is' defaultMessage='Your current layout is:' /> <b><FormattedMessage id='layout.desktop' defaultMessage='Desktop' /></b> + </p> + <p> + <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='auto'><FormattedMessage id='layout.auto' defaultMessage='Auto' /></a> • <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='single'><FormattedMessage id='layout.mobile' defaultMessage='Mobile' /></a> + </p> + </div> + ); + break; + default: + layoutContent = ( + <div className='layout__selector'> + <p> + <FormattedMessage id='layout.current_is' defaultMessage='Your current layout is:' /> <b><FormattedMessage id='layout.auto' defaultMessage='Auto' /></b> + </p> + <p> + <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='multiple'><FormattedMessage id='layout.desktop' defaultMessage='Desktop' /></a> • <a onClick={this.onLayoutClick} role='button' tabIndex='0' data-mastodon-layout='single'><FormattedMessage id='layout.mobile' defaultMessage='Mobile' /></a> + </p> + </div> + ); + break; + } + return ( <div className='drawer'> {header} @@ -79,6 +129,9 @@ export default class Compose extends React.PureComponent { } </Motion> </div> + + {layoutContent} + </div> ); } diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index f8ea01024..ac93b3d47 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -96,8 +96,8 @@ export default class GettingStarted extends ImmutablePureComponent { <p> <FormattedMessage id='getting_started.open_source_notice' - defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.' - values={{ github: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>tootsuite/mastodon</a> }} + defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.' + values={{ github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }} /> </p> </div> diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 9d631644a..bf580794d 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -44,7 +44,7 @@ export default class Notification extends ImmutablePureComponent { <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted collapse withDismiss /> </div> ); } @@ -59,7 +59,7 @@ export default class Notification extends ImmutablePureComponent { <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> </div> - <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss /> + <StatusContainer id={notification.get('status')} account={notification.get('account')} muted collapse withDismiss /> </div> ); } diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js index b056357a2..dab5e47ea 100644 --- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -30,8 +30,8 @@ const PageOne = ({ acct, domain }) => ( </div> <div> - <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1> - <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p> + <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1> + <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p> <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p> </div> </div> @@ -150,8 +150,8 @@ const PageSix = ({ admin, domain }) => { <div className='onboarding-modal__page onboarding-modal__page-six'> <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1> {adminSection} - <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p> - <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p> + <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ domain, fork: <a href='https://en.wikipedia.org/wiki/Fork_(software_development)' target='_blank' rel='noopener'>fork</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>Mastodon</a>, github: <a href='https://github.com/glitch-soc/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p> + <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ domain, apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p> <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p> </div> ); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 08d087da1..4d38c2677 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -72,12 +72,17 @@ class WrappedRoute extends React.Component { } -@connect() +const mapStateToProps = state => ({ + layout: state.getIn(['localSettings', 'layout']), +}); + +@connect(mapStateToProps) export default class UI extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, children: PropTypes.node, + layout: PropTypes.string, }; state = { @@ -174,12 +179,23 @@ export default class UI extends React.PureComponent { render () { const { width, draggingOver } = this.state; - const { children } = this.props; + const { children, layout } = this.props; + + const columnsClass = layout => { + switch (layout) { + case 'single': + return 'single-column'; + case 'multiple': + return 'multi-columns'; + default: + return 'auto-columns'; + } + }; return ( - <div className='ui' ref={this.setRef}> + <div className={'ui ' + columnsClass(layout)} ref={this.setRef}> <TabsBar /> - <ColumnsAreaContainer singleColumn={isMobile(width)}> + <ColumnsAreaContainer singleColumn={isMobile(width, layout)}> <WrappedSwitch> <Redirect from='/' to='/getting-started' exact /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js index 992e63727..014a9a8d5 100644 --- a/app/javascript/mastodon/is_mobile.js +++ b/app/javascript/mastodon/is_mobile.js @@ -1,7 +1,14 @@ const LAYOUT_BREAKPOINT = 1024; -export function isMobile(width) { - return width <= LAYOUT_BREAKPOINT; +export function isMobile(width, columns) { + switch (columns) { + case 'multiple': + return false; + case 'single': + return true; + default: + return width <= LAYOUT_BREAKPOINT; + } }; const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index ccf2e6303..5ab914477 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -191,6 +191,14 @@ { "defaultMessage": "{name} boosted", "id": "status.reblogged_by" + }, + { + "defaultMessage": "Collapse", + "id": "status.collapse" + }, + { + "defaultMessage": "Uncollapse", + "id": "status.uncollapse" } ], "path": "app/javascript/mastodon/components/status.json" @@ -650,6 +658,22 @@ { "defaultMessage": "Logout", "id": "navigation_bar.logout" + }, + { + "defaultMessage": "Your current layout is:", + "id": "layout.current_is" + }, + { + "defaultMessage": "Mobile", + "id": "layout.mobile" + }, + { + "defaultMessage": "Desktop", + "id": "layout.desktop" + }, + { + "defaultMessage": "Auto", + "id": "layout.auto" } ], "path": "app/javascript/mastodon/features/compose/index.json" @@ -756,7 +780,7 @@ "id": "getting_started.appsshort" }, { - "defaultMessage": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.", + "defaultMessage": "Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.", "id": "getting_started.open_source_notice" } ], @@ -1045,11 +1069,11 @@ "id": "column.public" }, { - "defaultMessage": "Welcome to Mastodon!", + "defaultMessage": "Welcome to {domain}!", "id": "onboarding.page_one.welcome" }, { - "defaultMessage": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "defaultMessage": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", "id": "onboarding.page_one.federation" }, { @@ -1097,7 +1121,7 @@ "id": "onboarding.page_six.almost_done" }, { - "defaultMessage": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "defaultMessage": "{domain} runs on Glitchsoc, a friendly fork of {Mastodon}. Glitchsoc is fully compatible with any Mastodon instance or app. You can report bugs, request features, or contribute to the code on {github}.", "id": "onboarding.page_six.github" }, { @@ -1187,4 +1211,4 @@ ], "path": "app/javascript/mastodon/features/ui/components/video_modal.json" } -] \ No newline at end of file +] diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 253db7110..d0c0ca137 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -71,7 +71,7 @@ "getting_started.appsshort": "Apps", "getting_started.faq": "FAQ", "getting_started.heading": "Getting started", - "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.", + "getting_started.open_source_notice": "Glitchsoc is free open source software forked from {Mastodon}. You can contribute or report issues on GitHub at {github}.", "getting_started.userguide": "User Guide", "home.column_settings.advanced": "Advanced", "home.column_settings.basic": "Basic", @@ -79,6 +79,10 @@ "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", "home.settings": "Column settings", + "layout.auto": "Auto", + "layout.current_is": "Your current layout is:", + "layout.desktop": "Desktop", + "layout.mobile": "Mobile", "lightbox.close": "Close", "loading_indicator.label": "Loading...", "media_gallery.toggle_visible": "Toggle visibility", @@ -111,14 +115,14 @@ "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", "onboarding.page_four.home": "The home timeline shows posts from people you follow.", "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", - "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.federation": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", - "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_one.welcome": "Welcome to {domain}!", "onboarding.page_six.admin": "Your instance's admin is {admin}.", "onboarding.page_six.almost_done": "Almost done...", "onboarding.page_six.appetoot": "Bon Appetoot!", "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", - "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.", "onboarding.page_six.guidelines": "community guidelines", "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", "onboarding.page_six.various_app": "mobile apps", @@ -143,6 +147,7 @@ "search.placeholder": "Search", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "status.cannot_reblog": "This post cannot be boosted", + "status.collapse": "Collapse", "status.delete": "Delete", "status.favourite": "Favourite", "status.load_more": "Load more", @@ -159,6 +164,7 @@ "status.sensitive_warning": "Sensitive content", "status.show_less": "Show less", "status.show_more": "Show more", + "status.uncollapse": "Uncollapse", "status.unmute_conversation": "Unmute conversation", "tabs_bar.compose": "Compose", "tabs_bar.federated_timeline": "Federated", diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index be402a16b..24f7f94a6 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -14,6 +14,7 @@ import relationships from './relationships'; import search from './search'; import notifications from './notifications'; import settings from './settings'; +import localSettings from './local_settings'; import status_lists from './status_lists'; import cards from './cards'; import reports from './reports'; @@ -36,6 +37,7 @@ export default combineReducers({ search, notifications, settings, + localSettings, cards, reports, contexts, diff --git a/app/javascript/mastodon/reducers/local_settings.js b/app/javascript/mastodon/reducers/local_settings.js new file mode 100644 index 000000000..529d31ebb --- /dev/null +++ b/app/javascript/mastodon/reducers/local_settings.js @@ -0,0 +1,20 @@ +import { LOCAL_SETTING_CHANGE } from '../actions/local_settings'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + layout: 'auto', +}); + +const hydrate = (state, localSettings) => state.mergeDeep(localSettings); + +export default function localSettings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('localSettings')); + case LOCAL_SETTING_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index ddad7a4fc..9a15a1fe3 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -6,6 +6,7 @@ import uuid from '../uuid'; const initialState = Immutable.Map({ onboarded: false, + layout: 'auto', home: Immutable.Map({ shows: Immutable.Map({ diff --git a/app/javascript/packs/custom.js b/app/javascript/packs/custom.js new file mode 100644 index 000000000..4db2964f6 --- /dev/null +++ b/app/javascript/packs/custom.js @@ -0,0 +1 @@ +require('../styles/custom.scss'); diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index a0e511b0a..ae903cdd3 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -4,6 +4,7 @@ import { delegate } from 'rails-ujs'; import emojify from '../mastodon/emoji'; import { getLocale } from '../mastodon/locales'; import loadPolyfills from '../mastodon/load_polyfills'; +import { processBio } from '../mastodon/features/account/util/bio_metadata'; require.context('../images/', true); @@ -87,7 +88,8 @@ function main() { delegate(document, '.account_note', 'input', ({ target }) => { const noteCounter = document.querySelector('.note-counter'); if (noteCounter) { - noteCounter.textContent = 160 - length(target.value); + const noteWithoutMetadata = processBio(target.value).text; + noteCounter.textContent = 500 - length(noteWithoutMetadata); } }); } diff --git a/app/javascript/styles/_mixins.scss b/app/javascript/styles/_mixins.scss index 67d768a6c..7412991b8 100644 --- a/app/javascript/styles/_mixins.scss +++ b/app/javascript/styles/_mixins.scss @@ -1,5 +1,5 @@ @mixin avatar-radius() { - border-radius: 4px; + border-radius: $ui-avatar-border-size; background: transparent no-repeat; background-position: 50%; background-clip: padding-box; @@ -10,3 +10,33 @@ height: $size; background-size: $size $size; } + +@mixin single-column($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .single-column #{$parent} { + @content; + } +} + +@mixin limited-single-column($media, $parent: '&') { + .auto-columns #{$parent}, .single-column #{$parent} { + @media #{$media} { + @content; + } + } +} + +@mixin multi-columns($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .multi-columns #{$parent} { + @content; + } +} diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss index 3512bdcb4..7145d0092 100644 --- a/app/javascript/styles/about.scss +++ b/app/javascript/styles/about.scss @@ -172,16 +172,14 @@ text-align: center; .avatar { - width: 80px; - height: 80px; + @include avatar-size(80px); margin: 0 auto; margin-bottom: 15px; img { + @include avatar-radius(); + @include avatar-size(80px); display: block; - width: 80px; - height: 80px; - border-radius: 48px; } } diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss index 801817d80..10f8bd2b9 100644 --- a/app/javascript/styles/accounts.scss +++ b/app/javascript/styles/accounts.scss @@ -46,17 +46,16 @@ } .avatar { - width: 120px; + @include avatar-size(120px); margin: 0 auto; margin-bottom: 15px; position: relative; z-index: 2; img { - width: 120px; - height: 120px; + @include avatar-radius(); + @include avatar-size(120px); display: block; - border-radius: 120px; } } @@ -283,16 +282,14 @@ } .avatar { - width: 60px; - height: 60px; + @include avatar-size(60px); float: left; margin-right: 15px; img { + @include avatar-radius(); + @include avatar-size(60px); display: block; - width: 60px; - height: 60px; - border-radius: 60px; } } @@ -359,15 +356,14 @@ } & > div { + @include avatar-size(48px); float: left; margin-right: 10px; - width: 48px; - height: 48px; } .avatar { + @include avatar-radius(); display: block; - border-radius: 4px; } .display-name { diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 88431fc69..a7c982cb2 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -423,11 +423,13 @@ .status__content, .reply-indicator__content { + position: relative; font-size: 15px; line-height: 20px; + color: $primary-text-color; word-wrap: break-word; font-weight: 400; - overflow: hidden; + overflow: visible; white-space: pre-wrap; .emojione { @@ -470,19 +472,10 @@ } } - .status__content__spoiler-link { - background: lighten($ui-base-color, 30%); - - &:hover { - background: lighten($ui-base-color, 33%); - text-decoration: none; - } - } - - .status__content__text { + .status__content__spoiler { display: none; - &.status__content__text--visible { + &.status__content__spoiler--visible { display: block; } } @@ -491,15 +484,21 @@ .status__content__spoiler-link { display: inline-block; border-radius: 2px; - background: transparent; - border: 0; + background: lighten($ui-base-color, 30%); + border: none; color: lighten($ui-base-color, 8%); font-weight: 500; font-size: 11px; - padding: 0 6px; + padding: 0 5px; text-transform: uppercase; line-height: inherit; cursor: pointer; + vertical-align: bottom; + + &:hover { + background: lighten($ui-base-color, 33%); + text-decoration: none; + } } .status__prepend-icon-wrapper { @@ -511,6 +510,7 @@ padding: 8px 10px; padding-left: 68px; position: relative; + height: auto; min-height: 48px; border-bottom: 1px solid lighten($ui-base-color, 8%); cursor: default; @@ -567,6 +567,29 @@ } } } + + &.status-collapsed { + height: 48px; + background-position: center; + background-size: cover; + + &::before { + display: block; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-image: linear-gradient(to bottom, transparentize($ui-base-color, .15), transparentize($ui-base-color, .3) 24px, transparentize($ui-base-color, .35)); + content: ""; + } + + .status__content { + height: 20px; + overflow: hidden; + text-overflow: ellipsis; + } + } } .notification-favourite { @@ -580,9 +603,16 @@ } .status__relative-time { + display: inline-block; + margin-left: auto; + padding-left: 18px; + width: 120px; color: lighten($ui-base-color, 26%); - float: right; font-size: 14px; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .status__display-name { @@ -596,7 +626,16 @@ } .status__info { + margin: 2px 0 0; font-size: 15px; + line-height: 24px; +} + +.status__info__icons { + display: inline-block; + position: relative; + float: right; + color: $ui-primary-color; } .status-check-box { @@ -637,11 +676,20 @@ align-items: center; display: flex; margin-top: 10px; + margin-left: -58px; + + &::before { + display: block; + flex: 1 1 0; + max-width: 58px; + content: ""; + } } .status__action-bar-button { float: left; margin-right: 18px; + flex: 0 0 auto; } .status__action-bar-dropdown { @@ -791,9 +839,12 @@ padding: 10px; } -.account__header { +.account__header__wrapper { flex: 0 0 auto; background: lighten($ui-base-color, 4%); +} + +.account__header { text-align: center; background-size: cover; background-position: center; @@ -858,6 +909,58 @@ } } +.account__metadata { + width: 100%; + font-size: 15px; + line-height: 20px; + overflow: hidden; + border-collapse: collapse; + + a { + text-decoration: none; + + &:hover{ + text-decoration: underline; + } + } + + tr { + border-top: 1px solid lighten($ui-base-color, 8%); + } + + th, td { + padding: 14px 20px; + vertical-align: middle; + + & > div { + max-height: 40px; + overflow-y: auto; + text-overflow: ellipsis; + } + } + + th { + color: $ui-primary-color; + background: lighten($ui-base-color, 13%); + font-variant: small-caps; + max-width: 120px; + + a { + color: $primary-text-color; + } + } + + td { + flex: auto; + color: $primary-text-color; + background: $ui-base-color; + + a { + color: $ui-highlight-color; + } + } +} + .account__action-bar { border-top: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%); @@ -919,12 +1022,11 @@ } .account__header__avatar { - background-size: 90px 90px; + @include avatar-radius(); + @include avatar-size(90px); display: block; - height: 90px; margin: 0 auto 10px; overflow: hidden; - width: 90px; } .account-authorize { @@ -1078,6 +1180,7 @@ .display-name { display: block; + position: relative; max-width: 100%; overflow: hidden; text-overflow: ellipsis; @@ -1246,11 +1349,12 @@ justify-content: flex-start; overflow-x: auto; position: relative; + padding: 10px; } -@media screen and (min-width: 360px) { +@include limited-single-column('screen and (max-width: 360px)', $parent: null) { .columns-area { - padding: 10px; + padding: 0; } } @@ -1260,6 +1364,7 @@ box-sizing: border-box; display: flex; flex-direction: column; + overflow: hidden; > .scrollable { background: $ui-base-color; @@ -1280,7 +1385,7 @@ box-sizing: border-box; display: flex; flex-direction: column; - overflow-y: hidden; + overflow-y: auto; } .drawer__tab { @@ -1298,24 +1403,22 @@ .column, .drawer { flex: 1 1 100%; - overflow: hidden; @supports(display: grid) { // hack to fix Chrome <57 contain: strict; } } -@media screen and (min-width: 360px) { +@include limited-single-column('screen and (max-width: 360px)', $parent: null) { .tabs-bar { - margin: 10px; - margin-bottom: 0; + margin: 0; } .search { - margin-bottom: 10px; + margin-bottom: 0; } } -@media screen and (max-width: 1024px) { +@include single-column('screen and (max-width: 1024px)', $parent: null) { .column, .drawer { width: 100%; @@ -1332,7 +1435,7 @@ } } -@media screen and (min-width: 1025px) { +@include multi-columns('screen and (min-width: 1025px)', $parent: null) { .columns-area { padding: 0; } @@ -1365,28 +1468,25 @@ .drawer__pager { box-sizing: border-box; padding: 0; - flex-grow: 1; + flex: 0 0 auto; position: relative; - overflow: hidden; - display: flex; } .drawer__inner { - position: absolute; - top: 0; - left: 0; background: lighten($ui-base-color, 13%); box-sizing: border-box; padding: 0; - display: flex; - flex-direction: column; overflow: hidden; overflow-y: auto; width: 100%; - height: 100%; &.darker { + position: absolute; + top: 0; + left: 0; background: $ui-base-color; + width: 100%; + height: 100%; } } @@ -1414,11 +1514,32 @@ } } +.layout__selector { + margin-top: 20px; + + a { + text-decoration: underline; + cursor: pointer; + color: lighten($ui-base-color, 26%); + } + + b { + font-weight: bold; + } + + p { + font-size: 13px; + color: $ui-secondary-color; + } +} + .tabs-bar { display: flex; background: lighten($ui-base-color, 8%); flex: 0 0 auto; overflow-y: auto; + margin: 10px; + margin-bottom: 0; } .tabs-bar__link { @@ -1446,7 +1567,7 @@ &:hover, &:focus, &:active { - @media screen and (min-width: 1025px) { + @include multi-columns('screen and (min-width: 1025px)') { background: lighten($ui-base-color, 14%); transition: all 100ms linear; } @@ -1458,7 +1579,7 @@ } } -@media screen and (min-width: 600px) { +@include limited-single-column('screen and (max-width: 600px)', $parent: null) { .tabs-bar__link { span { display: inline; @@ -1466,7 +1587,7 @@ } } -@media screen and (min-width: 1025px) { +@include multi-columns('screen and (min-width: 1025px)', $parent: null) { .tabs-bar { display: none; } @@ -1655,7 +1776,7 @@ } &.hidden-on-mobile { - @media screen and (max-width: 1024px) { + @include single-column('screen and (max-width: 1024px)') { display: none; } } @@ -1699,7 +1820,7 @@ outline: 0; } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } } @@ -1716,7 +1837,7 @@ padding-right: 10px + 22px; resize: none; - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { height: 100px !important; // prevent auto-resize textarea resize: vertical; } @@ -1829,7 +1950,7 @@ border-bottom-color: $ui-highlight-color; } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } @@ -2043,7 +2164,7 @@ button.icon-button.active i.fa-retweet { } &.hidden-on-mobile { - @media screen and (max-width: 1024px) { + @include single-column('screen and (max-width: 1024px)') { display: none; } } @@ -2740,6 +2861,7 @@ button.icon-button.active i.fa-retweet { .search { position: relative; + margin-bottom: 10px; } .search__input { @@ -2772,7 +2894,7 @@ button.icon-button.active i.fa-retweet { background: lighten($ui-base-color, 4%); } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } } diff --git a/app/javascript/styles/custom.scss b/app/javascript/styles/custom.scss new file mode 100644 index 000000000..5144e4fb6 --- /dev/null +++ b/app/javascript/styles/custom.scss @@ -0,0 +1,118 @@ +@import 'application'; + +@include multi-columns('screen and (min-width: 1300px)', $parent: null) { + .column { + flex-grow: 1 !important; + max-width: 400px; + } + + .drawer { + flex-grow: 1 !important; + flex-basis: 200px !important; + min-width: 268px; + max-width: 400px; + } +} + +.muted { + .status__content p, .status__content a { + color: lighten($ui-base-color, 35%); + } + + .status__display-name strong { + color: lighten($ui-base-color, 35%); + } +} + +.status time:after, +.detailed-status__datetime span:after { + font: normal normal normal 14px/1 FontAwesome; + content: "\00a0\00a0\f08e"; +} + +.compose-form__buttons button.active:last-child { + color:$ui-secondary-color; + background-color: $ui-highlight-color; + border-radius:3px; +} + +.about-body .mascot { + display:none; +} + +.screenshot-with-signup { + min-height:300px; +} + +.screenshot-with-signup .closed-registrations-message, +.screenshot-with-signup form { + background-color: rgba(0,0,0,0.7); + margin:auto; +} + +.screenshot-with-signup .closed-registrations-message .clock { + font-size:150%; +} + +.drawer .drawer__inner { + overflow: visible; +} + +.column { + // trying to fix @mdhughes safari problem + max-height:100vh; +} + + + +.media-gallery { + height:auto !important; + max-height:250px; + position:relative; + margin-top:20px; + margin-left:-68px; + width: calc(100% + 80px); +} + +.media-gallery:before{ + content: ""; + display: block; + padding-top: 100%; +} + +.media-gallery__item, +.media-gallery .media-spoiler{ + left: 0; + right: 0; + top: 0; + bottom: 0 !important; + position:absolute; +} + +.media-spoiler-video:before { + content:""; + display:block; + padding-top:100%; +} + +.media-spoiler-video, +.status__video-player, +.detailed-status > .media-spoiler, +.status > .media-spoiler { + height:auto !important; + max-height:250px; + position:relative; + margin-top:20px; + margin-left:-68px; + width: calc(100% + 80px) !important; +} + +.status__video-player-video { + transform:unset; +} + +.detailed-status > .media-spoiler, +.status > .media-spoiler { + height:250px !important; + vertical-align:middle; +} diff --git a/app/javascript/styles/stream_entries.scss b/app/javascript/styles/stream_entries.scss index fcec32d44..490e36fab 100644 --- a/app/javascript/styles/stream_entries.scss +++ b/app/javascript/styles/stream_entries.scss @@ -64,19 +64,17 @@ .status__avatar { position: absolute; + @include avatar-size(48px); left: 14px; top: 14px; - width: 48px; - height: 48px; & > div { - width: 48px; - height: 48px; + @include avatar-size(48px); } img { + @include avatar-radius(); display: block; - border-radius: 4px; } } @@ -164,12 +162,11 @@ } .avatar { - width: 48px; - height: 48px; + @include avatar-size(48px); img { + @include avatar-radius(); display: block; - border-radius: 4px; } } diff --git a/app/javascript/styles/variables.scss b/app/javascript/styles/variables.scss index 8362096e1..bf8c12bc0 100644 --- a/app/javascript/styles/variables.scss +++ b/app/javascript/styles/variables.scss @@ -26,3 +26,6 @@ $ui-base-color: $classic-base-color !default; // Darkest $ui-primary-color: $classic-primary-color !default; // Lighter $ui-secondary-color: $classic-secondary-color !default; // Lightest $ui-highlight-color: $classic-highlight-color !default; // Vibrant + +// Avatar border size (8% default, 100% for rounded avatars) +$ui-avatar-border-size: 8%; |