Merge tootsuite/master at 3023725936

This commit is contained in:
Surinna Curtis
2017-11-16 01:21:16 -06:00
230 changed files with 8548 additions and 567 deletions

View File

@@ -105,12 +105,13 @@ export function fetchAccountFail(id, error) {
};
};
export function followAccount(id) {
export function followAccount(id, reblogs = true) {
return (dispatch, getState) => {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
dispatch(followAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => {
dispatch(followAccountSuccess(response.data));
api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
dispatch(followAccountSuccess(response.data, alreadyFollowing));
}).catch(error => {
dispatch(followAccountFail(error));
});
@@ -136,10 +137,11 @@ export function followAccountRequest(id) {
};
};
export function followAccountSuccess(relationship) {
export function followAccountSuccess(relationship, alreadyFollowing) {
return {
type: ACCOUNT_FOLLOW_SUCCESS,
relationship,
alreadyFollowing,
};
};

View File

@@ -8,6 +8,7 @@ import {
refreshHomeTimeline,
refreshCommunityTimeline,
refreshPublicTimeline,
refreshDirectTimeline,
} from './timelines';
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
@@ -31,6 +32,7 @@ export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE';
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
@@ -44,6 +46,8 @@ export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
export const COMPOSE_DOODLE_SET = 'COMPOSE_DOODLE_SET';
export function changeCompose(text) {
return {
type: COMPOSE_CHANGE,
@@ -91,14 +95,16 @@ export function mentionCompose(account, router) {
export function submitCompose() {
return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], '');
let status = getState().getIn(['compose', 'text'], '');
if (!status || !status.length) {
return;
}
dispatch(submitComposeRequest());
if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
status = status + ' 👁️';
}
api(getState).post('/api/v1/statuses', {
status,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
@@ -128,6 +134,8 @@ export function submitCompose() {
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
insertOrRefresh('community', refreshCommunityTimeline);
insertOrRefresh('public', refreshPublicTimeline);
} else if (response.data.visibility === 'direct') {
insertOrRefresh('direct', refreshDirectTimeline);
}
}).catch(function (error) {
dispatch(submitComposeFail(error));
@@ -155,6 +163,13 @@ export function submitComposeFail(error) {
};
};
export function doodleSet(options) {
return {
type: COMPOSE_DOODLE_SET,
options: options,
};
};
export function uploadCompose(files) {
return function (dispatch, getState) {
if (getState().getIn(['compose', 'media_attachments']).size > 3) {
@@ -334,6 +349,13 @@ export function unmountCompose() {
};
};
export function toggleComposeAdvancedOption(option) {
return {
type: COMPOSE_ADVANCED_OPTIONS_CHANGE,
option: option,
};
}
export function changeComposeSensitivity() {
return {
type: COMPOSE_SENSITIVITY_CHANGE,

View File

@@ -6,6 +6,17 @@ import { defineMessages } from 'react-intl';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
// tracking the notif cleaning request
export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST';
export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS';
export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL';
export const NOTIFICATIONS_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE';
export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes
// Unmark notifications (when the cleaning mode is left)
export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE';
// Mark one for delete
export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE';
export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL';
@@ -188,3 +199,67 @@ export function scrollTopNotifications(top) {
top,
};
};
export function deleteMarkedNotifications() {
return (dispatch, getState) => {
dispatch(deleteMarkedNotificationsRequest());
let ids = [];
getState().getIn(['notifications', 'items']).forEach((n) => {
if (n.get('markedForDelete')) {
ids.push(n.get('id'));
}
});
if (ids.length === 0) {
return;
}
api(getState).delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => {
dispatch(deleteMarkedNotificationsSuccess());
}).catch(error => {
console.error(error);
dispatch(deleteMarkedNotificationsFail(error));
});
};
};
export function enterNotificationClearingMode(yes) {
return {
type: NOTIFICATIONS_ENTER_CLEARING_MODE,
yes: yes,
};
};
export function markAllNotifications(yes) {
return {
type: NOTIFICATIONS_MARK_ALL_FOR_DELETE,
yes: yes, // true, false or null. null = invert
};
};
export function deleteMarkedNotificationsRequest() {
return {
type: NOTIFICATIONS_DELETE_MARKED_REQUEST,
};
};
export function deleteMarkedNotificationsFail() {
return {
type: NOTIFICATIONS_DELETE_MARKED_FAIL,
};
};
export function markNotificationForDelete(id, yes) {
return {
type: NOTIFICATION_MARK_FOR_DELETE,
id: id,
yes: yes,
};
};
export function deleteMarkedNotificationsSuccess() {
return {
type: NOTIFICATIONS_DELETE_MARKED_SUCCESS,
};
};

View File

@@ -51,3 +51,4 @@ export const connectCommunityStream = () => connectTimelineStream('community', '
export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
export const connectPublicStream = () => connectTimelineStream('public', 'public');
export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');

View File

@@ -115,6 +115,7 @@ export function refreshTimeline(timelineId, path, params = {}) {
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
export const refreshDirectTimeline = () => refreshTimeline('direct', '/api/v1/timelines/direct');
export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
@@ -155,6 +156,7 @@ export function expandTimeline(timelineId, path, params = {}) {
export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home');
export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public');
export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
export const expandDirectTimeline = () => expandTimeline('direct', '/api/v1/timelines/direct');
export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);

View File

@@ -3,6 +3,7 @@
exports[`<Avatar /> Autoplay renders a animated avatar 1`] = `
<div
className="account__avatar"
data-avatar-of="@alice"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
@@ -19,6 +20,7 @@ exports[`<Avatar /> Autoplay renders a animated avatar 1`] = `
exports[`<Avatar /> Still renders a still avatar 1`] = `
<div
className="account__avatar"
data-avatar-of="@alice"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={

View File

@@ -6,6 +6,7 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
>
<div
className="account__avatar-overlay-base"
data-avatar-of="@alice"
style={
Object {
"backgroundImage": "url(/static/alice.jpg)",
@@ -14,6 +15,7 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
/>
<div
className="account__avatar-overlay-overlay"
data-avatar-of="@eve@blackhat.lair"
style={
Object {
"backgroundImage": "url(/static/eve.jpg)",

View File

@@ -112,3 +112,19 @@ exports[`<Button /> renders the props.text instead of children 1`] = `
foo
</button>
`;
exports[`<Button /> renders title if props.title is given 1`] = `
<button
className="button"
disabled={undefined}
onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
title="foo"
/>
`;

View File

@@ -72,4 +72,11 @@ describe('<Button />', () => {
expect(tree).toMatchSnapshot();
});
it('renders title if props.title is given', () => {
const component = renderer.create(<Button title='foo' />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -15,8 +15,8 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'You are not currently muting notifications from @{name}. Click to mute notifications' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'You are currently muting notifications from @{name}. Click to unmute notifications' },
});
@injectIntl
@@ -93,7 +93,7 @@ export default class Account extends ImmutablePureComponent {
</div>
);
} else {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following ? true : false} />;
}
}

View File

@@ -11,8 +11,8 @@ import classNames from 'classnames';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
let left = str.slice(0, caretPosition).search(/\S+$/);
let right = str.slice(caretPosition).search(/\s/);
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
let right = str.slice(caretPosition).search(/[\s\u200B]/);
if (right < 0) {
word = str.slice(left);

View File

@@ -64,6 +64,7 @@ export default class Avatar extends React.PureComponent {
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={style}
data-avatar-of={`@${account.get('acct')}`}
/>
);
}

View File

@@ -21,8 +21,8 @@ export default class AvatarOverlay extends React.PureComponent {
return (
<div className='account__avatar-overlay'>
<div className='account__avatar-overlay-base' style={baseStyle} />
<div className='account__avatar-overlay-overlay' style={overlayStyle} />
<div className='account__avatar-overlay-base' style={baseStyle} data-avatar-of={`@${account.get('acct')}`} />
<div className='account__avatar-overlay-overlay' style={overlayStyle} data-avatar-of={`@${friend.get('acct')}`} />
</div>
);
}

View File

@@ -14,6 +14,7 @@ export default class Button extends React.PureComponent {
className: PropTypes.string,
style: PropTypes.object,
children: PropTypes.node,
title: PropTypes.string,
};
static defaultProps = {
@@ -35,26 +36,26 @@ export default class Button extends React.PureComponent {
}
render () {
const style = {
padding: `0 ${this.props.size / 2.25}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
let attrs = {
className: classNames('button', this.props.className, {
'button-secondary': this.props.secondary,
'button--block': this.props.block,
}),
disabled: this.props.disabled,
onClick: this.handleClick,
ref: this.setRef,
style: {
padding: `0 ${this.props.size / 2.25}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
},
};
const className = classNames('button', this.props.className, {
'button-secondary': this.props.secondary,
'button--block': this.props.block,
});
if (this.props.title) attrs.title = this.props.title;
return (
<button
className={className}
disabled={this.props.disabled}
onClick={this.handleClick}
ref={this.setRef}
style={style}
>
<button {...attrs}>
{this.props.text || this.props.children}
</button>
);

View File

@@ -7,6 +7,8 @@ export default class Column extends React.PureComponent {
static propTypes = {
children: PropTypes.node,
extraClasses: PropTypes.string,
name: PropTypes.string,
};
scrollTop () {
@@ -40,10 +42,10 @@ export default class Column extends React.PureComponent {
}
render () {
const { children } = this.props;
const { children, extraClasses, name } = this.props;
return (
<div role='region' className='column' ref={this.setRef}>
<div role='region' data-column={name} className={`column ${extraClasses || ''}`} ref={this.setRef}>
{children}
</div>
);

View File

@@ -9,7 +9,8 @@ export default class ColumnBackButton extends React.PureComponent {
};
handleClick = () => {
if (window.history && window.history.length === 1) {
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();

View File

@@ -9,8 +9,12 @@ export default class ColumnBackButtonSlim extends React.PureComponent {
};
handleClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/');
else this.context.router.history.goBack();
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
}
render () {

View File

@@ -1,13 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Glitch imports
import NotificationPurgeButtonsContainer from '../../glitch/components/column/notif_cleaning_widget/container';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
});
@injectIntl
@@ -22,14 +27,19 @@ export default class ColumnHeader extends React.PureComponent {
title: PropTypes.node.isRequired,
icon: PropTypes.string.isRequired,
active: PropTypes.bool,
localSettings : ImmutablePropTypes.map,
multiColumn: PropTypes.bool,
focusable: PropTypes.bool,
showBackButton: PropTypes.bool,
notifCleaning: PropTypes.bool, // true only for the notification column
notifCleaningActive: PropTypes.bool,
onEnterCleaningMode: PropTypes.func,
children: PropTypes.node,
pinned: PropTypes.bool,
onPin: PropTypes.func,
onMove: PropTypes.func,
onClick: PropTypes.func,
intl: PropTypes.object.isRequired,
};
static defaultProps = {
@@ -39,6 +49,7 @@ export default class ColumnHeader extends React.PureComponent {
state = {
collapsed: true,
animating: false,
animatingNCD: false,
};
handleToggleClick = (e) => {
@@ -59,17 +70,32 @@ export default class ColumnHeader extends React.PureComponent {
}
handleBackClick = () => {
if (window.history && window.history.length === 1) this.context.router.history.push('/');
else this.context.router.history.goBack();
// if history is exhausted, or we would leave mastodon, just go to root.
if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) {
this.context.router.history.push('/');
} else {
this.context.router.history.goBack();
}
}
handleTransitionEnd = () => {
this.setState({ animating: false });
}
handleTransitionEndNCD = () => {
this.setState({ animatingNCD: false });
}
onEnterCleaningMode = () => {
this.setState({ animatingNCD: true });
this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
}
render () {
const { title, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage } } = this.props;
const { collapsed, animating } = this.state;
const { intl, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive } = this.props;
const { collapsed, animating, animatingNCD } = this.state;
let title = this.props.title;
const wrapperClassName = classNames('column-header__wrapper', {
'active': active,
@@ -88,8 +114,20 @@ export default class ColumnHeader extends React.PureComponent {
'active': !collapsed,
});
const notifCleaningButtonClassName = classNames('column-header__button', {
'active': notifCleaningActive,
});
const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', {
'collapsed': !notifCleaningActive,
'animating': animatingNCD,
});
let extraContent, pinButton, moveButtons, backButton, collapseButton;
//*glitch
const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
if (children) {
extraContent = (
<div key='extra-content' className='column-header__collapsible__extra'>
@@ -140,13 +178,30 @@ export default class ColumnHeader extends React.PureComponent {
<span className='column-header__title'>
{title}
</span>
<div className='column-header__buttons'>
{backButton}
{ notifCleaning ? (
<button
aria-label={msgEnterNotifCleaning}
title={msgEnterNotifCleaning}
onClick={this.onEnterCleaningMode}
className={notifCleaningButtonClassName}
>
<i className='fa fa-eraser' />
</button>
) : null}
{collapseButton}
</div>
</h1>
{ notifCleaning ? (
<div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}>
<div className='column-header__collapsible-inner nopad-drawer'>
{(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null }
</div>
</div>
) : null}
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}

View File

@@ -20,8 +20,10 @@ export default class IconButton extends React.PureComponent {
disabled: PropTypes.bool,
inverted: PropTypes.bool,
animate: PropTypes.bool,
flip: PropTypes.bool,
overlay: PropTypes.bool,
tabIndex: PropTypes.string,
label: PropTypes.string,
};
static defaultProps = {
@@ -42,14 +44,18 @@ export default class IconButton extends React.PureComponent {
}
render () {
const style = {
let style = {
fontSize: `${this.props.size}px`,
width: `${this.props.size * 1.28571429}px`,
height: `${this.props.size * 1.28571429}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
...(this.props.active ? this.props.activeStyle : {}),
};
if (!this.props.label) {
style.width = `${this.props.size * 1.28571429}px`;
} else {
style.textAlign = 'left';
}
const {
active,
@@ -59,6 +65,7 @@ export default class IconButton extends React.PureComponent {
expanded,
icon,
inverted,
flip,
overlay,
pressed,
tabIndex,
@@ -72,6 +79,21 @@ export default class IconButton extends React.PureComponent {
overlayed: overlay,
});
const flipDeg = flip ? -180 : -360;
const rotateDeg = active ? flipDeg : 0;
const motionDefaultStyle = {
rotate: rotateDeg,
};
const springOpts = {
stiffness: this.props.flip ? 60 : 120,
damping: 7,
};
const motionStyle = {
rotate: animate ? spring(rotateDeg, springOpts) : 0,
};
if (!animate) {
// Perf optimization: avoid unnecessary <Motion> components unless
// we actually need to animate.
@@ -92,7 +114,7 @@ export default class IconButton extends React.PureComponent {
}
return (
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
<Motion defaultStyle={motionDefaultStyle} style={motionStyle}>
{({ rotate }) =>
<button
aria-label={title}
@@ -105,6 +127,7 @@ export default class IconButton extends React.PureComponent {
tabIndex={tabIndex}
>
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
{this.props.label}
</button>
}
</Motion>

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/gallery
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/action_bar
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/content
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import StatusContainer from '../containers/status_container';
import StatusContainer from '../../glitch/components/status/container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ScrollableList from './scrollable_list';

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/status/container
import React from 'react';
import { connect } from 'react-redux';
import Status from '../components/status';

View File

@@ -20,6 +20,8 @@ const messages = defineMessages({
media: { id: 'account.media', defaultMessage: 'Media' },
blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
});
@injectIntl
@@ -30,6 +32,7 @@ export default class ActionBar extends React.PureComponent {
onFollow: PropTypes.func,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
@@ -60,6 +63,15 @@ export default class ActionBar extends React.PureComponent {
if (account.get('id') === me) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
} else {
const following = account.getIn(['relationship', 'following']);
if (following) {
if (following.get('reblogs')) {
menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
} else {
menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
}
}
if (account.getIn(['relationship', 'muting'])) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
} else {

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/account/header
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import InnerHeader from '../../account/components/header';
import InnerHeader from '../../../../glitch/components/account/header';
import ActionBar from '../../account/components/action_bar';
import MissingIndicator from '../../../components/missing_indicator';
import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -13,6 +13,7 @@ export default class Header extends ImmutablePureComponent {
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
@@ -39,6 +40,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onReport(this.props.account);
}
handleReblogToggle = () => {
this.props.onReblogToggle(this.props.account);
}
handleMute = () => {
this.props.onMute(this.props.account);
}
@@ -77,6 +82,7 @@ export default class Header extends ImmutablePureComponent {
account={account}
onBlock={this.handleBlock}
onMention={this.handleMention}
onReblogToggle={this.handleReblogToggle}
onReport={this.handleReport}
onMute={this.handleMute}
onBlockDomain={this.handleBlockDomain}

View File

@@ -67,6 +67,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(mentionCompose(account, router));
},
onReblogToggle (account) {
if (account.getIn(['relationship', 'following', 'reblogs'])) {
dispatch(followAccount(account.get('id'), false));
} else {
dispatch(followAccount(account.get('id'), true));
}
},
onReport (account) {
dispatch(initReport(account));
},

View File

@@ -59,7 +59,7 @@ export default class AccountTimeline extends ImmutablePureComponent {
}
return (
<Column>
<Column name='account'>
<ColumnBackButton />
<StatusList

View File

@@ -54,7 +54,7 @@ export default class Blocks extends ImmutablePureComponent {
}
return (
<Column icon='ban' heading={intl.formatMessage(messages.heading)}>
<Column name='blocks' icon='ban' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollContainer scrollKey='blocks'>
<div className='scrollable' onScroll={this.handleScroll}>

View File

@@ -79,7 +79,7 @@ export default class CommunityTimeline extends React.PureComponent {
const pinned = !!columnId;
return (
<Column ref={this.setRef}>
<Column ref={this.setRef} name='local'>
<ColumnHeader
icon='users'
active={hasUnread}

View File

@@ -5,11 +5,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl } from 'react-intl';
import Collapsable from '../../../components/collapsable';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import ComposeAdvancedOptionsContainer from '../../../../glitch/components/compose/advanced_options/container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import UploadFormContainer from '../containers/upload_form_container';
@@ -18,6 +18,7 @@ import { isMobile } from '../../../is_mobile';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import { countableText } from '../util/counter';
import ComposeAttachOptions from '../../../../glitch/components/compose/attach_options/index';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -36,6 +37,9 @@ export default class ComposeForm extends ImmutablePureComponent {
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
privacy: PropTypes.string,
advanced_options: ImmutablePropTypes.contains({
do_not_federate: PropTypes.bool,
}),
spoiler_text: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
preselectDate: PropTypes.instanceOf(Date),
@@ -45,11 +49,13 @@ export default class ComposeForm extends ImmutablePureComponent {
onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onPrivacyChange: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
onChangeSpoilerText: PropTypes.func.isRequired,
onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool,
settings : ImmutablePropTypes.map.isRequired,
};
static defaultProps = {
@@ -66,6 +72,11 @@ export default class ComposeForm extends ImmutablePureComponent {
}
}
handleSubmit2 = () => {
this.props.onPrivacyChange(this.props.settings.get('side_arm'));
this.handleSubmit();
}
handleSubmit = () => {
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
@@ -144,16 +155,58 @@ export default class ComposeForm extends ImmutablePureComponent {
render () {
const { intl, onPaste, showSearch } = this.props;
const disabled = this.props.is_submitting;
const text = [this.props.spoiler_text, countableText(this.props.text)].join('');
const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
const secondaryVisibility = this.props.settings.get('side_arm');
let showSideArm = secondaryVisibility !== 'none';
let publishText = '';
let publishText2 = '';
let title = '';
let title2 = '';
if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
const privacyIcons = {
none: '',
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
};
title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
if (showSideArm) {
// Enhanced behavior with dual toot buttons
publishText = (
<span>
{
<i
className={`fa fa-${privacyIcons[this.props.privacy]}`}
style={{ paddingRight: '5px' }}
/>
}{intl.formatMessage(messages.publish)}
</span>
);
title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
publishText2 = (
<i
className={`fa fa-${privacyIcons[secondaryVisibility]}`}
aria-label={title2}
/>
);
} else {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
// Original vanilla behavior - no icon if public or unlisted
if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
} else {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
}
const submitDisabled = disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0);
return (
<div className='compose-form'>
<Collapsable isVisible={this.props.spoiler} fullHeight={50}>
@@ -192,17 +245,35 @@ export default class ComposeForm extends ImmutablePureComponent {
<UploadFormContainer />
</div>
<div className='compose-form__buttons-wrapper'>
<div className='compose-form__buttons'>
<UploadButtonContainer />
<PrivacyDropdownContainer />
<SensitiveButtonContainer />
<SpoilerButtonContainer />
</div>
<div className='compose-form__buttons'>
<ComposeAttachOptions />
<SensitiveButtonContainer />
<div className='compose-form__buttons-separator' />
<PrivacyDropdownContainer />
<SpoilerButtonContainer />
<ComposeAdvancedOptionsContainer />
</div>
<div className='compose-form__publish'>
<div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0)} block /></div>
<div className='compose-form__publish'>
<div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
<div className='compose-form__publish-button-wrapper'>
{
showSideArm ?
<Button
className='compose-form__publish__side-arm'
text={publishText2}
title={title2}
onClick={this.handleSubmit2}
disabled={submitDisabled}
/> : ''
}
<Button
className='compose-form__publish__primary'
text={publishText}
title={title}
onClick={this.handleSubmit}
disabled={submitDisabled}
/>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container';
import StatusContainer from '../../../../glitch/components/status/container';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';

View File

@@ -1,6 +1,6 @@
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import { uploadCompose } from '../../../actions/compose';
import { changeComposeVisibility, uploadCompose } from '../../../actions/compose';
import {
changeCompose,
submitCompose,
@@ -15,6 +15,7 @@ const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']),
suggestion_token: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']),
advanced_options: state.getIn(['compose', 'advanced_options']),
spoiler: state.getIn(['compose', 'spoiler']),
spoiler_text: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']),
@@ -23,6 +24,8 @@ const mapStateToProps = state => ({
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
settings: state.get('local_settings'),
filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,
});
const mapDispatchToProps = (dispatch) => ({
@@ -31,6 +34,10 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changeCompose(text));
},
onPrivacyChange (value) {
dispatch(changeComposeVisibility(value));
},
onSubmit () {
dispatch(submitCompose());
},

View File

@@ -5,6 +5,8 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from '../../actions/compose';
import { openModal } from '../../actions/modal';
import { changeLocalSetting } from '../../../glitch/actions/local_settings';
import { Link } from 'react-router-dom';
import { injectIntl, defineMessages } from 'react-intl';
import SearchContainer from './containers/search_container';
@@ -19,7 +21,7 @@ const messages = defineMessages({
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' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
});
@@ -48,6 +50,16 @@ 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();
}
openSettings = () => {
this.props.dispatch(openModal('SETTINGS', {}));
}
onFocus = () => {
this.props.dispatch(changeComposing(true));
}
@@ -78,12 +90,14 @@ export default class Compose extends React.PureComponent {
{!columns.some(column => column.get('id') === 'PUBLIC') && (
<Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><i role='img' className='fa fa-fw fa-globe' /></Link>
)}
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><i role='img' className='fa fa-fw fa-cog' /></a>
<a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)} aria-label={intl.formatMessage(messages.settings)}><i role='img' className='fa fa-fw fa-cogs' /></a>
<a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><i role='img' className='fa fa-fw fa-sign-out' /></a>
</nav>
);
}
return (
<div className='drawer'>
{header}
@@ -91,7 +105,7 @@ export default class Compose extends React.PureComponent {
<SearchContainer />
<div className='drawer__pager'>
<div className='drawer__inner' onFocus={this.onFocus}>
<div className='drawer__inner scrollable optionally-scrollable' onFocus={this.onFocus}>
<NavigationContainer onClose={this.onBlur} />
<ComposeFormContainer />
</div>
@@ -104,6 +118,7 @@ export default class Compose extends React.PureComponent {
}
</Motion>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import ColumnSettings from '../../community_timeline/components/column_settings';
import { changeSetting } from '../../../actions/settings';
const mapStateToProps = state => ({
settings: state.getIn(['settings', 'direct']),
});
const mapDispatchToProps = dispatch => ({
onChange (key, checked) {
dispatch(changeSetting(['direct', ...key], checked));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import {
refreshDirectTimeline,
expandDirectTimeline,
} from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from '../../actions/streaming';
const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
});
@connect(mapStateToProps)
@injectIntl
export default class DirectTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('DIRECT', {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
componentDidMount () {
const { dispatch } = this.props;
dispatch(refreshDirectTimeline());
this.disconnect = dispatch(connectDirectStream());
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => {
this.column = c;
}
handleLoadMore = () => {
this.props.dispatch(expandDirectTimeline());
}
render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
return (
<Column ref={this.setRef}>
<ColumnHeader
icon='envelope'
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer />
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
loadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
/>
</Column>
);
}
}

View File

@@ -68,7 +68,7 @@ export default class Favourites extends ImmutablePureComponent {
const pinned = !!columnId;
return (
<Column ref={this.setRef}>
<Column ref={this.setRef} name='favourites'>
<ColumnHeader
icon='star'
title={intl.formatMessage(messages.heading)}

View File

@@ -47,14 +47,14 @@ export default class FollowRequests extends ImmutablePureComponent {
if (!accountIds) {
return (
<Column>
<Column name='follow-requests'>
<LoadingIndicator />
</Column>
);
}
return (
<Column icon='users' heading={intl.formatMessage(messages.heading)}>
<Column name='follow-requests' icon='users' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollContainer scrollKey='follow_requests'>

View File

@@ -4,6 +4,7 @@ import ColumnLink from '../ui/components/column_link';
import ColumnSubheading from '../ui/components/column_subheading';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from '../../actions/modal';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -17,13 +18,16 @@ const messages = defineMessages({
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
});
@@ -41,8 +45,18 @@ export default class GettingStarted extends ImmutablePureComponent {
myAccount: ImmutablePropTypes.map.isRequired,
columns: ImmutablePropTypes.list,
multiColumn: PropTypes.bool,
dispatch: PropTypes.func.isRequired,
};
openSettings = () => {
this.props.dispatch(openModal('SETTINGS', {}));
}
openOnboardingModal = (e) => {
e.preventDefault();
this.props.dispatch(openModal('ONBOARDING'));
}
render () {
const { intl, myAccount, columns, multiColumn } = this.props;
@@ -66,43 +80,62 @@ export default class GettingStarted extends ImmutablePureComponent {
}
}
navItems = navItems.concat([
<ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
]);
if (myAccount.get('locked')) {
navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
}
navItems = navItems.concat([
<ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
<ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
<ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
]);
if (myAccount.get('locked')) {
navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
}
navItems = navItems.concat([
<ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
<ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
]);
return (
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile>
<div className='getting-started__wrapper'>
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
{navItems}
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
<ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div>
<Column name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile>
<div className='scrollable optionally-scrollable'>
<div className='getting-started__wrapper'>
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
{navItems}
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
<ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
<ColumnLink icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
<ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={this.openSettings} />
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div>
<div className='getting-started__footer scrollable optionally-scrollable'>
<div className='static-content getting-started'>
<p>
<a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a>
</p>
<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> }}
/>
</p>
<div className='getting-started__footer'>
<div className='static-content getting-started'>
<p>
<a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'>
<FormattedMessage id='getting_started.faq' defaultMessage='FAQ' />
</a>&nbsp;&nbsp;
<a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'>
<FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' />
</a>&nbsp;&nbsp;
<a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'>
<FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' />
</a>
</p>
<p>
<FormattedMessage
id='getting_started.open_source_notice'
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>
</div>
</div>
</Column>

View File

@@ -91,7 +91,7 @@ export default class HashtagTimeline extends React.PureComponent {
const pinned = !!columnId;
return (
<Column ref={this.setRef}>
<Column ref={this.setRef} name='hashtag'>
<ColumnHeader
icon='hashtag'
active={hasUnread}

View File

@@ -62,7 +62,7 @@ export default class HomeTimeline extends React.PureComponent {
const pinned = !!columnId;
return (
<Column ref={this.setRef}>
<Column ref={this.setRef} name='home'>
<ColumnHeader
icon='home'
active={hasUnread}

View File

@@ -54,7 +54,7 @@ export default class Mutes extends ImmutablePureComponent {
}
return (
<Column icon='volume-off' heading={intl.formatMessage(messages.heading)}>
<Column name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollContainer scrollKey='mutes'>
<div className='scrollable mutes' onScroll={this.handleScroll}>

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/notification
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';

View File

@@ -1,3 +1,6 @@
// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
// SEE INSTEAD : glitch/components/notification/container
import { connect } from 'react-redux';
import { makeGetNotification } from '../../../selectors';
import Notification from '../components/notification';

View File

@@ -4,9 +4,13 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import { expandNotifications, scrollTopNotifications } from '../../actions/notifications';
import {
enterNotificationClearingMode,
expandNotifications,
scrollTopNotifications,
} from '../../actions/notifications';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import NotificationContainer from './containers/notification_container';
import NotificationContainer from '../../../glitch/components/notification/container';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { createSelector } from 'reselect';
@@ -25,12 +29,22 @@ const getNotifications = createSelector([
const mapStateToProps = state => ({
notifications: getNotifications(state),
localSettings: state.get('local_settings'),
isLoading: state.getIn(['notifications', 'isLoading'], true),
isUnread: state.getIn(['notifications', 'unread']) > 0,
hasMore: !!state.getIn(['notifications', 'next']),
notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
});
@connect(mapStateToProps)
/* glitch */
const mapDispatchToProps = dispatch => ({
onEnterCleaningMode(yes) {
dispatch(enterNotificationClearingMode(yes));
},
dispatch,
});
@connect(mapStateToProps, mapDispatchToProps)
@injectIntl
export default class Notifications extends React.PureComponent {
@@ -44,6 +58,9 @@ export default class Notifications extends React.PureComponent {
isUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
localSettings: ImmutablePropTypes.map,
notifCleaningActive: PropTypes.bool,
onEnterCleaningMode: PropTypes.func,
};
static defaultProps = {
@@ -146,7 +163,11 @@ export default class Notifications extends React.PureComponent {
);
return (
<Column ref={this.setColumnRef}>
<Column
ref={this.setColumnRef}
name='notifications'
extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null}
>
<ColumnHeader
icon='bell'
active={isUnread}
@@ -156,6 +177,10 @@ export default class Notifications extends React.PureComponent {
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
localSettings={this.props.localSettings}
notifCleaning
notifCleaningActive={this.props.notifCleaningActive} // this is used to toggle the header text
onEnterCleaningMode={this.props.onEnterCleaningMode}
>
<ColumnSettingsContainer />
</ColumnHeader>

View File

@@ -79,7 +79,7 @@ export default class PublicTimeline extends React.PureComponent {
const pinned = !!columnId;
return (
<Column ref={this.setRef}>
<Column ref={this.setRef} name='federated'>
<ColumnHeader
icon='globe'
active={hasUnread}

View File

@@ -107,8 +107,8 @@ export default class ActionBar extends React.PureComponent {
);
let reblogIcon = 'retweet';
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
//if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
// else if (status.get('visibility') === 'private') reblogIcon = 'lock';
let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private');

View File

@@ -3,14 +3,16 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import StatusContent from '../../../../glitch/components/status/content';
import StatusGallery from '../../../../glitch/components/status/gallery';
import StatusPlayer from '../../../../glitch/components/status/player';
import AttachmentList from '../../../components/attachment_list';
import { Link } from 'react-router-dom';
import { FormattedDate, FormattedNumber } from 'react-intl';
import CardContainer from '../containers/card_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video';
// import Video from '../../video';
import VisibilityIcon from '../../../../glitch/components/status/visibility_icon';
export default class DetailedStatus extends ImmutablePureComponent {
@@ -20,6 +22,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
settings: ImmutablePropTypes.map.isRequired,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
};
@@ -33,14 +36,16 @@ export default class DetailedStatus extends ImmutablePureComponent {
e.stopPropagation();
}
handleOpenVideo = startTime => {
this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
}
// handleOpenVideo = startTime => {
// this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
// }
render () {
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
const { settings } = this.props;
let media = '';
let mediaIcon = null;
let applicationLink = '';
let reblogLink = '';
let reblogIcon = 'retweet';
@@ -49,32 +54,32 @@ export default class DetailedStatus extends ImmutablePureComponent {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = <AttachmentList media={status.get('media_attachments')} />;
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);
media = (
<Video
preview={video.get('preview_url')}
src={video.get('url')}
width={300}
height={150}
onOpenVideo={this.handleOpenVideo}
<StatusPlayer
sensitive={status.get('sensitive')}
media={status.getIn(['media_attachments', 0])}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenVideo={this.props.onOpenVideo}
autoplay
/>
);
mediaIcon = 'video-camera';
} else {
media = (
<MediaGallery
standalone
<StatusGallery
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
height={300}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenMedia={this.props.onOpenMedia}
/>
);
mediaIcon = 'picture-o';
}
} else if (status.get('spoiler_text').length === 0) {
media = <CardContainer statusId={status.get('id')} />;
}
} else media = <CardContainer statusId={status.get('id')} />;
if (status.get('application')) {
applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>;
@@ -104,9 +109,11 @@ export default class DetailedStatus extends ImmutablePureComponent {
<DisplayName account={status.get('account')} />
</a>
<StatusContent status={status} />
{media}
<StatusContent
status={status}
media={media}
mediaIcon={mediaIcon}
/>
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
@@ -116,7 +123,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
<span className='detailed-status__favorites'>
<FormattedNumber value={status.get('favourites_count')} />
</span>
</Link>
</Link> · <VisibilityIcon visibility={status.get('visibility')} />
</div>
</div>
);

View File

@@ -25,7 +25,7 @@ import { initReport } from '../../actions/reports';
import { makeGetStatus } from '../../selectors';
import { ScrollContainer } from 'react-router-scroll-4';
import ColumnBackButton from '../../components/column_back_button';
import StatusContainer from '../../containers/status_container';
import StatusContainer from '../../../glitch/components/status/container';
import { openModal } from '../../actions/modal';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -43,6 +43,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
status: getStatus(state, props.params.statusId),
settings: state.get('local_settings'),
ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
});
@@ -62,6 +63,7 @@ export default class Status extends ImmutablePureComponent {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
status: ImmutablePropTypes.map,
settings: ImmutablePropTypes.map.isRequired,
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
@@ -253,8 +255,10 @@ export default class Status extends ImmutablePureComponent {
if (status && ancestorsIds && ancestorsIds.size > 0) {
const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
element.scrollIntoView(true);
this._scrolledIntoView = true;
if (element) {
element.scrollIntoView(true);
this._scrolledIntoView = true;
}
}
}
@@ -268,7 +272,7 @@ export default class Status extends ImmutablePureComponent {
render () {
let ancestors, descendants;
const { status, ancestorsIds, descendantsIds } = this.props;
const { status, settings, ancestorsIds, descendantsIds } = this.props;
const { fullscreen } = this.state;
if (status === null) {
@@ -310,6 +314,7 @@ export default class Status extends ImmutablePureComponent {
<div className='focusable' tabIndex='0'>
<DetailedStatus
status={status}
settings={settings}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
/>

View File

@@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Button from '../../../components/button';
import StatusContent from '../../../components/status_content';
import StatusContent from '../../../../glitch/components/status/content';
import Avatar from '../../../components/avatar';
import RelativeTimestamp from '../../../components/relative_timestamp';
import DisplayName from '../../../components/display_name';

View File

@@ -13,6 +13,7 @@ export default class Column extends React.PureComponent {
children: PropTypes.node,
active: PropTypes.bool,
hideHeadingOnMobile: PropTypes.bool,
name: PropTypes.string,
};
handleHeaderClick = () => {
@@ -47,7 +48,7 @@ export default class Column extends React.PureComponent {
}
render () {
const { heading, icon, children, active, hideHeadingOnMobile } = this.props;
const { heading, icon, children, active, hideHeadingOnMobile, name } = this.props;
const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
@@ -59,6 +60,7 @@ export default class Column extends React.PureComponent {
<div
ref={this.setRef}
role='region'
data-column={name}
aria-labelledby={columnHeaderId}
className='column'
onScroll={this.handleScroll}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
const ColumnLink = ({ icon, text, to, href, method }) => {
const ColumnLink = ({ icon, text, to, onClick, href, method }) => {
if (href) {
return (
<a href={href} className='column-link' data-method={method}>
@@ -10,13 +10,20 @@ const ColumnLink = ({ icon, text, to, href, method }) => {
{text}
</a>
);
} else {
} else if (to) {
return (
<Link to={to} className='column-link'>
<i className={`fa fa-fw fa-${icon} column-link__icon`} />
{text}
</Link>
);
} else {
return (
<a onClick={onClick} className='column-link' role='button' tabIndex='0' data-method={method}>
<i className={`fa fa-fw fa-${icon} column-link__icon`} />
{text}
</a>
);
}
};
@@ -24,9 +31,9 @@ ColumnLink.propTypes = {
icon: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
to: PropTypes.string,
onClick: PropTypes.func,
href: PropTypes.string,
method: PropTypes.string,
hideOnMobile: PropTypes.bool,
};
export default ColumnLink;

View File

@@ -11,7 +11,7 @@ import BundleContainer from '../containers/bundle_container';
import ColumnLoading from './column_loading';
import DrawerLoading from './drawer_loading';
import BundleColumnError from './bundle_column_error';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from '../../ui/util/async-components';
import detectPassiveEvents from 'detect-passive-events';
import { scrollRight } from '../../../scroll';
@@ -23,6 +23,7 @@ const componentMap = {
'PUBLIC': PublicTimeline,
'COMMUNITY': CommunityTimeline,
'HASHTAG': HashtagTimeline,
'DIRECT': DirectTimeline,
'FAVOURITES': FavouritedStatuses,
};

View File

@@ -0,0 +1,614 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../../../components/button';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Atrament from 'atrament'; // the doodling library
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { doodleSet, uploadCompose } from '../../../actions/compose';
import IconButton from '../../../components/icon_button';
import { debounce, mapValues } from 'lodash';
import classNames from 'classnames';
// palette nicked from MyPaint, CC0
const palette = [
['rgb( 0, 0, 0)', 'Black'],
['rgb( 38, 38, 38)', 'Gray 15'],
['rgb( 77, 77, 77)', 'Grey 30'],
['rgb(128, 128, 128)', 'Grey 50'],
['rgb(171, 171, 171)', 'Grey 67'],
['rgb(217, 217, 217)', 'Grey 85'],
['rgb(255, 255, 255)', 'White'],
['rgb(128, 0, 0)', 'Maroon'],
['rgb(209, 0, 0)', 'English-red'],
['rgb(255, 54, 34)', 'Tomato'],
['rgb(252, 60, 3)', 'Orange-red'],
['rgb(255, 140, 105)', 'Salmon'],
['rgb(252, 232, 32)', 'Cadium-yellow'],
['rgb(243, 253, 37)', 'Lemon yellow'],
['rgb(121, 5, 35)', 'Dark crimson'],
['rgb(169, 32, 62)', 'Deep carmine'],
['rgb(255, 140, 0)', 'Orange'],
['rgb(255, 168, 18)', 'Dark tangerine'],
['rgb(217, 144, 88)', 'Persian orange'],
['rgb(194, 178, 128)', 'Sand'],
['rgb(255, 229, 180)', 'Peach'],
['rgb(100, 54, 46)', 'Bole'],
['rgb(108, 41, 52)', 'Dark cordovan'],
['rgb(163, 65, 44)', 'Chestnut'],
['rgb(228, 136, 100)', 'Dark salmon'],
['rgb(255, 195, 143)', 'Apricot'],
['rgb(255, 219, 188)', 'Unbleached silk'],
['rgb(242, 227, 198)', 'Straw'],
['rgb( 53, 19, 13)', 'Bistre'],
['rgb( 84, 42, 14)', 'Dark chocolate'],
['rgb(102, 51, 43)', 'Burnt sienna'],
['rgb(184, 66, 0)', 'Sienna'],
['rgb(216, 153, 12)', 'Yellow ochre'],
['rgb(210, 180, 140)', 'Tan'],
['rgb(232, 204, 144)', 'Dark wheat'],
['rgb( 0, 49, 83)', 'Prussian blue'],
['rgb( 48, 69, 119)', 'Dark grey blue'],
['rgb( 0, 71, 171)', 'Cobalt blue'],
['rgb( 31, 117, 254)', 'Blue'],
['rgb(120, 180, 255)', 'Bright french blue'],
['rgb(171, 200, 255)', 'Bright steel blue'],
['rgb(208, 231, 255)', 'Ice blue'],
['rgb( 30, 51, 58)', 'Medium jungle green'],
['rgb( 47, 79, 79)', 'Dark slate grey'],
['rgb( 74, 104, 93)', 'Dark grullo green'],
['rgb( 0, 128, 128)', 'Teal'],
['rgb( 67, 170, 176)', 'Turquoise'],
['rgb(109, 174, 199)', 'Cerulean frost'],
['rgb(173, 217, 186)', 'Tiffany green'],
['rgb( 22, 34, 29)', 'Gray-asparagus'],
['rgb( 36, 48, 45)', 'Medium dark teal'],
['rgb( 74, 104, 93)', 'Xanadu'],
['rgb(119, 198, 121)', 'Mint'],
['rgb(175, 205, 182)', 'Timberwolf'],
['rgb(185, 245, 246)', 'Celeste'],
['rgb(193, 255, 234)', 'Aquamarine'],
['rgb( 29, 52, 35)', 'Cal Poly Pomona'],
['rgb( 1, 68, 33)', 'Forest green'],
['rgb( 42, 128, 0)', 'Napier green'],
['rgb(128, 128, 0)', 'Olive'],
['rgb( 65, 156, 105)', 'Sea green'],
['rgb(189, 246, 29)', 'Green-yellow'],
['rgb(231, 244, 134)', 'Bright chartreuse'],
['rgb(138, 23, 137)', 'Purple'],
['rgb( 78, 39, 138)', 'Violet'],
['rgb(193, 75, 110)', 'Dark thulian pink'],
['rgb(222, 49, 99)', 'Cerise'],
['rgb(255, 20, 147)', 'Deep pink'],
['rgb(255, 102, 204)', 'Rose pink'],
['rgb(255, 203, 219)', 'Pink'],
['rgb(255, 255, 255)', 'White'],
['rgb(229, 17, 1)', 'RGB Red'],
['rgb( 0, 255, 0)', 'RGB Green'],
['rgb( 0, 0, 255)', 'RGB Blue'],
['rgb( 0, 255, 255)', 'CMYK Cyan'],
['rgb(255, 0, 255)', 'CMYK Magenta'],
['rgb(255, 255, 0)', 'CMYK Yellow'],
];
// re-arrange to the right order for display
let palReordered = [];
for (let row = 0; row < 7; row++) {
for (let col = 0; col < 11; col++) {
palReordered.push(palette[col * 7 + row]);
}
palReordered.push(null); // null indicates a <br />
}
// Utility for converting base64 image to binary for upload
// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
function dataURLtoFile(dataurl, filename) {
let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
while(n--){
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
}
const DOODLE_SIZES = {
normal: [500, 500, 'Square 500'],
tootbanner: [702, 330, 'Tootbanner'],
s640x480: [640, 480, '640×480 - 480p'],
s800x600: [800, 600, '800×600 - SVGA'],
s720x480: [720, 405, '720x405 - 16:9'],
};
const mapStateToProps = state => ({
options: state.getIn(['compose', 'doodle']),
});
const mapDispatchToProps = dispatch => ({
/** Set options in the redux store */
setOpt: (opts) => dispatch(doodleSet(opts)),
/** Submit doodle for upload */
submit: (file) => dispatch(uploadCompose([file])),
});
/**
* Doodling dialog with drawing canvas
*
* Keyboard shortcuts:
* - Delete: Clear screen, fill with background color
* - Backspace, Ctrl+Z: Undo one step
* - Ctrl held while drawing: Use background color
* - Shift held while clicking screen: Use fill tool
*
* Palette:
* - Left mouse button: pick foreground
* - Ctrl + left mouse button: pick background
* - Right mouse button: pick background
*/
@connect(mapStateToProps, mapDispatchToProps)
export default class DoodleModal extends ImmutablePureComponent {
static propTypes = {
options: ImmutablePropTypes.map,
onClose: PropTypes.func.isRequired,
setOpt: PropTypes.func.isRequired,
submit: PropTypes.func.isRequired,
};
//region Option getters/setters
/** Foreground color */
get fg () {
return this.props.options.get('fg');
}
set fg (value) {
this.props.setOpt({ fg: value });
}
/** Background color */
get bg () {
return this.props.options.get('bg');
}
set bg (value) {
this.props.setOpt({ bg: value });
}
/** Swap Fg and Bg for drawing */
get swapped () {
return this.props.options.get('swapped');
}
set swapped (value) {
this.props.setOpt({ swapped: value });
}
/** Mode - 'draw' or 'fill' */
get mode () {
return this.props.options.get('mode');
}
set mode (value) {
this.props.setOpt({ mode: value });
}
/** Base line weight */
get weight () {
return this.props.options.get('weight');
}
set weight (value) {
this.props.setOpt({ weight: value });
}
/** Drawing opacity */
get opacity () {
return this.props.options.get('opacity');
}
set opacity (value) {
this.props.setOpt({ opacity: value });
}
/** Adaptive stroke - change width with speed */
get adaptiveStroke () {
return this.props.options.get('adaptiveStroke');
}
set adaptiveStroke (value) {
this.props.setOpt({ adaptiveStroke: value });
}
/** Smoothing (for mouse drawing) */
get smoothing () {
return this.props.options.get('smoothing');
}
set smoothing (value) {
this.props.setOpt({ smoothing: value });
}
/** Size preset */
get size () {
return this.props.options.get('size');
}
set size (value) {
this.props.setOpt({ size: value });
}
//endregion
/** Key up handler */
handleKeyUp = (e) => {
if (e.target.nodeName === 'INPUT') return;
if (e.key === 'Delete') {
e.preventDefault();
this.handleClearBtn();
return;
}
if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
this.undo();
}
if (e.key === 'Control' || e.key === 'Meta') {
this.controlHeld = false;
this.swapped = false;
}
if (e.key === 'Shift') {
this.shiftHeld = false;
this.mode = 'draw';
}
};
/** Key down handler */
handleKeyDown = (e) => {
if (e.key === 'Control' || e.key === 'Meta') {
this.controlHeld = true;
this.swapped = true;
}
if (e.key === 'Shift') {
this.shiftHeld = true;
this.mode = 'fill';
}
};
/**
* Component installed in the DOM, do some initial set-up
*/
componentDidMount () {
this.controlHeld = false;
this.shiftHeld = false;
this.swapped = false;
window.addEventListener('keyup', this.handleKeyUp, false);
window.addEventListener('keydown', this.handleKeyDown, false);
};
/**
* Tear component down
*/
componentWillUnmount () {
window.removeEventListener('keyup', this.handleKeyUp, false);
window.removeEventListener('keydown', this.handleKeyDown, false);
if (this.sketcher) this.sketcher.destroy();
}
/**
* Set reference to the canvas element.
* This is called during component init
*
* @param elem - canvas element
*/
setCanvasRef = (elem) => {
this.canvas = elem;
if (elem) {
elem.addEventListener('dirty', () => {
this.saveUndo();
this.sketcher._dirty = false;
});
elem.addEventListener('click', () => {
// sketcher bug - does not fire dirty on fill
if (this.mode === 'fill') {
this.saveUndo();
}
});
// prevent context menu
elem.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
elem.addEventListener('mousedown', (e) => {
if (e.button === 2) {
this.swapped = true;
}
});
elem.addEventListener('mouseup', (e) => {
if (e.button === 2) {
this.swapped = this.controlHeld;
}
});
this.initSketcher(elem);
this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
}
};
/**
* Set up the sketcher instance
*
* @param canvas - canvas element. Null if we're just resizing
*/
initSketcher (canvas = null) {
const sizepreset = DOODLE_SIZES[this.size];
if (this.sketcher) this.sketcher.destroy();
this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]);
if (canvas) {
this.ctx = this.sketcher.context;
this.updateSketcherSettings();
}
this.clearScreen();
}
/**
* Done button handler
*/
onDoneButton = () => {
const dataUrl = this.sketcher.toImage();
const file = dataURLtoFile(dataUrl, 'doodle.png');
this.props.submit(file);
this.props.onClose(); // close dialog
};
/**
* Cancel button handler
*/
onCancelButton = () => {
if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) {
return;
}
this.props.onClose(); // close dialog
};
/**
* Update sketcher options based on state
*/
updateSketcherSettings () {
if (!this.sketcher) return;
if (this.oldSize !== this.size) this.initSketcher();
this.sketcher.color = (this.swapped ? this.bg : this.fg);
this.sketcher.opacity = this.opacity;
this.sketcher.weight = this.weight;
this.sketcher.mode = this.mode;
this.sketcher.smoothing = this.smoothing;
this.sketcher.adaptiveStroke = this.adaptiveStroke;
this.oldSize = this.size;
}
/**
* Fill screen with background color
*/
clearScreen = () => {
this.ctx.fillStyle = this.bg;
this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2);
this.undos = [];
this.doSaveUndo();
};
/**
* Undo one step
*/
undo = () => {
if (this.undos.length > 1) {
this.undos.pop();
const buf = this.undos.pop();
this.sketcher.clear();
this.ctx.putImageData(buf, 0, 0);
this.doSaveUndo();
}
};
/**
* Save canvas content into the undo buffer immediately
*/
doSaveUndo = () => {
this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
};
/**
* Called on each canvas change.
* Saves canvas content to the undo buffer after some period of inactivity.
*/
saveUndo = debounce(() => {
this.doSaveUndo();
}, 100);
/**
* Palette left click.
* Selects Fg color (or Bg, if Control/Meta is held)
*
* @param e - event
*/
onPaletteClick = (e) => {
const c = e.target.dataset.color;
if (this.controlHeld) {
this.bg = c;
} else {
this.fg = c;
}
e.target.blur();
e.preventDefault();
};
/**
* Palette right click.
* Selects Bg color
*
* @param e - event
*/
onPaletteRClick = (e) => {
this.bg = e.target.dataset.color;
e.target.blur();
e.preventDefault();
};
/**
* Handle click on the Draw mode button
*
* @param e - event
*/
setModeDraw = (e) => {
this.mode = 'draw';
e.target.blur();
};
/**
* Handle click on the Fill mode button
*
* @param e - event
*/
setModeFill = (e) => {
this.mode = 'fill';
e.target.blur();
};
/**
* Handle click on Smooth checkbox
*
* @param e - event
*/
tglSmooth = (e) => {
this.smoothing = !this.smoothing;
e.target.blur();
};
/**
* Handle click on Adaptive checkbox
*
* @param e - event
*/
tglAdaptive = (e) => {
this.adaptiveStroke = !this.adaptiveStroke;
e.target.blur();
};
/**
* Handle change of the Weight input field
*
* @param e - event
*/
setWeight = (e) => {
this.weight = +e.target.value || 1;
};
/**
* Set size - clalback from the select box
*
* @param e - event
*/
changeSize = (e) => {
let newSize = e.target.value;
if (newSize === this.oldSize) return;
if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) {
return;
}
this.size = newSize;
};
handleClearBtn = () => {
if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) {
return;
}
this.clearScreen();
};
/**
* Render the component
*/
render () {
this.updateSketcherSettings();
return (
<div className='modal-root__modal doodle-modal'>
<div className='doodle-modal__container'>
<canvas ref={this.setCanvasRef} />
</div>
<div className='doodle-modal__action-bar'>
<div className='doodle-toolbar'>
<Button text='Done' onClick={this.onDoneButton} />
<Button text='Cancel' onClick={this.onCancelButton} />
</div>
<div className='filler' />
<div className='doodle-toolbar with-inputs'>
<div>
<label htmlFor='dd_smoothing'>Smoothing</label>
<span className='val'>
<input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} />
</span>
</div>
<div>
<label htmlFor='dd_adaptive'>Adaptive</label>
<span className='val'>
<input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} />
</span>
</div>
<div>
<label htmlFor='dd_weight'>Weight</label>
<span className='val'>
<input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} />
</span>
</div>
<div>
<select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}>
{ Object.values(mapValues(DOODLE_SIZES, (val, k) =>
<option key={k} value={k}>{val[2]}</option>
)) }
</select>
</div>
</div>
<div className='doodle-toolbar'>
<IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted />
<IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted />
<IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted />
<IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted />
</div>
<div className='doodle-palette'>
{
palReordered.map((c, i) =>
c === null ?
<br key={i} /> :
<button
key={i}
style={{ backgroundColor: c[0] }}
onClick={this.onPaletteClick}
onContextMenu={this.onPaletteRClick}
data-color={c[0]}
title={c[1]}
className={classNames({
'foreground': this.fg === c[0],
'background': this.bg === c[0],
})}
/>
)
}
</div>
</div>
</div>
);
}
}

View File

@@ -7,11 +7,13 @@ import ActionsModal from './actions_modal';
import MediaModal from './media_modal';
import VideoModal from './video_modal';
import BoostModal from './boost_modal';
import DoodleModal from './doodle_modal';
import ConfirmationModal from './confirmation_modal';
import {
OnboardingModal,
MuteModal,
ReportModal,
SettingsModal,
EmbedModal,
} from '../../../features/ui/util/async-components';
@@ -20,9 +22,11 @@ const MODAL_COMPONENTS = {
'ONBOARDING': OnboardingModal,
'VIDEO': () => Promise.resolve({ default: VideoModal }),
'BOOST': () => Promise.resolve({ default: BoostModal }),
'DOODLE': () => Promise.resolve({ default: DoodleModal }),
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
'MUTE': MuteModal,
'REPORT': ReportModal,
'SETTINGS': SettingsModal,
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
};
@@ -41,7 +45,7 @@ export default class ModalRoot extends React.PureComponent {
handleKeyUp = (e) => {
if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
&& !!this.props.type) {
&& !!this.props.type && !this.props.props.noEsc) {
this.props.onClose();
}
}
@@ -86,7 +90,7 @@ export default class ModalRoot extends React.PureComponent {
}
renderLoading = modalId => () => {
return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
return ['MEDIA', 'VIDEO', 'BOOST', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
}
renderError = (props) => {

View File

@@ -10,7 +10,10 @@ import ComposeForm from '../../compose/components/compose_form';
import Search from '../../compose/components/search';
import NavigationBar from '../../compose/components/navigation_bar';
import ColumnHeader from './column_header';
import { List as ImmutableList } from 'immutable';
import {
List as ImmutableList,
Map as ImmutableMap,
} from 'immutable';
import { me } from '../../../initial_state';
const noop = () => { };
@@ -29,8 +32,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>
@@ -45,7 +48,7 @@ const PageTwo = ({ myAccount }) => (
<div className='onboarding-modal__page onboarding-modal__page-two'>
<div className='figure non-interactive'>
<div className='pseudo-drawer'>
<NavigationBar account={myAccount} />
<NavigationBar onClose={noop} account={myAccount} />
</div>
<ComposeForm
text='Awoo! #introductions'
@@ -60,7 +63,9 @@ const PageTwo = ({ myAccount }) => (
onClearSuggestions={noop}
onFetchSuggestions={noop}
onSuggestionSelected={noop}
onPrivacyChange={noop}
showSearch
settings={ImmutableMap.of('side_arm', 'none')}
/>
</div>
@@ -84,7 +89,7 @@ const PageThree = ({ myAccount }) => (
/>
<div className='pseudo-drawer'>
<NavigationBar account={myAccount} />
<NavigationBar onClose={noop} account={myAccount} />
</div>
</div>
@@ -149,8 +154,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>
);

View File

@@ -15,6 +15,7 @@ import { clearHeight } from '../../actions/height_cache';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container';
import classNames from 'classnames';
import {
Compose,
Status,
@@ -28,6 +29,7 @@ import {
Following,
Reblogs,
Favourites,
DirectTimeline,
HashtagTimeline,
Notifications,
FollowRequests,
@@ -43,7 +45,7 @@ import { defineMessages, injectIntl } from 'react-intl';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
import '../../components/status';
import '../../../glitch/components/status';
const messages = defineMessages({
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
@@ -72,6 +74,7 @@ const keyMap = {
goToNotifications: 'g n',
goToLocal: 'g l',
goToFederated: 'g t',
goToDirect: 'g d',
goToStart: 'g s',
goToFavourites: 'g f',
goToPinned: 'g p',
@@ -92,6 +95,10 @@ export default class UI extends React.Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
children: PropTypes.node,
layout: PropTypes.string,
isWide: PropTypes.bool,
systemFontUi: PropTypes.bool,
navbarUnder: PropTypes.bool,
isComposing: PropTypes.bool,
hasComposingText: PropTypes.bool,
location: PropTypes.object,
@@ -214,6 +221,7 @@ export default class UI extends React.Component {
if (nextProps.isComposing !== this.props.isComposing) {
// Avoid expensive update just to toggle a class
this.node.classList.toggle('is-composing', nextProps.isComposing);
this.node.classList.toggle('navbar-under', nextProps.navbarUnder);
return false;
}
@@ -313,6 +321,10 @@ export default class UI extends React.Component {
this.context.router.history.push('/timelines/public');
}
handleHotkeyGoToDirect = () => {
this.context.router.history.push('/timelines/direct');
}
handleHotkeyGoToStart = () => {
this.context.router.history.push('/getting-started');
}
@@ -339,7 +351,24 @@ export default class UI extends React.Component {
render () {
const { width, draggingOver } = this.state;
const { children } = this.props;
const { children, layout, isWide, navbarUnder } = this.props;
const columnsClass = layout => {
switch (layout) {
case 'single':
return 'single-column';
case 'multiple':
return 'multi-columns';
default:
return 'auto-columns';
}
};
const className = classNames('ui', columnsClass(layout), {
'wide': isWide,
'system-font': this.props.systemFontUi,
'navbar-under': navbarUnder,
});
const handlers = {
new: this.handleHotkeyNew,
@@ -351,6 +380,7 @@ export default class UI extends React.Component {
goToNotifications: this.handleHotkeyGoToNotifications,
goToLocal: this.handleHotkeyGoToLocal,
goToFederated: this.handleHotkeyGoToFederated,
goToDirect: this.handleHotkeyGoToDirect,
goToStart: this.handleHotkeyGoToStart,
goToFavourites: this.handleHotkeyGoToFavourites,
goToPinned: this.handleHotkeyGoToPinned,
@@ -361,16 +391,17 @@ export default class UI extends React.Component {
return (
<HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
<div className='ui' ref={this.setRef}>
<TabsBar />
<div className={className} ref={this.setRef}>
{navbarUnder ? null : (<TabsBar />)}
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width)}>
<ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
<WrappedSwitch>
<Redirect from='/' to='/getting-started' exact />
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
<WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
<WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} />
@@ -396,6 +427,7 @@ export default class UI extends React.Component {
</ColumnsAreaContainer>
<NotificationsContainer />
{navbarUnder ? (<TabsBar />) : null}
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />

View File

@@ -26,6 +26,10 @@ export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
}
export function DirectTimeline() {
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
}
export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status');
}
@@ -94,6 +98,13 @@ export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
}
export function SettingsModal () {
return import(/* webpackChunkName: "modals/settings_modal" */'glitch/components/local_settings/container');
}
// THESE AREN'T USED BY US; SEE `glitch/components/status` AND `mastodon/features/status`. //
// IF MASTODON EVER CHANGES DETAILED STATUSES TO REQUIRE THEM, WE'LL NEED TO UPDATE THE URLS OR SOMETHING LOL. //
export function MediaGallery () {
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
}

View File

@@ -1,5 +1,13 @@
const element = document.getElementById('initial-state');
const initialState = element && JSON.parse(element.textContent);
const initialState = element && function () {
const result = JSON.parse(element.textContent);
try {
result.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
} catch (e) {
result.local_settings = {};
}
return result;
}();
const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];

View File

@@ -2,8 +2,15 @@ import detectPassiveEvents from 'detect-passive-events';
const LAYOUT_BREAKPOINT = 630;
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;

View File

@@ -755,6 +755,19 @@
],
"path": "app/javascript/mastodon/features/compose/index.json"
},
{
"descriptors": [
{
"defaultMessage": "Direct messages",
"id": "column.direct"
},
{
"defaultMessage": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
"id": "empty_column.direct"
}
],
"path": "app/javascript/mastodon/features/direct_timeline/index.json"
},
{
"descriptors": [
{
@@ -816,6 +829,10 @@
"defaultMessage": "Local timeline",
"id": "navigation_bar.community_timeline"
},
{
"defaultMessage": "Direct messages",
"id": "navigation_bar.direct"
},
{
"defaultMessage": "Preferences",
"id": "navigation_bar.preferences"

View File

@@ -28,6 +28,7 @@
"bundle_modal_error.retry": "Try again",
"column.blocks": "Blocked users",
"column.community": "Local timeline",
"column.direct": "Direct messages",
"column.favourites": "Favourites",
"column.follow_requests": "Follow requests",
"column.home": "Home",
@@ -80,6 +81,7 @@
"emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places",
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
"empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
"empty_column.home.public_timeline": "the public timeline",
@@ -106,6 +108,7 @@
"missing_indicator.label": "Not found",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.community_timeline": "Local timeline",
"navigation_bar.direct": "Direct messages",
"navigation_bar.edit_profile": "Edit profile",
"navigation_bar.favourites": "Favourites",
"navigation_bar.follow_requests": "Follow requests",

View File

@@ -28,6 +28,11 @@ function main() {
WebPushSubscription.register();
}
perf.stop('main()');
// remember the initial URL
if (window.history && typeof window._mastoInitialHistoryLen === 'undefined') {
window._mastoInitialHistoryLen = window.history.length;
}
});
}

View File

@@ -126,6 +126,7 @@ export default function accountsCounters(state = initialState, action) {
case STATUS_FETCH_SUCCESS:
return normalizeAccountFromStatus(state, action.status);
case ACCOUNT_FOLLOW_SUCCESS:
if (action.alreadyFollowing) { return state; }
return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
case ACCOUNT_UNFOLLOW_SUCCESS:
return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));

View File

@@ -16,6 +16,7 @@ import {
COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT,
COMPOSE_ADVANCED_OPTIONS_CHANGE,
COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
@@ -25,6 +26,7 @@ import {
COMPOSE_UPLOAD_CHANGE_REQUEST,
COMPOSE_UPLOAD_CHANGE_SUCCESS,
COMPOSE_UPLOAD_CHANGE_FAIL,
COMPOSE_DOODLE_SET,
COMPOSE_RESET,
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
@@ -35,6 +37,9 @@ import { me } from '../initial_state';
const initialState = ImmutableMap({
mounted: false,
advanced_options: ImmutableMap({
do_not_federate: false,
}),
sensitive: false,
spoiler: false,
spoiler_text: '',
@@ -50,10 +55,24 @@ const initialState = ImmutableMap({
media_attachments: ImmutableList(),
suggestion_token: null,
suggestions: ImmutableList(),
default_advanced_options: ImmutableMap({
do_not_federate: false,
}),
default_privacy: 'public',
default_sensitive: false,
resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null,
doodle: ImmutableMap({
fg: 'rgb( 0, 0, 0)',
bg: 'rgb(255, 255, 255)',
swapped: false,
mode: 'draw',
size: 'normal',
weight: 2,
opacity: 1,
adaptiveStroke: true,
smoothing: false,
}),
});
function statusToTextMentions(state, status) {
@@ -73,6 +92,7 @@ function clearAll(state) {
map.set('spoiler_text', '');
map.set('is_submitting', false);
map.set('in_reply_to', null);
map.set('advanced_options', state.get('default_advanced_options'));
map.set('privacy', state.get('default_privacy'));
map.set('sensitive', false);
map.update('media_attachments', list => list.clear());
@@ -114,7 +134,7 @@ function removeMedia(state, mediaId) {
const insertSuggestion = (state, position, token, completion) => {
return state.withMutations(map => {
map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
map.update('text', oldText => `${oldText.slice(0, position)}${completion}\u200B${oldText.slice(position + token.length)}`);
map.set('suggestion_token', null);
map.update('suggestions', ImmutableList(), list => list.clear());
map.set('focusDate', new Date());
@@ -126,7 +146,7 @@ const insertEmoji = (state, position, emojiData) => {
const emoji = emojiData.native;
return state.withMutations(map => {
map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`);
map.set('focusDate', new Date());
map.set('idempotencyKey', uuid());
});
@@ -164,6 +184,11 @@ export default function compose(state = initialState, action) {
return state
.set('mounted', false)
.set('is_composing', false);
case COMPOSE_ADVANCED_OPTIONS_CHANGE:
return state
.set('advanced_options',
state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option])))
.set('idempotencyKey', uuid());
case COMPOSE_SENSITIVITY_CHANGE:
return state.withMutations(map => {
if (!state.get('spoiler')) {
@@ -201,6 +226,9 @@ export default function compose(state = initialState, action) {
map.set('in_reply_to', action.status.get('id'));
map.set('text', statusToTextMentions(state, action.status));
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('advanced_options', new ImmutableMap({
do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')),
}));
map.set('focusDate', new Date());
map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid());
@@ -221,6 +249,7 @@ export default function compose(state = initialState, action) {
map.set('spoiler', false);
map.set('spoiler_text', '');
map.set('privacy', state.get('default_privacy'));
map.set('advanced_options', state.get('default_advanced_options'));
map.set('idempotencyKey', uuid());
});
case COMPOSE_SUBMIT_REQUEST:
@@ -270,6 +299,8 @@ export default function compose(state = initialState, action) {
return item;
}));
case COMPOSE_DOODLE_SET:
return state.mergeIn(['doodle'], action.options);
default:
return state;
}

View File

@@ -10,6 +10,7 @@ import accounts_counters from './accounts_counters';
import statuses from './statuses';
import relationships from './relationships';
import settings from './settings';
import local_settings from '../../glitch/reducers/local_settings';
import push_notifications from './push_notifications';
import status_lists from './status_lists';
import cards from './cards';
@@ -36,6 +37,7 @@ const reducers = {
statuses,
relationships,
settings,
local_settings,
push_notifications,
cards,
mutes,

View File

@@ -8,6 +8,12 @@ import {
NOTIFICATIONS_EXPAND_FAIL,
NOTIFICATIONS_CLEAR,
NOTIFICATIONS_SCROLL_TOP,
NOTIFICATIONS_DELETE_MARKED_REQUEST,
NOTIFICATIONS_DELETE_MARKED_SUCCESS,
NOTIFICATION_MARK_FOR_DELETE,
NOTIFICATIONS_DELETE_MARKED_FAIL,
NOTIFICATIONS_ENTER_CLEARING_MODE,
NOTIFICATIONS_MARK_ALL_FOR_DELETE,
} from '../actions/notifications';
import {
ACCOUNT_BLOCK_SUCCESS,
@@ -23,12 +29,16 @@ const initialState = ImmutableMap({
unread: 0,
loaded: false,
isLoading: true,
cleaningMode: false,
// notification removal mark of new notifs loaded whilst cleaningMode is true.
markNewForDelete: false,
});
const notificationToMap = notification => ImmutableMap({
const notificationToMap = (state, notification) => ImmutableMap({
id: notification.id,
type: notification.type,
account: notification.account.id,
markedForDelete: state.get('markNewForDelete'),
status: notification.status ? notification.status.id : null,
});
@@ -44,7 +54,7 @@ const normalizeNotification = (state, notification) => {
list = list.take(20);
}
return list.unshift(notificationToMap(notification));
return list.unshift(notificationToMap(state, notification));
});
};
@@ -53,7 +63,7 @@ const normalizeNotifications = (state, notifications, next) => {
const loaded = state.get('loaded');
notifications.forEach((n, i) => {
items = items.set(i, notificationToMap(n));
items = items.set(i, notificationToMap(state, n));
});
if (state.get('next') === null) {
@@ -70,7 +80,7 @@ const appendNormalizedNotifications = (state, notifications, next) => {
let items = ImmutableList();
notifications.forEach((n, i) => {
items = items.set(i, notificationToMap(n));
items = items.set(i, notificationToMap(state, n));
});
return state
@@ -95,11 +105,43 @@ const deleteByStatus = (state, statusId) => {
return state.update('items', list => list.filterNot(item => item.get('status') === statusId));
};
const markForDelete = (state, notificationId, yes) => {
return state.update('items', list => list.map(item => {
if(item.get('id') === notificationId) {
return item.set('markedForDelete', yes);
} else {
return item;
}
}));
};
const markAllForDelete = (state, yes) => {
return state.update('items', list => list.map(item => {
if(yes !== null) {
return item.set('markedForDelete', yes);
} else {
return item.set('markedForDelete', !item.get('markedForDelete'));
}
}));
};
const unmarkAllForDelete = (state) => {
return state.update('items', list => list.map(item => item.set('markedForDelete', false)));
};
const deleteMarkedNotifs = (state) => {
return state.update('items', list => list.filterNot(item => item.get('markedForDelete')));
};
export default function notifications(state = initialState, action) {
let st;
switch(action.type) {
case NOTIFICATIONS_REFRESH_REQUEST:
case NOTIFICATIONS_EXPAND_REQUEST:
case NOTIFICATIONS_DELETE_MARKED_REQUEST:
return state.set('isLoading', true);
case NOTIFICATIONS_DELETE_MARKED_FAIL:
case NOTIFICATIONS_REFRESH_FAIL:
case NOTIFICATIONS_EXPAND_FAIL:
return state.set('isLoading', false);
@@ -118,6 +160,31 @@ export default function notifications(state = initialState, action) {
return state.set('items', ImmutableList()).set('next', null);
case TIMELINE_DELETE:
return deleteByStatus(state, action.id);
case NOTIFICATION_MARK_FOR_DELETE:
return markForDelete(state, action.id, action.yes);
case NOTIFICATIONS_DELETE_MARKED_SUCCESS:
return deleteMarkedNotifs(state).set('isLoading', false);
case NOTIFICATIONS_ENTER_CLEARING_MODE:
st = state.set('cleaningMode', action.yes);
if (!action.yes) {
return unmarkAllForDelete(st).set('markNewForDelete', false);
} else {
return st;
}
case NOTIFICATIONS_MARK_ALL_FOR_DELETE:
st = state;
if (action.yes === null) {
// Toggle - this is a bit confusing, as it toggles the all-none mode
//st = st.set('markNewForDelete', !st.get('markNewForDelete'));
} else {
st = st.set('markNewForDelete', action.yes);
}
return markAllForDelete(st, action.yes);
default:
return state;
}

View File

@@ -9,6 +9,7 @@ const initialState = ImmutableMap({
saved: true,
onboarded: false,
layout: 'auto',
skinTone: 1,
@@ -57,6 +58,12 @@ const initialState = ImmutableMap({
body: '',
}),
}),
direct: ImmutableMap({
regex: ImmutableMap({
body: '',
}),
}),
});
const defaultColumns = fromJS([