1
0
mirror of https://github.com/funamitech/mastodon synced 2025-01-20 00:33:26 +09:00
This commit is contained in:
Noa Himesaka 2023-12-14 21:51:07 +09:00
commit b78f7f0c46
211 changed files with 2722 additions and 2034 deletions

View File

@ -70,7 +70,7 @@ services:
hard: -1
libretranslate:
image: libretranslate/libretranslate:v1.3.12
image: libretranslate/libretranslate:v1.4.0
restart: unless-stopped
volumes:
- lt-data:/home/libretranslate/.local

View File

@ -16,6 +16,5 @@ linters:
exclude:
- 'app/views/admin/accounts/_buttons.html.haml'
- 'app/views/admin/accounts/_local_account.html.haml'
- 'app/views/admin/accounts/index.html.haml'
- 'app/views/admin/roles/_form.html.haml'
- 'app/views/layouts/application.html.haml'

View File

@ -154,6 +154,7 @@ GEM
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
base64 (0.1.1)
bcp47_spec (0.2.1)
bcrypt (3.1.19)
better_errors (2.10.1)
erubi (>= 1.0.0)
@ -375,19 +376,19 @@ GEM
reline (>= 0.3.8)
jmespath (1.6.2)
json (2.6.3)
json-canonicalization (0.3.2)
json-canonicalization (1.0.0)
json-jwt (1.15.3)
activesupport (>= 4.2)
aes_key_wrap
bindata
httpclient
json-ld (3.2.5)
json-ld (3.3.1)
htmlentities (~> 4.3)
json-canonicalization (~> 0.3, >= 0.3.2)
json-canonicalization (~> 1.0)
link_header (~> 0.0, >= 0.0.8)
multi_json (~> 1.15)
rack (>= 2.2, < 4)
rdf (~> 3.2, >= 3.2.10)
rdf (~> 3.3)
json-ld-preloaded (3.2.2)
json-ld (~> 3.2)
rdf (~> 3.2)
@ -532,7 +533,7 @@ GEM
public_suffix (5.0.3)
puma (6.4.0)
nio4r (~> 2.0)
pundit (2.3.0)
pundit (2.3.1)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.7.1)
@ -596,7 +597,8 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.0.6)
rdf (3.2.11)
rdf (3.3.1)
bcp47_spec (~> 0.2)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.6.1)
rdf (~> 3.2)

View File

@ -21,7 +21,7 @@ module Admin
account_action.save!
if account_action.with_report?
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: params[:report_id])
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
else
redirect_to admin_account_path(@account.id)
end

View File

@ -20,7 +20,7 @@ class Admin::Disputes::AppealsController < Admin::BaseController
authorize @appeal, :approve?
log_action :reject, @appeal
@appeal.reject!(current_account)
UserMailer.appeal_rejected(@appeal.account.user, @appeal)
UserMailer.appeal_rejected(@appeal.account.user, @appeal).deliver_later
redirect_to disputes_strike_path(@appeal.strike)
end

View File

@ -176,7 +176,10 @@ class ApplicationController < ActionController::Base
return unless self_destruct?
respond_to do |format|
format.any { render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html] }
format.any do
use_pack 'error'
render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html]
end
format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: code }
end
end

View File

@ -40,6 +40,12 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
show
end
def redirect_to_app?
truthy_param?(:redirect_to_app)
end
helper_method :redirect_to_app?
private
def require_captcha_if_needed!
@ -87,7 +93,7 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
end
def after_confirmation_path_for(_resource_name, user)
if user.created_by_application && truthy_param?(:redirect_to_app)
if user.created_by_application && redirect_to_app?
user.created_by_application.confirmation_redirect_uri
elsif user_signed_in?
web_url('start')

View File

@ -1,69 +0,0 @@
import api from '../api';
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL';
export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
export function submitAccountNote() {
return (dispatch, getState) => {
dispatch(submitAccountNoteRequest());
const id = getState().getIn(['account_notes', 'edit', 'account_id']);
api(getState).post(`/api/v1/accounts/${id}/note`, {
comment: getState().getIn(['account_notes', 'edit', 'comment']),
}).then(response => {
dispatch(submitAccountNoteSuccess(response.data));
}).catch(error => dispatch(submitAccountNoteFail(error)));
};
}
export function submitAccountNoteRequest() {
return {
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
};
}
export function submitAccountNoteSuccess(relationship) {
return {
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
relationship,
};
}
export function submitAccountNoteFail(error) {
return {
type: ACCOUNT_NOTE_SUBMIT_FAIL,
error,
};
}
export function initEditAccountNote(account) {
return (dispatch, getState) => {
const comment = getState().getIn(['relationships', account.get('id'), 'note']);
dispatch({
type: ACCOUNT_NOTE_INIT_EDIT,
account,
comment,
});
};
}
export function cancelAccountNote() {
return {
type: ACCOUNT_NOTE_CANCEL,
};
}
export function changeAccountNoteComment(comment) {
return {
type: ACCOUNT_NOTE_CHANGE_COMMENT,
comment,
};
}

View File

@ -0,0 +1,18 @@
import { createAppAsyncThunk } from 'flavours/glitch/store/typed_functions';
import api from '../api';
export const submitAccountNote = createAppAsyncThunk(
'account_note/submit',
async (args: { id: string; value: string }, { getState }) => {
// TODO: replace `unknown` with `ApiRelationshipJSON` when it is merged
const response = await api(getState).post<unknown>(
`/api/v1/accounts/${args.id}/note`,
{
comment: args.value,
},
);
return { relationship: response.data };
},
);

View File

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

View File

@ -84,10 +84,14 @@ export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTIO
export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS';
export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
export const COMPOSE_FOCUS = 'COMPOSE_FOCUS';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
});
export const ensureComposeIsVisible = (getState, routerHistory) => {
@ -144,6 +148,15 @@ export function resetCompose() {
};
}
export const focusCompose = (routerHistory, defaultText) => dispatch => {
dispatch({
type: COMPOSE_FOCUS,
defaultText,
});
ensureComposeIsVisible(routerHistory);
};
export function mentionCompose(account, routerHistory) {
return (dispatch, getState) => {
dispatch({
@ -264,6 +277,13 @@ export function submitCompose(routerHistory) {
} else if (statusId === null && response.data.visibility === 'direct') {
insertIfOnline('direct');
}
dispatch(showAlert({
message: statusId === null ? messages.published : messages.saved,
action: messages.open,
dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),
}));
}).catch(function (error) {
dispatch(submitComposeFail(error));
});
@ -300,18 +320,19 @@ export function doodleSet(options) {
export function uploadCompose(files) {
return function (dispatch, getState) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
if (files.length + media.size + pending > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
dispatch(showAlert({ message: messages.uploadErrorLimit }));
return;
}
if (getState().getIn(['compose', 'poll'])) {
dispatch(showAlert(undefined, messages.uploadErrorPoll));
dispatch(showAlert({ message: messages.uploadErrorPoll }));
return;
}

View File

@ -1,16 +1,8 @@
import { openModal } from './modal';
import { changeSetting, saveSettings } from './settings';
export function showOnboardingOnce() {
return (dispatch, getState) => {
const alreadySeen = getState().getIn(['settings', 'onboarded']);
export const INTRODUCTION_VERSION = 20181216044202;
if (!alreadySeen) {
dispatch(openModal({
modalType: 'ONBOARDING',
}));
dispatch(changeSetting(['onboarded'], true));
dispatch(saveSettings());
}
};
}
export const closeOnboarding = () => dispatch => {
dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
dispatch(saveSettings());
};

View File

@ -1,75 +0,0 @@
// @ts-check
import axios from 'axios';
import LinkHeader from 'http-link-header';
import ready from './ready';
/**
* @param {import('axios').AxiosResponse} response
* @returns {LinkHeader}
*/
export const getLinks = response => {
const value = response.headers.link;
if (!value) {
return new LinkHeader();
}
return LinkHeader.parse(value);
};
/** @type {import('axios').RawAxiosRequestHeaders} */
const csrfHeader = {};
/**
* @returns {void}
*/
const setCSRFHeader = () => {
/** @type {HTMLMetaElement | null} */
const csrfToken = document.querySelector('meta[name=csrf-token]');
if (csrfToken) {
csrfHeader['X-CSRF-Token'] = csrfToken.content;
}
};
ready(setCSRFHeader);
/**
* @param {() => import('immutable').Map<string,any>} getState
* @returns {import('axios').RawAxiosRequestHeaders}
*/
const authorizationHeaderFromState = getState => {
const accessToken = getState && getState().getIn(['meta', 'access_token'], '');
if (!accessToken) {
return {};
}
return {
'Authorization': `Bearer ${accessToken}`,
};
};
/**
* @param {() => import('immutable').Map<string,any>} getState
* @returns {import('axios').AxiosInstance}
*/
export default function api(getState) {
return axios.create({
headers: {
...csrfHeader,
...authorizationHeaderFromState(getState),
},
transformResponse: [
function (data) {
try {
return JSON.parse(data);
} catch {
return data;
}
},
],
});
}

View File

@ -0,0 +1,63 @@
import type { AxiosResponse, RawAxiosRequestHeaders } from 'axios';
import axios from 'axios';
import LinkHeader from 'http-link-header';
import ready from './ready';
import type { GetState } from './store';
export const getLinks = (response: AxiosResponse) => {
const value = response.headers.link as string | undefined;
if (!value) {
return new LinkHeader();
}
return LinkHeader.parse(value);
};
const csrfHeader: RawAxiosRequestHeaders = {};
const setCSRFHeader = () => {
const csrfToken = document.querySelector<HTMLMetaElement>(
'meta[name=csrf-token]',
);
if (csrfToken) {
csrfHeader['X-CSRF-Token'] = csrfToken.content;
}
};
void ready(setCSRFHeader);
const authorizationHeaderFromState = (getState?: GetState) => {
const accessToken =
getState && (getState().meta.get('access_token', '') as string);
if (!accessToken) {
return {};
}
return {
Authorization: `Bearer ${accessToken}`,
} as RawAxiosRequestHeaders;
};
// eslint-disable-next-line import/no-default-export
export default function api(getState: GetState) {
return axios.create({
headers: {
...csrfHeader,
...authorizationHeaderFromState(getState),
},
transformResponse: [
function (data: unknown) {
try {
return JSON.parse(data as string) as unknown;
} catch {
return data;
}
},
],
});
}

View File

@ -1,30 +1,35 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import { EmptyAccount } from 'flavours/glitch/components/empty_account';
import { ShortNumber } from 'flavours/glitch/components/short_number';
import { VerifiedBadge } from 'flavours/glitch/components/verified_badge';
import { me } from '../initial_state';
import { Avatar } from './avatar';
import { Button } from './button';
import { FollowersCounter } from './counters';
import { DisplayName } from './display_name';
import { IconButton } from './icon_button';
import Permalink from './permalink';
import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
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: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' },
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
block: { id: 'account.block_short', defaultMessage: 'Block' },
});
class Account extends ImmutablePureComponent {
@ -38,14 +43,13 @@ class Account extends ImmutablePureComponent {
onMuteNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
actionIcon: PropTypes.string,
actionTitle: PropTypes.string,
minimal: PropTypes.bool,
defaultAction: PropTypes.string,
onActionClick: PropTypes.func,
withBio: PropTypes.bool,
};
static defaultProps = {
size: 36,
size: 46,
};
handleFollow = () => {
@ -68,24 +72,11 @@ class Account extends ImmutablePureComponent {
this.props.onMuteNotifications(this.props.account, false);
};
handleAction = () => {
this.props.onActionClick(this.props.account);
};
render () {
const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size } = this.props;
const { account, intl, hidden, withBio, defaultAction, size, minimal } = this.props;
if (!account) {
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Skeleton width={36} height={36} /></div>
<DisplayName />
</div>
</div>
</div>
);
return <EmptyAccount size={size} minimal={minimal} />;
}
if (hidden) {
@ -99,61 +90,89 @@ class Account extends ImmutablePureComponent {
let buttons;
if (onActionClick) {
if (actionIcon) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
}
} else if (account.get('id') !== me && account.get('relationship', null) !== null) {
if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={this.handleFollow} />;
} else if (blocking) {
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
} else if (muting) {
let hidingNotificationsButton;
if (account.getIn(['relationship', 'muting_notifications'])) {
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />;
hidingNotificationsButton = <Button text={intl.formatMessage(messages.unmute_notifications)} onClick={this.handleUnmuteNotifications} />;
} else {
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />;
hidingNotificationsButton = <Button text={intl.formatMessage(messages.mute_notifications)} onClick={this.handleMuteNotifications} />;
}
buttons = (
<>
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
<Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />
{hidingNotificationsButton}
</>
);
} else if (defaultAction === 'mute') {
buttons = <IconButton icon='volume-off' title={intl.formatMessage(messages.mute, { name: account.get('username') })} onClick={this.handleMute} />;
buttons = <Button title={intl.formatMessage(messages.mute)} onClick={this.handleMute} />;
} else if (defaultAction === 'block') {
buttons = <IconButton icon='lock' title={intl.formatMessage(messages.block, { name: account.get('username') })} onClick={this.handleBlock} />;
buttons = <Button text={intl.formatMessage(messages.block)} onClick={this.handleBlock} />;
} else if (!account.get('moved') || following) {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
}
}
let mute_expires_at;
let muteTimeRemaining;
if (account.get('mute_expires_at')) {
mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
muteTimeRemaining = <>· <RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></>;
}
let verification;
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
if (firstVerifiedField) {
verification = <VerifiedBadge link={firstVerifiedField.get('value')} />;
}
return (
<div className='account'>
<div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={size} /></div>
{mute_expires_at}
<DisplayName account={account} />
<div className='account__avatar-wrapper'>
<Avatar account={account} size={size} />
</div>
<div className='account__contents'>
<DisplayName account={account} inline />
{!minimal && (
<div className='account__details'>
{account.get('followers_count') !== -1 && (
<ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} />
)} {verification} {muteTimeRemaining}
</div>
)}
</div>
</Permalink>
{buttons ?
{!minimal && (
<div className='account__relationship'>
{buttons}
</div>
: null}
)}
</div>
{withBio && (account.get('note').length > 0 ? (
<div
className='account__note translate'
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/>
) : (
<div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
))}
</div>
);
}

View File

@ -5,49 +5,44 @@ import { autoPlayGif } from '../initial_state';
import type { Account } from '../types/resources';
interface Props {
account: Account | undefined;
className?: string;
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
size: number;
style?: React.CSSProperties;
inline?: boolean;
animate?: boolean;
}
export const Avatar: React.FC<Props> = ({
account,
className,
animate = autoPlayGif,
size = 20,
inline = false,
style: styleFromParent,
}) => {
const { hovering, handleMouseEnter, handleMouseLeave } =
useHovering(autoPlayGif);
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
const style = {
...styleFromParent,
width: `${size}px`,
height: `${size}px`,
backgroundSize: `${size}px ${size}px`,
};
if (account) {
style.backgroundImage = `url(${account.get(
hovering ? 'avatar' : 'avatar_static',
)})`;
}
const src =
hovering || animate
? account?.get('avatar')
: account?.get('avatar_static');
return (
<div
className={classNames(
'account__avatar',
{ 'account__avatar-inline': inline },
className,
)}
className={classNames('account__avatar', {
'account__avatar-inline': inline,
})}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={style}
data-avatar-of={account && `@${account.get('acct')}`}
role='img'
aria-label={account?.get('acct')}
/>
>
{src && <img src={src} alt={account?.get('acct')} />}
</div>
);
};

View File

@ -5,6 +5,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import { Avatar } from './avatar';
export default class AvatarComposite extends PureComponent {
static propTypes = {
@ -76,12 +78,12 @@ export default class AvatarComposite extends PureComponent {
bottom: bottom,
width: `${width}%`,
height: `${height}%`,
backgroundSize: 'cover',
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
};
return (
<div key={account.get('id')} style={style} data-avatar-of={`@${account.get('acct')}`} />
<div key={account.get('id')} style={style}>
<Avatar account={account} animate={animate} />
</div>
);
}

View File

@ -1,39 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from 'flavours/glitch/initial_state';
export default class AvatarOverlay extends PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
};
static defaultProps = {
animate: autoPlayGif,
};
render() {
const { account, friend, animate } = this.props;
const baseStyle = {
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
};
const overlayStyle = {
backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`,
};
return (
<div className='account__avatar-overlay'>
<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

@ -0,0 +1,56 @@
import { useHovering } from '../hooks/useHovering';
import { autoPlayGif } from '../initial_state';
import type { Account } from '../types/resources';
interface Props {
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
size?: number;
baseSize?: number;
overlaySize?: number;
}
export const AvatarOverlay: React.FC<Props> = ({
account,
friend,
size = 46,
baseSize = 36,
overlaySize = 24,
}) => {
const { hovering, handleMouseEnter, handleMouseLeave } =
useHovering(autoPlayGif);
const accountSrc = hovering
? account?.get('avatar')
: account?.get('avatar_static');
const friendSrc = hovering
? friend?.get('avatar')
: friend?.get('avatar_static');
return (
<div
className='account__avatar-overlay'
style={{ width: size, height: size }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className='account__avatar-overlay-base'>
<div
className='account__avatar'
style={{ width: `${baseSize}px`, height: `${baseSize}px` }}
data-avatar-of={`@${account?.get('acct')}`}
>
{accountSrc && <img src={accountSrc} alt={account?.get('acct')} />}
</div>
</div>
<div className='account__avatar-overlay-overlay'>
<div
className='account__avatar'
style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }}
data-avatar-of={`@${friend?.get('acct')}`}
>
{friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />}
</div>
</div>
</div>
);
};

View File

@ -1,7 +0,0 @@
const Check = () => (
<svg width='14' height='11' viewBox='0 0 14 11'>
<path d='M11.264 0L5.26 6.004 2.103 2.847 0 4.95l5.26 5.26 8.108-8.107L11.264 0' fill='currentColor' fillRule='evenodd' />
</svg>
);
export default Check;

View File

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

View File

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

View File

@ -13,13 +13,16 @@ export class ColumnBackButton extends PureComponent {
static propTypes = {
multiColumn: PropTypes.bool,
onClick: PropTypes.func,
...WithRouterPropTypes,
};
handleClick = () => {
const { history } = this.props;
const { onClick, history } = this.props;
if (history.location?.state?.fromMastodon) {
if (onClick) {
onClick();
} else if (history.location?.state?.fromMastodon) {
history.goBack();
} else {
history.push('/');

View File

@ -0,0 +1,33 @@
import React from 'react';
import classNames from 'classnames';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { Skeleton } from 'flavours/glitch/components/skeleton';
interface Props {
size?: number;
minimal?: boolean;
}
export const EmptyAccount: React.FC<Props> = ({
size = 46,
minimal = false,
}) => {
return (
<div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'>
<Skeleton width={size} height={size} />
</div>
<div>
<DisplayName />
<Skeleton width='7ch' />
</div>
</div>
</div>
</div>
);
};

View File

@ -63,7 +63,7 @@ class ServerBanner extends PureComponent {
<div className='server-banner__meta__column'>
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} />
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal />
</div>
<div className='server-banner__meta__column'>

View File

@ -84,6 +84,7 @@ class Status extends ImmutablePureComponent {
previousId: PropTypes.string,
nextInReplyToId: PropTypes.string,
rootId: PropTypes.string,
onClick: PropTypes.func,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
@ -116,7 +117,6 @@ class Status extends ImmutablePureComponent {
intl: PropTypes.object.isRequired,
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
onClick: PropTypes.func,
scrollKey: PropTypes.string,
deployPictureInPicture: PropTypes.func,
settings: ImmutablePropTypes.map.isRequired,
@ -576,7 +576,7 @@ class Status extends ImmutablePureComponent {
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleSpoiler: this.handleExpandedToggle,
toggleHidden: this.handleExpandedToggle,
bookmark: this.handleHotkeyBookmark,
toggleCollapse: this.handleHotkeyCollapse,
toggleSensitive: this.handleHotkeyToggleSensitive,

View File

@ -6,7 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
// Mastodon imports.
import { Avatar } from './avatar';
import AvatarOverlay from './avatar_overlay';
import { AvatarOverlay } from './avatar_overlay';
import { DisplayName } from './display_name';
export default class StatusHeader extends PureComponent {
@ -39,7 +39,7 @@ export default class StatusHeader extends PureComponent {
let statusAvatar;
if (friend === undefined || friend === null) {
statusAvatar = <Avatar account={account} size={48} />;
statusAvatar = <Avatar account={account} size={46} />;
} else {
statusAvatar = <AvatarOverlay account={account} friend={friend} />;
}

View File

@ -0,0 +1,27 @@
import { Icon } from './icon';
const domParser = new DOMParser();
const stripRelMe = (html: string) => {
const document = domParser.parseFromString(html, 'text/html').documentElement;
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
link.rel = link.rel
.split(' ')
.filter((x: string) => x !== 'me')
.join(' ');
});
const body = document.querySelector('body');
return body ? { __html: body.innerHTML } : undefined;
};
interface Props {
link: string;
}
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
<span className='verified-badge'>
<Icon id='check' className='verified-badge__mark' />
<span dangerouslySetInnerHTML={stripRelMe(link)} />
</span>
);

View File

@ -22,6 +22,7 @@ import { store } from 'flavours/glitch/store';
const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
const hydrateAction = hydrateStore(initialState);
store.dispatch(hydrateAction);
// check for deprecated local settings
@ -71,8 +72,8 @@ export default class Mastodon extends PureComponent {
}
}
shouldUpdateScroll (_, { location }) {
return !(location.state?.mastodonModalKey);
shouldUpdateScroll (prevRouterProps, { location }) {
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
}
render () {

View File

@ -128,7 +128,7 @@ class About extends PureComponent {
<div className='about__meta__column'>
<h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} />
<Account id={server.getIn(['contact', 'account', 'id'])} size={36} minimal />
</div>
<hr className='about__meta__divider' />

View File

@ -1,108 +1,174 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { is } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({
placeholder: { id: 'account_note.glitch_placeholder', defaultMessage: 'No comment provided' },
placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
});
class Header extends ImmutablePureComponent {
class InlineAlert extends PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
isEditing: PropTypes.bool,
isSubmitting: PropTypes.bool,
accountNote: PropTypes.string,
onEditAccountNote: PropTypes.func.isRequired,
onCancelAccountNote: PropTypes.func.isRequired,
onSaveAccountNote: PropTypes.func.isRequired,
onChangeAccountNote: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
show: PropTypes.bool,
};
handleChangeAccountNote = (e) => {
this.props.onChangeAccountNote(e.target.value);
state = {
mountMessage: false,
};
componentWillUnmount () {
if (this.props.isEditing) {
this.props.onCancelAccountNote();
static TRANSITION_DELAY = 200;
UNSAFE_componentWillReceiveProps (nextProps) {
if (!this.props.show && nextProps.show) {
this.setState({ mountMessage: true });
} else if (this.props.show && !nextProps.show) {
setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
}
}
render () {
const { show } = this.props;
const { mountMessage } = this.state;
return (
<span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
{mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
</span>
);
}
}
class AccountNote extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
value: PropTypes.string,
onSave: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
value: null,
saving: false,
saved: false,
};
UNSAFE_componentWillMount () {
this._reset();
}
UNSAFE_componentWillReceiveProps (nextProps) {
const accountWillChange = !is(this.props.account, nextProps.account);
const newState = {};
if (accountWillChange && this._isDirty()) {
this._save(false);
}
if (accountWillChange || nextProps.value === this.state.value) {
newState.saving = false;
}
if (this.props.value !== nextProps.value) {
newState.value = nextProps.value;
}
this.setState(newState);
}
componentWillUnmount () {
if (this._isDirty()) {
this._save(false);
}
}
setTextareaRef = c => {
this.textarea = c;
};
handleChange = e => {
this.setState({ value: e.target.value, saving: false });
};
handleKeyDown = e => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.props.onSaveAccountNote();
e.preventDefault();
this._save();
if (this.textarea) {
this.textarea.blur();
}
} else if (e.keyCode === 27) {
this.props.onCancelAccountNote();
e.preventDefault();
this._reset(() => {
if (this.textarea) {
this.textarea.blur();
}
});
}
};
handleBlur = () => {
if (this._isDirty()) {
this._save();
}
};
_save (showMessage = true) {
this.setState({ saving: true }, () => this.props.onSave(this.state.value));
if (showMessage) {
this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
}
}
_reset (callback) {
this.setState({ value: this.props.value }, callback);
}
_isDirty () {
return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
}
render () {
const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
const { account, intl } = this.props;
const { value, saved } = this.state;
if (!account || (!accountNote && !isEditing)) {
if (!account) {
return null;
}
let action_buttons = null;
if (isEditing) {
action_buttons = (
<div className='account__header__account-note__buttons'>
<button className='icon-button' tabIndex={0} onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
<Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
</button>
<div className='flex-spacer' />
<button className='icon-button' tabIndex={0} onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
<Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
</button>
</div>
);
} else {
action_buttons = (
<div className='account__header__account-note__buttons'>
<button className='icon-button' tabIndex={0} onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
<Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
</button>
</div>
);
}
let note_container = null;
if (isEditing) {
note_container = (
<Textarea
className='account__header__account-note__content'
disabled={isSubmitting}
placeholder={intl.formatMessage(messages.placeholder)}
value={accountNote}
onChange={this.handleChangeAccountNote}
onKeyDown={this.handleKeyDown}
autoFocus
/>
);
} else {
note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
}
return (
<div className='account__header__account-note'>
<div className='account__header__account-note__header'>
<strong><FormattedMessage id='account.account_note_header' defaultMessage='Note' /></strong>
{action_buttons}
</div>
{note_container}
<label htmlFor={`account-note-${account.get('id')}`}>
<FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
</label>
<Textarea
id={`account-note-${account.get('id')}`}
className='account__header__account-note__content'
disabled={this.props.value === null || value === null}
placeholder={intl.formatMessage(messages.placeholder)}
value={value || ''}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onBlur={this.handleBlur}
ref={this.setTextareaRef}
/>
</div>
);
}
}
export default injectIntl(Header);
export default injectIntl(AccountNote);

View File

@ -38,7 +38,7 @@ class FeaturedTags extends ImmutablePureComponent {
name={featuredTag.get('name')}
href={featuredTag.get('url')}
to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`}
uses={featuredTag.get('statuses_count')}
uses={featuredTag.get('statuses_count') * 1}
withGraph={false}
description={((featuredTag.get('statuses_count') * 1) > 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
/>

View File

@ -11,7 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { Avatar } from 'flavours/glitch/components/avatar';
import { Button } from 'flavours/glitch/components/button';
import { Icon } from 'flavours/glitch/components/icon';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { autoPlayGif, me, domain } from 'flavours/glitch/initial_state';
@ -59,7 +59,6 @@ const messages = defineMessages({
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' },
openOriginalPage: { id: 'account.open_original_page', defaultMessage: 'Open original page' },
});
@ -98,7 +97,6 @@ class Header extends ImmutablePureComponent {
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
onEditAccountNote: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
onOpenAvatar: PropTypes.func.isRequired,
@ -167,8 +165,6 @@ class Header extends ImmutablePureComponent {
return null;
}
const accountNote = account.getIn(['relationship', 'note']);
const suspended = account.get('suspended');
const isRemote = account.get('acct') !== account.get('username');
const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
@ -237,10 +233,6 @@ class Header extends ImmutablePureComponent {
menu.push(null);
}
if (accountNote === null || accountNote === '') {
menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
}
if (account.get('id') === me) {
if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });

View File

@ -1,36 +1,19 @@
import { connect } from 'react-redux';
import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'flavours/glitch/actions/account_notes';
import { submitAccountNote } from 'flavours/glitch/actions/account_notes';
import AccountNote from '../components/account_note';
const mapStateToProps = (state, { account }) => {
const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
return {
isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
isEditing,
};
};
const mapStateToProps = (state, { account }) => ({
value: account.getIn(['relationship', 'note']),
});
const mapDispatchToProps = (dispatch, { account }) => ({
onEditAccountNote() {
dispatch(initEditAccountNote(account));
onSave (value) {
dispatch(submitAccountNote({ id: account.get('id'), value}));
},
onSaveAccountNote() {
dispatch(submitAccountNote());
},
onCancelAccountNote() {
dispatch(cancelAccountNote());
},
onChangeAccountNote(comment) {
dispatch(changeAccountNoteComment(comment));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);

View File

@ -76,7 +76,7 @@ export default class MediaItem extends ImmutablePureComponent {
if (['audio', 'video'].includes(attachment.get('type'))) {
content = (
<img
src={attachment.get('preview_url') || attachment.getIn(['account', 'avatar_static'])}
src={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
alt={attachment.get('description')}
lang={status.get('language')}
onLoad={this.handleImageLoad}

View File

@ -8,7 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { Icon } from 'flavours/glitch/components/icon';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import AvatarOverlay from '../../../components/avatar_overlay';
import { AvatarOverlay } from '../../../components/avatar_overlay';
import { DisplayName } from '../../../components/display_name';
class MovedNote extends ImmutablePureComponent {

View File

@ -2,7 +2,6 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { initEditAccountNote } from '../../../actions/account_notes';
import {
followAccount,
unfollowAccount,
@ -139,10 +138,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onEditAccountNote (account) {
dispatch(initEditAccountNote(account));
},
onBlockDomain (domain) {
dispatch(openModal({
modalType: 'CONFIRM',

View File

@ -184,7 +184,7 @@ class AccountTimeline extends ImmutablePureComponent {
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
return (
<Column ref={this.setRef} name='account'>
<Column ref={this.setRef}>
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
<StatusList

View File

@ -9,7 +9,7 @@ import { is } from 'immutable';
import { throttle, debounce } from 'lodash';
import { Icon } from 'flavours/glitch/components/icon';
import { Icon } from 'flavours/glitch/components/icon';
import { formatTime, getPointerPosition, fileNameFromURL } from 'flavours/glitch/features/video';
import { Blurhash } from '../../components/blurhash';

View File

@ -59,7 +59,7 @@ class Blocks extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />;
return (
<Column name='blocks' bindToDocument={!multiColumn} icon='ban' heading={intl.formatMessage(messages.heading)}>
<Column bindToDocument={!multiColumn} icon='ban' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollableList
scrollKey='blocks'

View File

@ -77,7 +77,7 @@ class Bookmarks extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} name='bookmarks'>
<Column bindToDocument={!multiColumn} ref={this.setRef}>
<ColumnHeader
icon='bookmark'
title={intl.formatMessage(messages.heading)}

View File

@ -40,14 +40,14 @@ const mapStateToProps = (state, { columnId }) => {
class CommunityTimeline extends PureComponent {
static defaultProps = {
onlyMedia: false,
};
static contextTypes = {
identity: PropTypes.object,
};
static defaultProps = {
onlyMedia: false,
};
static propTypes = {
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
@ -128,7 +128,7 @@ class CommunityTimeline extends PureComponent {
const pinned = !!columnId;
return (
<Column ref={this.setRef} name='local' bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='users'
active={hasUnread}

View File

@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -84,6 +86,10 @@ class ComposeForm extends ImmutablePureComponent {
showSearch: false,
};
state = {
highlighted: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
};
@ -209,6 +215,10 @@ class ComposeForm extends ImmutablePureComponent {
this._updateFocusAndSelection({ });
}
componentWillUnmount () {
if (this.timeout) clearTimeout(this.timeout);
}
componentDidUpdate (prevProps) {
this._updateFocusAndSelection(prevProps);
}
@ -257,6 +267,8 @@ class ComposeForm extends ImmutablePureComponent {
textarea.setSelectionRange(selectionStart, selectionEnd);
textarea.focus();
if (!singleColumn) textarea.scrollIntoView();
this.setState({ highlighted: true });
this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700);
}).catch(console.error);
}
@ -302,6 +314,7 @@ class ComposeForm extends ImmutablePureComponent {
spoilersAlwaysOn,
isEditing,
} = this.props;
const { highlighted } = this.state;
const countText = this.getFulltextForCharacterCounting();
@ -332,42 +345,44 @@ class ComposeForm extends ImmutablePureComponent {
/>
</div>
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting}
value={this.props.text}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
suggestions={suggestions}
onFocus={this.handleFocus}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionSelected={this.handleSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
lang={this.props.lang}
>
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
<TextareaIcons advancedOptions={advancedOptions} />
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
</AutosuggestTextarea>
<div className='compose-form__buttons-wrapper'>
<OptionsContainer
advancedOptions={advancedOptions}
<div className={classNames('compose-form__highlightable', { active: highlighted })}>
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={isSubmitting}
onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
onUpload={onPaste}
isEditing={isEditing}
sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
/>
<div className='character-counter__wrapper'>
<CharacterCounter text={countText} max={maxChars} />
value={this.props.text}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
suggestions={suggestions}
onFocus={this.handleFocus}
onSuggestionsFetchRequested={onFetchSuggestions}
onSuggestionsClearRequested={onClearSuggestions}
onSuggestionSelected={this.handleSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
lang={this.props.lang}
>
<TextareaIcons advancedOptions={advancedOptions} />
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
</AutosuggestTextarea>
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
<div className='compose-form__buttons-wrapper'>
<OptionsContainer
advancedOptions={advancedOptions}
disabled={isSubmitting}
onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
onUpload={onPaste}
isEditing={isEditing}
sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
/>
<div className='character-counter__wrapper'>
<CharacterCounter text={countText} max={maxChars} />
</div>
</div>
</div>

View File

@ -17,19 +17,21 @@ export default class NavigationBar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func,
};
render () {
const username = this.props.account.get('acct');
return (
<div className='navigation-bar'>
<Permalink className='avatar' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={48} />
<Permalink className='avatar' href={this.props.account.get('url')} to={`/@${username}`}>
<span style={{ display: 'none' }}>{username}</span>
<Avatar account={this.props.account} size={46} />
</Permalink>
<div className='navigation-bar__profile'>
<Permalink className='acct' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
<strong>@{this.props.account.get('acct')}</strong>
<strong className='navigation-bar__profile-account'>@{username}</strong>
</Permalink>
{ profileLink !== undefined && (

View File

@ -92,25 +92,6 @@ class Search extends PureComponent {
}
};
handleBlur = () => {
this.setState({ expanded: false, selectedOption: -1 });
};
handleFocus = () => {
const { onShow, singleColumn } = this.props;
this.setState({ expanded: true, selectedOption: -1 });
onShow();
if (this.searchForm && !singleColumn) {
const { left, right } = this.searchForm.getBoundingClientRect();
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
this.searchForm.scrollIntoView();
}
}
};
handleKeyDown = (e) => {
const { selectedOption } = this.state;
const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
@ -161,8 +142,23 @@ class Search extends PureComponent {
}
};
findTarget = () => {
return this.searchForm;
handleFocus = () => {
const { onShow, singleColumn } = this.props;
this.setState({ expanded: true, selectedOption: -1 });
onShow();
if (this.searchForm && !singleColumn) {
const { left, right } = this.searchForm.getBoundingClientRect();
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
this.searchForm.scrollIntoView();
}
}
};
handleBlur = () => {
this.setState({ expanded: false, selectedOption: -1 });
};
handleHashtagClick = () => {

View File

@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Icon } from 'flavours/glitch/components/icon';
import { Icon } from 'flavours/glitch/components/icon';
import { LoadMore } from 'flavours/glitch/components/load_more';
import { SearchSection } from 'flavours/glitch/features/explore/components/search_section';
@ -69,6 +69,7 @@ class SearchResults extends ImmutablePureComponent {
);
}
return (
<div className='drawer--results'>
<header className='search-results__header'>

View File

@ -77,7 +77,7 @@ class Favourites extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favorite posts yet. When you favorite one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} name='favourites' label={intl.formatMessage(messages.heading)}>
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='star'
title={intl.formatMessage(messages.heading)}

View File

@ -85,9 +85,10 @@ class Favourites extends ImmutablePureComponent {
showBackButton
multiColumn={multiColumn}
extraButton={(
<button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
<button type='button' className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
)}
/>
<ScrollableList
scrollKey='favourites'
onLoadMore={this.handleLoadMore}

View File

@ -1,87 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts';
import { Avatar } from 'flavours/glitch/components/avatar';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { IconButton } from 'flavours/glitch/components/icon_button';
import Permalink from 'flavours/glitch/components/permalink';
import { makeGetAccount } from 'flavours/glitch/selectors';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
});
return mapStateToProps;
};
const getFirstSentence = str => {
const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/);
return arr[0];
};
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
};
handleFollow = () => {
const { account, dispatch } = this.props;
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
dispatch(unfollowAccount(account.get('id')));
} else {
dispatch(followAccount(account.get('id')));
}
};
render () {
const { account, intl } = this.props;
let button;
if (account.getIn(['relationship', 'following'])) {
button = <IconButton icon='check' title={intl.formatMessage(messages.unfollow)} active onClick={this.handleFollow} />;
} else {
button = <IconButton icon='plus' title={intl.formatMessage(messages.follow)} onClick={this.handleFollow} />;
}
return (
<div className='account follow-recommendations-account'>
<div className='account__wrapper'>
<Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
<div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
</Permalink>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(Account));

View File

@ -1,119 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings';
import { fetchSuggestions } from 'flavours/glitch/actions/suggestions';
import { markAsPartial } from 'flavours/glitch/actions/timelines';
import { Button } from 'flavours/glitch/components/button';
import Column from 'flavours/glitch/features/ui/components/column';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
import Account from './components/account';
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']),
});
class FollowRecommendations extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
...WithRouterPropTypes,
};
componentDidMount () {
const { dispatch, suggestions } = this.props;
// Don't re-fetch if we're e.g. navigating backwards to this page,
// since we don't want followed accounts to disappear from the list
if (suggestions.size === 0) {
dispatch(fetchSuggestions(true));
}
}
componentWillUnmount () {
const { dispatch } = this.props;
// Force the home timeline to be reloaded when the user navigates
// to it; if the user is new, it would've been empty before
dispatch(markAsPartial('home'));
}
handleDone = () => {
const { history, dispatch } = this.props;
dispatch(requestBrowserPermission((permission) => {
if (permission === 'granted') {
dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
dispatch(saveSettings());
}
}));
history.push('/home');
};
render () {
const { suggestions, isLoading } = this.props;
return (
<Column>
<div className='scrollable follow-recommendations-container'>
<div className='column-title'>
<svg viewBox='0 0 79 79' className='logo'>
<use xlinkHref='#logo-symbol-icon' />
</svg>
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
<p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p>
</div>
{!isLoading && (
<>
<div className='column-list'>
{suggestions.size > 0 ? suggestions.map(suggestion => (
<Account key={suggestion.get('account')} id={suggestion.get('account')} />
)) : (
<div className='column-list__empty-message'>
<FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' />
</div>
)}
</div>
<div className='column-actions'>
<img src={imageGreeting} alt='' className='column-actions__background' />
<Button onClick={this.handleDone}><FormattedMessage id='follow_recommendations.done' defaultMessage='Done' /></Button>
</div>
</>
)}
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default withRouter(connect(mapStateToProps)(FollowRecommendations));

View File

@ -67,7 +67,7 @@ class FollowRequests extends ImmutablePureComponent {
);
return (
<Column bindToDocument={!multiColumn} name='follow-requests' icon='user-plus' heading={intl.formatMessage(messages.heading)}>
<Column bindToDocument={!multiColumn} icon='user-plus' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollableList
scrollKey='follow_requests'

View File

@ -394,7 +394,7 @@ class Announcements extends ImmutablePureComponent {
_markAnnouncementAsRead () {
const { dismissAnnouncement, announcements } = this.props;
const { index } = this.state;
const announcement = announcements.get(index);
const announcement = announcements.get(announcements.size - 1 - index);
if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
}
@ -435,7 +435,7 @@ class Announcements extends ImmutablePureComponent {
selected={index === idx}
disabled={disableSwiping}
/>
))}
)).reverse()}
</ReactSwipeableViews>
{announcements.size > 1 && (

View File

@ -173,7 +173,7 @@ class GettingStarted extends ImmutablePureComponent {
}
return (
<Column bindToDocument={!multiColumn} name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile>
<Column bindToDocument={!multiColumn} icon='asterisk' heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile>
<div className='scrollable optionally-scrollable'>
<div className='getting-started__wrapper'>
{!multiColumn && signedIn && <NavigationBar account={myAccount} />}

View File

@ -19,7 +19,6 @@ const messages = defineMessages({
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' },
@ -36,12 +35,6 @@ class GettingStartedMisc extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired,
};
openOnboardingModal = () => {
this.props.dispatch(openModal({
modalType: 'ONBOARDING',
}));
};
openFeaturedAccountsModal = () => {
this.props.dispatch(openModal({
modalType: 'PINNED_ACCOUNTS_EDITOR',
@ -65,7 +58,6 @@ class GettingStartedMisc extends ImmutablePureComponent {
{signedIn && (<ColumnLink key='blocks' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />)}
{signedIn && (<ColumnLink key='domain_blocks' icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />)}
<ColumnLink key='shortcuts' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
{signedIn && (<ColumnLink key='onboarding' icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />)}
</div>
</Column>
);

View File

@ -196,7 +196,7 @@ class HomeTimeline extends PureComponent {
}
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}>
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='home'
active={hasUnread}

View File

@ -16,7 +16,7 @@ const messages = defineMessages({
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
});
const mapStateToProps = (state, { listId, added }) => ({
const MapStateToProps = (state, { listId, added }) => ({
list: state.get('lists').get(listId),
added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added,
});
@ -69,4 +69,4 @@ class List extends ImmutablePureComponent {
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(List));
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(List));

View File

@ -1,20 +1,39 @@
import PropTypes from 'prop-types';
import { defineMessages } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { removeFromListEditor, addToListEditor } from '../../../actions/lists';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
import { makeGetAccount } from '../../../selectors';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
});
export default class Account extends ImmutablePureComponent {
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromListEditor(accountId)),
onAdd: () => dispatch(addToListEditor(accountId)),
});
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
@ -56,3 +75,5 @@ export default class Account extends ImmutablePureComponent {
}
}
export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account));

View File

@ -1,17 +1,31 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { Icon } from 'flavours/glitch/components/icon';
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
const messages = defineMessages({
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
});
export default class Search extends PureComponent {
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchListSuggestions(value)),
onClear: () => dispatch(clearListSuggestions()),
onChange: value => dispatch(changeListSuggestions(value)),
});
class Search extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
@ -63,3 +77,5 @@ export default class Search extends PureComponent {
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));

View File

@ -1,26 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { removeFromListEditor, addToListEditor } from 'flavours/glitch/actions/lists';
import { makeGetAccount } from 'flavours/glitch/selectors';
import Account from '../components/account';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromListEditor(accountId)),
onAdd: () => dispatch(addToListEditor(accountId)),
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));

View File

@ -1,18 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
import Search from '../components/search';
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchListSuggestions(value)),
onClear: () => dispatch(clearListSuggestions()),
onChange: value => dispatch(changeListSuggestions(value)),
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));

View File

@ -11,10 +11,9 @@ import spring from 'react-motion/lib/spring';
import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists';
import Motion from '../ui/util/optional_motion';
import Account from './components/account';
import EditListForm from './components/edit_list_form';
import AccountContainer from './containers/account_container';
import SearchContainer from './containers/search_container';
import Search from './components/search';
const mapStateToProps = state => ({
accountIds: state.getIn(['listEditor', 'accounts', 'items']),
@ -58,21 +57,21 @@ class ListEditor extends ImmutablePureComponent {
<div className='modal-root__modal list-editor'>
<EditListForm />
<SearchContainer />
<Search />
<div className='drawer__pager'>
<div className='drawer__inner list-editor__accounts'>
{accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
</div>
{showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) =>
(<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
</div>)
}
{({ x }) => (
<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
</div>
)}
</Motion>
</div>
</div>

View File

@ -61,7 +61,7 @@ class Mutes extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />;
return (
<Column bindToDocument={!multiColumn} name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}>
<Column bindToDocument={!multiColumn} icon='volume-off' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollableList
scrollKey='mutes'

View File

@ -1,11 +1,11 @@
import PropTypes from 'prop-types';
import { Component } from 'react';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import { Icon } from 'flavours/glitch/components/icon';
export default class ClearColumnButton extends Component {
export default class ClearColumnButton extends PureComponent {
static propTypes = {
onClick: PropTypes.func.isRequired,

View File

@ -5,7 +5,7 @@ import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import AvatarOverlay from 'flavours/glitch/components/avatar_overlay';
import { AvatarOverlay } from 'flavours/glitch/components/avatar_overlay';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
// This needs to be kept in sync with app/models/report.rb

View File

@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (permission === 'granted') {
dispatch(changePushNotifications(path.slice(1), checked));
} else {
dispatch(showAlert(undefined, messages.permissionDenied));
dispatch(showAlert({ message: messages.permissionDenied }));
}
}));
} else {
@ -47,7 +47,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (permission === 'granted') {
dispatch(changeSetting(['notifications', ...path], checked));
} else {
dispatch(showAlert(undefined, messages.permissionDenied));
dispatch(showAlert({ message: messages.permissionDenied }));
}
}));
} else {

View File

@ -333,7 +333,6 @@ class Notifications extends PureComponent {
<Column
bindToDocument={!multiColumn}
ref={this.setColumnRef}
name='notifications'
extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null}
label={intl.formatMessage(messages.title)}
>

View File

@ -0,0 +1,7 @@
const ArrowSmallRight = () => (
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='currentColor'>
<path fillRule='evenodd' d='M5 10a.75.75 0 01.75-.75h6.638L10.23 7.29a.75.75 0 111.04-1.08l3.5 3.25a.75.75 0 010 1.08l-3.5 3.25a.75.75 0 11-1.04-1.08l2.158-1.96H5.75A.75.75 0 015 10z' clipRule='evenodd' />
</svg>
);
export default ArrowSmallRight;

View File

@ -0,0 +1,28 @@
import PropTypes from 'prop-types';
import { Fragment } from 'react';
import classNames from 'classnames';
import { Check } from 'flavours/glitch/components/check';
const ProgressIndicator = ({ steps, completed }) => (
<div className='onboarding__progress-indicator'>
{(new Array(steps)).fill().map((_, i) => (
<Fragment key={i}>
{i > 0 && <div className={classNames('onboarding__progress-indicator__line', { active: completed > i })} />}
<div className={classNames('onboarding__progress-indicator__step', { active: completed > i })}>
{completed > i && <Check />}
</div>
</Fragment>
))}
</div>
);
ProgressIndicator.propTypes = {
steps: PropTypes.number.isRequired,
completed: PropTypes.number,
};
export default ProgressIndicator;

View File

@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import { Check } from 'flavours/glitch/components/check';
import { Icon } from 'flavours/glitch/components/icon';
import ArrowSmallRight from './arrow_small_right';
const Step = ({ label, description, icon, completed, onClick, href }) => {
const content = (
<>
<div className='onboarding__steps__item__icon'>
<Icon id={icon} />
</div>
<div className='onboarding__steps__item__description'>
<h6>{label}</h6>
<p>{description}</p>
</div>
<div className={completed ? 'onboarding__steps__item__progress' : 'onboarding__steps__item__go'}>
{completed ? <Check /> : <ArrowSmallRight />}
</div>
</>
);
if (href) {
return (
<a href={href} onClick={onClick} target='_blank' rel='noopener' className='onboarding__steps__item'>
{content}
</a>
);
}
return (
<button onClick={onClick} className='onboarding__steps__item'>
{content}
</button>
);
};
Step.propTypes = {
label: PropTypes.node,
description: PropTypes.node,
icon: PropTypes.string,
completed: PropTypes.bool,
href: PropTypes.string,
onClick: PropTypes.func,
};
export default Step;

View File

@ -0,0 +1,80 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { fetchSuggestions } from 'flavours/glitch/actions/suggestions';
import { markAsPartial } from 'flavours/glitch/actions/timelines';
import Column from 'flavours/glitch/components/column';
import ColumnBackButton from 'flavours/glitch/components/column_back_button';
import { EmptyAccount } from 'flavours/glitch/components/empty_account';
import Account from 'flavours/glitch/containers/account_container';
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']),
});
class Follows extends PureComponent {
static propTypes = {
onBack: PropTypes.func,
dispatch: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchSuggestions(true));
}
componentWillUnmount () {
const { dispatch } = this.props;
dispatch(markAsPartial('home'));
}
render () {
const { onBack, isLoading, suggestions, multiColumn } = this.props;
let loadedContent;
if (isLoading) {
loadedContent = (new Array(8)).fill().map((_, i) => <EmptyAccount key={i} />);
} else if (suggestions.isEmpty()) {
loadedContent = <div className='follow-recommendations__empty'><FormattedMessage id='onboarding.follows.empty' defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.' /></div>;
} else {
loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} withBio />);
}
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} onClick={onBack} />
<div className='scrollable privacy-policy'>
<div className='column-title'>
<h3><FormattedMessage id='onboarding.follows.title' defaultMessage='Popular on Mastodon' /></h3>
<p><FormattedMessage id='onboarding.follows.lead' defaultMessage='You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!' /></p>
</div>
<div className='follow-recommendations'>
{loadedContent}
</div>
<p className='onboarding__lead'><FormattedMessage id='onboarding.tips.accounts_from_other_servers' defaultMessage='<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p>
<div className='onboarding__footer'>
<button className='link-button' onClick={onBack}><FormattedMessage id='onboarding.actions.back' defaultMessage='Take me back' /></button>
</div>
</div>
</Column>
);
}
}
export default connect(mapStateToProps)(Follows);

View File

@ -0,0 +1,149 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { fetchAccount } from 'flavours/glitch/actions/accounts';
import { focusCompose } from 'flavours/glitch/actions/compose';
import { closeOnboarding } from 'flavours/glitch/actions/onboarding';
import Column from 'flavours/glitch/features/ui/components/column';
import { me } from 'flavours/glitch/initial_state';
import { makeGetAccount } from 'flavours/glitch/selectors';
import { assetHost } from 'flavours/glitch/utils/config';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import illustration from 'mastodon/../images/elephant_ui_conversation.svg';
import ArrowSmallRight from './components/arrow_small_right';
import Step from './components/step';
import Follows from './follows';
import Share from './share';
const messages = defineMessages({
template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' },
});
const mapStateToProps = () => {
const getAccount = makeGetAccount();
return state => ({
account: getAccount(state, me),
});
};
class Onboarding extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
account: ImmutablePropTypes.map,
multiColumn: PropTypes.bool,
...WithRouterPropTypes,
};
state = {
step: null,
profileClicked: false,
shareClicked: false,
};
handleClose = () => {
const { dispatch, history } = this.props;
dispatch(closeOnboarding());
history.push('/home');
};
handleProfileClick = () => {
this.setState({ profileClicked: true });
};
handleFollowClick = () => {
this.setState({ step: 'follows' });
};
handleComposeClick = () => {
const { dispatch, intl, history } = this.props;
dispatch(focusCompose(history, intl.formatMessage(messages.template)));
};
handleShareClick = () => {
this.setState({ step: 'share', shareClicked: true });
};
handleBackClick = () => {
this.setState({ step: null });
};
handleWindowFocus = debounce(() => {
const { dispatch, account } = this.props;
dispatch(fetchAccount(account.get('id')));
}, 1000, { trailing: true });
componentDidMount () {
window.addEventListener('focus', this.handleWindowFocus, false);
}
componentWillUnmount () {
window.removeEventListener('focus', this.handleWindowFocus);
}
render () {
const { account, multiColumn } = this.props;
const { step, shareClicked } = this.state;
switch(step) {
case 'follows':
return <Follows onBack={this.handleBackClick} multiColumn={multiColumn} />;
case 'share':
return <Share onBack={this.handleBackClick} multiColumn={multiColumn} />;
}
return (
<Column>
<div className='scrollable privacy-policy'>
<div className='column-title'>
<img src={illustration} alt='' className='onboarding__illustration' />
<h3><FormattedMessage id='onboarding.start.title' defaultMessage="You've made it!" /></h3>
<p><FormattedMessage id='onboarding.start.lead' defaultMessage="Your new Mastodon account is ready to go. Here's how you can make the most of it:" /></p>
</div>
<div className='onboarding__steps'>
<Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
<Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
<Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
<Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
</div>
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p>
<div className='onboarding__links'>
<Link to='/explore' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
<ArrowSmallRight />
</Link>
<Link to='/home' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
<ArrowSmallRight />
</Link>
</div>
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default withRouter(connect(mapStateToProps)(injectIntl(Onboarding)));

View File

@ -0,0 +1,200 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import SwipeableViews from 'react-swipeable-views';
import Column from 'flavours/glitch/components/column';
import ColumnBackButton from 'flavours/glitch/components/column_back_button';
import { Icon } from 'flavours/glitch/components/icon';
import { me, domain } from 'flavours/glitch/initial_state';
import ArrowSmallRight from './components/arrow_small_right';
const messages = defineMessages({
shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on #Mastodon! Come follow me at {url}' },
});
const mapStateToProps = state => ({
account: state.getIn(['accounts', me]),
});
class CopyPasteText extends PureComponent {
static propTypes = {
value: PropTypes.string,
};
state = {
copied: false,
focused: false,
};
setRef = c => {
this.input = c;
};
handleInputClick = () => {
this.setState({ copied: false });
this.input.focus();
this.input.select();
this.input.setSelectionRange(0, this.props.value.length);
};
handleButtonClick = e => {
e.stopPropagation();
const { value } = this.props;
navigator.clipboard.writeText(value);
this.input.blur();
this.setState({ copied: true });
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
};
handleFocus = () => {
this.setState({ focused: true });
};
handleBlur = () => {
this.setState({ focused: false });
};
componentWillUnmount () {
if (this.timeout) clearTimeout(this.timeout);
}
render () {
const { value } = this.props;
const { copied, focused } = this.state;
return (
<div className={classNames('copy-paste-text', { copied, focused })} tabIndex='0' role='button' onClick={this.handleInputClick}>
<textarea readOnly value={value} ref={this.setRef} onClick={this.handleInputClick} onFocus={this.handleFocus} onBlur={this.handleBlur} />
<button className='button' onClick={this.handleButtonClick}>
<Icon id='copy' /> {copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : <FormattedMessage id='copypaste.copy_to_clipboard' defaultMessage='Copy to clipboard' />}
</button>
</div>
);
}
}
class TipCarousel extends PureComponent {
static propTypes = {
children: PropTypes.node,
};
state = {
index: 0,
};
handleSwipe = index => {
this.setState({ index });
};
handleChangeIndex = e => {
this.setState({ index: Number(e.currentTarget.getAttribute('data-index')) });
};
handleKeyDown = e => {
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
this.setState(({ index }, { children }) => ({ index: Math.abs(index - 1) % children.length }));
break;
case 'ArrowRight':
e.preventDefault();
this.setState(({ index }, { children }) => ({ index: (index + 1) % children.length }));
break;
}
};
render () {
const { children } = this.props;
const { index } = this.state;
return (
<div className='tip-carousel' tabIndex='0' onKeyDown={this.handleKeyDown}>
<SwipeableViews onChangeIndex={this.handleSwipe} index={index} enableMouseEvents tabIndex='-1'>
{children}
</SwipeableViews>
<div className='media-modal__pagination'>
{children.map((_, i) => (
<button key={i} className={classNames('media-modal__page-dot', { active: i === index })} data-index={i} onClick={this.handleChangeIndex}>
{i + 1}
</button>
))}
</div>
</div>
);
}
}
class Share extends PureComponent {
static propTypes = {
onBack: PropTypes.func,
account: ImmutablePropTypes.map,
multiColumn: PropTypes.bool,
intl: PropTypes.object,
};
render () {
const { onBack, account, multiColumn, intl } = this.props;
const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href;
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} onClick={onBack} />
<div className='scrollable privacy-policy'>
<div className='column-title'>
<h3><FormattedMessage id='onboarding.share.title' defaultMessage='Share your profile' /></h3>
<p><FormattedMessage id='onboarding.share.lead' defaultMessage='Let people know how they can find you on Mastodon!' /></p>
</div>
<CopyPasteText value={intl.formatMessage(messages.shareableMessage, { username: `@${account.get('username')}@${domain}`, url })} />
<TipCarousel>
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.verification' defaultMessage='<strong>Did you know?</strong> You can verify your account by putting a link to your Mastodon profile on your own website and adding the website to your profile. No fees or documents necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div>
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.migration' defaultMessage='<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!' values={{ domain, strong: chunks => <strong>{chunks}</strong> }} /></p></div>
<div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.2fa' defaultMessage='<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div>
</TipCarousel>
<p className='onboarding__lead'><FormattedMessage id='onboarding.share.next_steps' defaultMessage='Possible next steps:' /></p>
<div className='onboarding__links'>
<Link to='/home' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
<ArrowSmallRight />
</Link>
<Link to='/explore' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
<ArrowSmallRight />
</Link>
</div>
<div className='onboarding__footer'>
<button className='link-button' onClick={onBack}><FormattedMessage id='onboarding.action.back' defaultMessage='Take me back' /></button>
</div>
</div>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Share));

View File

@ -122,8 +122,8 @@ class Footer extends ImmutablePureComponent {
}
};
_performReblog = (privacy) => {
const { dispatch, status } = this.props;
_performReblog = (status, privacy) => {
const { dispatch } = this.props;
dispatch(reblog(status, privacy));
};
@ -135,7 +135,7 @@ class Footer extends ImmutablePureComponent {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else if ((e && e.shiftKey) || !boostModal) {
this._performReblog();
this._performReblog(status);
} else {
dispatch(initBoostModal({ status, onReblog: this._performReblog }));
}

View File

@ -133,7 +133,7 @@ class PublicTimeline extends PureComponent {
const pinned = !!columnId;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} name='federated' label={intl.formatMessage(messages.title)}>
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='globe'
active={hasUnread}

View File

@ -3,7 +3,7 @@ import { PureComponent } from 'react';
import classNames from 'classnames';
import Check from 'flavours/glitch/components/check';
import { Check } from 'flavours/glitch/components/check';
export default class Option extends PureComponent {

View File

@ -12,7 +12,7 @@ import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
import Option from './option';
export default class StatusCheckBox extends PureComponent {
class StatusCheckBox extends PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
@ -40,7 +40,9 @@ export default class StatusCheckBox extends PureComponent {
<Avatar account={status.get('account')} size={46} />
</div>
<div><DisplayName account={status.get('account')} /> · <VisibilityIcon visibility={status.get('visibility')} /><RelativeTimestamp timestamp={status.get('created_at')} /></div>
<div>
<DisplayName account={status.get('account')} /> · <VisibilityIcon visibility={status.get('visibility')} /><RelativeTimestamp timestamp={status.get('created_at')} />
</div>
</div>
<StatusContent status={status} media={<MediaAttachments status={status} revealed={false} />} />
@ -61,3 +63,5 @@ export default class StatusCheckBox extends PureComponent {
}
}
export default StatusCheckBox;

View File

@ -61,13 +61,13 @@ class ActionBar extends PureComponent {
onFavourite: PropTypes.func.isRequired,
onReactionAdd: PropTypes.func.isRequired,
onBookmark: PropTypes.func.isRequired,
onMute: PropTypes.func,
onMuteConversation: PropTypes.func,
onBlock: PropTypes.func,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onMute: PropTypes.func,
onBlock: PropTypes.func,
onMuteConversation: PropTypes.func,
onReport: PropTypes.func,
onPin: PropTypes.func,
onEmbed: PropTypes.func,
@ -119,14 +119,14 @@ class ActionBar extends PureComponent {
this.props.onMute(this.props.status.get('account'));
};
handleConversationMuteClick = () => {
this.props.onMuteConversation(this.props.status);
};
handleBlockClick = () => {
this.props.onBlock(this.props.status);
};
handleConversationMuteClick = () => {
this.props.onMuteConversation(this.props.status);
};
handleReport = () => {
this.props.onReport(this.props.status);
};

View File

@ -3,13 +3,13 @@ import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import classnames from 'classnames';
import classNames from 'classnames';
import Immutable from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Blurhash } from 'flavours/glitch/components/blurhash';
import { Icon } from 'flavours/glitch/components/icon';
import { Icon } from 'flavours/glitch/components/icon';
import { useBlurhash } from 'flavours/glitch/initial_state';
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
@ -148,7 +148,7 @@ export default class Card extends PureComponent {
const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
const horizontal = (!compact && card.get('width') > card.get('height')) || card.get('type') !== 'link' || embedded;
const interactive = card.get('type') !== 'link';
const className = classnames('status-card', { horizontal, compact, interactive });
const className = classNames('status-card', { horizontal, compact, interactive });
const title = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
const language = card.get('language') || '';
@ -171,15 +171,17 @@ export default class Card extends PureComponent {
let embed = '';
let canvas = (
<Blurhash
className={classnames('status-card__image-preview', {
className={classNames('status-card__image-preview', {
'status-card__image-preview--hidden': revealed && this.state.previewLoaded,
})}
hash={card.get('blurhash')}
dummy={!useBlurhash}
/>
);
const thumbnailDescription = card.get('image_description');
const thumbnail = <img src={card.get('image')} alt={thumbnailDescription} title={thumbnailDescription} lang={language} style={thumbnailStyle} onLoad={this.handleImageLoad} className='status-card__image-image' />;
let spoilerButton = (
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>
@ -188,8 +190,9 @@ export default class Card extends PureComponent {
</span>
</button>
);
spoilerButton = (
<div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
<div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
{spoilerButton}
</div>
);
@ -209,15 +212,14 @@ export default class Card extends PureComponent {
{canvas}
{thumbnail}
{revealed && (
{revealed ? (
<div className='status-card__actions'>
<div>
<button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
<button type='button' onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
{horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
</div>
</div>
)}
{!revealed && spoilerButton}
) : spoilerButton}
</div>
);
}

View File

@ -12,7 +12,7 @@ import { AnimatedNumber } from 'flavours/glitch/components/animated_number';
import AttachmentList from 'flavours/glitch/components/attachment_list';
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar';
import { Icon } from 'flavours/glitch/components/icon';
import { Icon } from 'flavours/glitch/components/icon';
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
import PollContainer from 'flavours/glitch/containers/poll_container';

View File

@ -726,7 +726,7 @@ class Status extends ImmutablePureComponent {
bookmark: this.handleHotkeyBookmark,
mention: this.handleHotkeyMention,
openProfile: this.handleHotkeyOpenProfile,
toggleSpoiler: this.handleToggleHidden,
toggleHidden: this.handleToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
openMedia: this.handleHotkeyOpenMedia,
};

View File

@ -1,10 +1,10 @@
import PropTypes from 'prop-types';
import { Component } from 'react';
import { PureComponent } from 'react';
const emptyComponent = () => null;
const noop = () => { };
class Bundle extends Component {
class Bundle extends PureComponent {
static propTypes = {
fetchComponent: PropTypes.func.isRequired,
@ -26,7 +26,7 @@ class Bundle extends Component {
onFetchFail: noop,
};
static cache = {};
static cache = new Map;
state = {
mod: undefined,
@ -51,6 +51,7 @@ class Bundle extends Component {
load = (props) => {
const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
const cachedMod = Bundle.cache.get(fetchComponent);
if (fetchComponent === undefined) {
this.setState({ mod: null });
@ -59,10 +60,8 @@ class Bundle extends Component {
onFetch();
if (Bundle.cache[fetchComponent.name]) {
const mod = Bundle.cache[fetchComponent.name];
this.setState({ mod: mod.default });
if (cachedMod) {
this.setState({ mod: cachedMod.default });
onFetchSuccess();
return Promise.resolve();
}
@ -76,7 +75,7 @@ class Bundle extends Component {
return fetchComponent()
.then((mod) => {
Bundle.cache[fetchComponent.name] = mod;
Bundle.cache.set(fetchComponent, mod);
this.setState({ mod: mod.default });
onFetchSuccess();
})

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import { Component } from 'react';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
@ -11,7 +11,7 @@ const messages = defineMessages({
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
});
class BundleModalError extends Component {
class BundleModalError extends PureComponent {
static propTypes = {
onRetry: PropTypes.func.isRequired,

View File

@ -75,7 +75,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
this.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
}
componentWillUpdate(nextProps) {
UNSAFE_componentWillUpdate(nextProps) {
if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
this.node.removeEventListener('wheel', this.handleWheel);
}

View File

@ -19,7 +19,7 @@ import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
const messages = defineMessages({
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
});
class FavouriteModal extends ImmutablePureComponent {

View File

@ -96,24 +96,10 @@ class MediaModal extends ImmutablePureComponent {
componentDidMount () {
window.addEventListener('keydown', this.handleKeyDown, false);
this._sendBackgroundColor();
}
componentWillUnmount () {
window.removeEventListener('keydown', this.handleKeyDown);
this.props.onChangeBackgroundColor(null);
}
getIndex () {
return this.state.index !== null ? this.state.index : this.props.index;
}
toggleNavigation = () => {
this.setState(prevState => ({
navigationHidden: !prevState.navigationHidden,
}));
};
componentDidUpdate (prevProps, prevState) {
if (prevState.index !== this.state.index) {
this._sendBackgroundColor();
@ -131,6 +117,22 @@ class MediaModal extends ImmutablePureComponent {
}
}
componentWillUnmount () {
window.removeEventListener('keydown', this.handleKeyDown);
this.props.onChangeBackgroundColor(null);
}
getIndex () {
return this.state.index !== null ? this.state.index : this.props.index;
}
toggleNavigation = () => {
this.setState(prevState => ({
navigationHidden: !prevState.navigationHidden,
}));
};
render () {
const { media, statusId, lang, intl, onClose } = this.props;
const { navigationHidden } = this.state;

View File

@ -5,7 +5,6 @@ import { Helmet } from 'react-helmet';
import Base from 'flavours/glitch/components/modal_root';
import {
OnboardingModal,
MuteModal,
BlockModal,
ReportModal,
@ -40,7 +39,6 @@ import VideoModal from './video_modal';
export const MODAL_COMPONENTS = {
'MEDIA': () => Promise.resolve({ default: MediaModal }),
'ONBOARDING': OnboardingModal,
'VIDEO': () => Promise.resolve({ default: VideoModal }),
'AUDIO': () => Promise.resolve({ default: AudioModal }),
'IMAGE': () => Promise.resolve({ default: ImageModal }),

View File

@ -48,7 +48,7 @@ class NavigationPanel extends Component {
return match || location.pathname.startsWith('/public');
};
render() {
render () {
const { intl, onOpenSettings } = this.props;
const { signedIn, disabledAccountId } = this.context.identity;

View File

@ -1,328 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import ReactSwipeableViews from 'react-swipeable-views';
import Permalink from 'flavours/glitch/components/permalink';
import ComposeForm from 'flavours/glitch/features/compose/components/compose_form';
import DrawerAccount from 'flavours/glitch/features/compose/components/navigation_bar';
import Search from 'flavours/glitch/features/compose/components/search';
import { me, source_url } from 'flavours/glitch/initial_state';
import ColumnHeader from './column_header';
const noop = () => { };
const messages = defineMessages({
home_title: { id: 'column.home', defaultMessage: 'Home' },
notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
local_title: { id: 'column.community', defaultMessage: 'Local timeline' },
federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' },
});
const PageOne = ({ acct, domain }) => (
<div className='onboarding-modal__page onboarding-modal__page-one'>
<div style={{ flex: '0 0 auto' }}>
<div className='onboarding-modal__page-one__elephant-friend' />
</div>
<div>
<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>
);
PageOne.propTypes = {
acct: PropTypes.string.isRequired,
domain: PropTypes.string.isRequired,
};
const PageTwo = ({ myAccount }) => (
<div className='onboarding-modal__page onboarding-modal__page-two'>
<div className='figure non-interactive'>
<div className='pseudo-drawer'>
<DrawerAccount account={myAccount} />
<ComposeForm
privacy='public'
text='Awoo! #introductions'
spoilerText=''
suggestions={[]}
/>
</div>
</div>
<p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p>
</div>
);
PageTwo.propTypes = {
intl: PropTypes.object.isRequired,
myAccount: ImmutablePropTypes.map.isRequired,
};
const PageThree = ({ myAccount }) => (
<div className='onboarding-modal__page onboarding-modal__page-three'>
<div className='figure non-interactive'>
<Search
value=''
onChange={noop}
onSubmit={noop}
onClear={noop}
onShow={noop}
recent={{}}
/>
<div className='pseudo-drawer'>
<DrawerAccount account={myAccount} />
</div>
</div>
<p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }} /></p>
<p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p>
</div>
);
PageThree.propTypes = {
intl: PropTypes.object.isRequired,
myAccount: ImmutablePropTypes.map.isRequired,
};
const PageFour = ({ domain, intl }) => (
<div className='onboarding-modal__page onboarding-modal__page-four'>
<div className='onboarding-modal__page-four__columns'>
<div className='row'>
<div>
<div className='figure non-interactive'><ColumnHeader icon='home' type={intl.formatMessage(messages.home_title)} /></div>
<p><FormattedMessage id='onboarding.page_four.home' defaultMessage='The home timeline shows posts from people you follow.' /></p>
</div>
<div>
<div className='figure non-interactive'><ColumnHeader icon='bell' type={intl.formatMessage(messages.notifications_title)} /></div>
<p><FormattedMessage id='onboarding.page_four.notifications' defaultMessage='The notifications column shows when someone interacts with you.' /></p>
</div>
</div>
<div className='row'>
<div>
<div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='users' type={intl.formatMessage(messages.local_title)} /></div>
</div>
<div>
<div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='globe' type={intl.formatMessage(messages.federated_title)} /></div>
</div>
</div>
<p><FormattedMessage id='onboarding.page_five.public_timelines' defaultMessage='The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.' values={{ domain }} /></p>
</div>
</div>
);
PageFour.propTypes = {
domain: PropTypes.string.isRequired,
intl: PropTypes.object.isRequired,
};
const PageSix = ({ admin, domain }) => {
let adminSection = '';
if (admin) {
adminSection = (
<p>
<FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/@${admin.get('acct')}`}>@{admin.get('acct')}</Permalink> }} />
<br />
<FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage="Please read {domain}'s {guidelines}!" values={{ domain, guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }} />
</p>
);
}
return (
<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='{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/mastodon/mastodon' target='_blank' rel='noopener'>Mastodon</a>,
github: <a href={source_url} 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://joinmastodon.org/apps' 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>
);
};
PageSix.propTypes = {
admin: ImmutablePropTypes.map,
domain: PropTypes.string.isRequired,
};
const mapStateToProps = state => ({
myAccount: state.getIn(['accounts', me]),
admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
domain: state.getIn(['meta', 'domain']),
});
class OnboardingModal extends PureComponent {
static propTypes = {
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
myAccount: ImmutablePropTypes.map.isRequired,
domain: PropTypes.string.isRequired,
admin: ImmutablePropTypes.map,
};
state = {
currentIndex: 0,
};
UNSAFE_componentWillMount() {
const { myAccount, admin, domain, intl } = this.props;
this.pages = [
<PageOne key='1' acct={myAccount.get('acct')} domain={domain} />,
<PageTwo key='2' myAccount={myAccount} intl={intl} />,
<PageThree key='3' myAccount={myAccount} intl={intl} />,
<PageFour key='4' domain={domain} intl={intl} />,
<PageSix key='6' admin={admin} domain={domain} />,
];
}
componentDidMount() {
window.addEventListener('keyup', this.handleKeyUp);
}
componentWillUnmount() {
window.addEventListener('keyup', this.handleKeyUp);
}
handleSkip = (e) => {
e.preventDefault();
this.props.onClose();
};
handleDot = (e) => {
const i = Number(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.setState({ currentIndex: i });
};
handlePrev = () => {
this.setState(({ currentIndex }) => ({
currentIndex: Math.max(0, currentIndex - 1),
}));
};
handleNext = () => {
const { pages } = this;
this.setState(({ currentIndex }) => ({
currentIndex: Math.min(currentIndex + 1, pages.length - 1),
}));
};
handleSwipe = (index) => {
this.setState({ currentIndex: index });
};
handleKeyUp = ({ key }) => {
switch (key) {
case 'ArrowLeft':
this.handlePrev();
break;
case 'ArrowRight':
this.handleNext();
break;
}
};
handleClose = () => {
this.props.onClose();
};
render () {
const { pages } = this;
const { currentIndex } = this.state;
const hasMore = currentIndex < pages.length - 1;
const nextOrDoneBtn = hasMore ? (
<button
onClick={this.handleNext}
className='onboarding-modal__nav onboarding-modal__next'
>
<FormattedMessage id='onboarding.next' defaultMessage='Next' />
</button>
) : (
<button
onClick={this.handleClose}
className='onboarding-modal__nav onboarding-modal__done'
>
<FormattedMessage id='onboarding.done' defaultMessage='Done' />
</button>
);
return (
<div className='modal-root__modal onboarding-modal'>
<ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='onboarding-modal__pager'>
{pages.map((page, i) => {
const className = classNames('onboarding-modal__page__wrapper', {
'onboarding-modal__page__wrapper--active': i === currentIndex,
});
return (
<div key={i} className={className}>{page}</div>
);
})}
</ReactSwipeableViews>
<div className='onboarding-modal__paginator'>
<div>
<button
onClick={this.handleSkip}
className='onboarding-modal__nav onboarding-modal__skip'
>
<FormattedMessage id='onboarding.skip' defaultMessage='Skip' />
</button>
</div>
<div className='onboarding-modal__dots'>
{pages.map((_, i) => {
const className = classNames('onboarding-modal__dot', {
active: i === currentIndex,
});
return (
<div
key={`dot-${i}`}
role='button'
tabIndex={0}
data-index={i}
onClick={this.handleDot}
className={className}
/>
);
})}
</div>
<div>
{nextOrDoneBtn}
</div>
</div>
</div>
);
}
}
export default connect(mapStateToProps)(injectIntl(OnboardingModal));

View File

@ -40,14 +40,14 @@ export default class UploadArea extends PureComponent {
return (
<Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
{({ backgroundOpacity, backgroundScale }) =>
(<div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
{({ backgroundOpacity, backgroundScale }) => (
<div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
<div className='upload-area__drop'>
<div className='upload-area__background' style={{ transform: `scale(${backgroundScale})` }} />
<div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
</div>
</div>)
}
</div>
)}
</Motion>
);
}

View File

@ -7,26 +7,27 @@ import { NotificationStack } from 'react-notification';
import { dismissAlert } from '../../../actions/alerts';
import { getAlerts } from '../../../selectors';
const mapStateToProps = (state, { intl }) => {
const notifications = getAlerts(state);
const formatIfNeeded = (intl, message, values) => {
if (typeof message === 'object') {
return intl.formatMessage(message, values);
}
notifications.forEach(notification => ['title', 'message'].forEach(key => {
const value = notification[key];
if (typeof value === 'object') {
notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
}
}));
return { notifications };
return message;
};
const mapDispatchToProps = (dispatch) => {
return {
onDismiss: alert => {
dispatch(dismissAlert(alert));
},
};
};
const mapStateToProps = (state, { intl }) => ({
notifications: getAlerts(state).map(alert => ({
...alert,
action: formatIfNeeded(intl, alert.action, alert.values),
title: formatIfNeeded(intl, alert.title, alert.values),
message: formatIfNeeded(intl, alert.message, alert.values),
})),
});
const mapDispatchToProps = (dispatch) => ({
onDismiss (alert) {
dispatch(dismissAlert(alert));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import { PureComponent, Component } from 'react';
import { PureComponent } from 'react';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
@ -14,6 +14,7 @@ import { HotKeys } from 'react-hotkeys';
import { changeLayout } from 'flavours/glitch/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
import PermaLink from 'flavours/glitch/components/permalink';
import PictureInPicture from 'flavours/glitch/features/picture_in_picture';
import { layoutFromWindow } from 'flavours/glitch/is_mobile';
@ -62,14 +63,15 @@ import {
GettingStartedMisc,
Directory,
Explore,
FollowRecommendations,
Onboarding,
About,
PrivacyPolicy,
} from './util/async-components';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
// 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 '../../components/status';
const messages = defineMessages({
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
@ -86,7 +88,7 @@ const mapStateToProps = state => ({
showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']),
hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']),
moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]),
firstLaunch: false, // TODO: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
username: state.getIn(['accounts', me, 'username']),
});
@ -118,7 +120,7 @@ const keyMap = {
goToBlocked: 'g b',
goToMuted: 'g m',
goToRequests: 'g r',
toggleSpoiler: 'x',
toggleHidden: 'x',
bookmark: 'd',
toggleCollapse: 'shift+x',
toggleSensitive: 'h',
@ -216,7 +218,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/start' component={FollowRecommendations} content={children} />
<WrappedRoute path='/start' exact component={Onboarding} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} />
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
@ -254,7 +256,7 @@ class SwitchingColumnsArea extends PureComponent {
}
class UI extends Component {
class UI extends PureComponent {
static contextTypes = {
identity: PropTypes.object.isRequired,
@ -269,7 +271,6 @@ class UI extends Component {
hasComposingText: PropTypes.bool,
hasMediaAttachments: PropTypes.bool,
canUploadMore: PropTypes.bool,
match: PropTypes.object.isRequired,
intl: PropTypes.object.isRequired,
dropdownMenuIsOpen: PropTypes.bool,
unreadNotifications: PropTypes.number,
@ -286,7 +287,7 @@ class UI extends Component {
draggingOver: false,
};
handleBeforeUnload = (e) => {
handleBeforeUnload = e => {
const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props;
dispatch(synchronouslySubmitMarkers());
@ -299,6 +300,14 @@ class UI extends Component {
}
};
handleVisibilityChange = () => {
const visibility = !document[this.visibilityHiddenProp];
this.props.dispatch(notificationsSetVisibility(visibility));
if (visibility) {
this.props.dispatch(submitMarkers({ immediate: true }));
}
};
handleDragEnter = (e) => {
e.preventDefault();
@ -310,13 +319,14 @@ class UI extends Component {
this.dragTargets.push(e.target);
}
if (e.dataTransfer && e.dataTransfer.types.includes('Files') && this.props.canUploadMore && this.context.identity.signedIn) {
if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files') && this.props.canUploadMore && this.context.identity.signedIn) {
this.setState({ draggingOver: true });
}
};
handleDragOver = (e) => {
if (this.dataTransferIsText(e.dataTransfer)) return false;
e.preventDefault();
e.stopPropagation();
@ -371,14 +381,6 @@ class UI extends Component {
}
};
handleVisibilityChange = () => {
const visibility = !document[this.visibilityHiddenProp];
this.props.dispatch(notificationsSetVisibility(visibility));
if (visibility) {
this.props.dispatch(submitMarkers({ immediate: true }));
}
};
handleLayoutChange = debounce(() => {
this.props.dispatch(clearHeight()); // The cached heights are no longer accurate, invalidate
}, 500, {
@ -414,12 +416,6 @@ class UI extends Component {
this.favicon = new Favico({ animation:'none' });
// On first launch, redirect to the follow recommendations page
if (signedIn && this.props.firstLaunch) {
this.props.history.replace('/start');
// TODO: this.props.dispatch(closeOnboarding());
}
if (signedIn) {
this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline());

View File

@ -118,10 +118,6 @@ export function Mutes () {
return import(/* webpackChunkName: "flavours/glitch/async/mutes" */'flavours/glitch/features/mutes');
}
export function OnboardingModal () {
return import(/* webpackChunkName: "flavours/glitch/async/onboarding_modal" */'flavours/glitch/features/ui/components/onboarding_modal');
}
export function MuteModal () {
return import(/* webpackChunkName: "flavours/glitch/async/mute_modal" */'flavours/glitch/features/ui/components/mute_modal');
}
@ -170,8 +166,8 @@ export function Directory () {
return import(/* webpackChunkName: "features/glitch/async/directory" */'flavours/glitch/features/directory');
}
export function FollowRecommendations () {
return import(/* webpackChunkName: "features/glitch/async/follow_recommendations" */'flavours/glitch/features/follow_recommendations');
export function Onboarding () {
return import(/* webpackChunkName: "features/glitch/async/onboarding" */'flavours/glitch/features/onboarding');
}
export function CompareHistoryModal () {

View File

@ -1,17 +1,10 @@
{
"about.fork_disclaimer": "Glitch-YRYR is free open source software forked from Mastodon.",
"account.add_account_note": "Add note for @{name}",
"account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
"account.follows": "Follows",
"account.joined": "Joined {date}",
"account.mute_notifications": "Mute notifications from @{name}",
"account.suspended_disclaimer_full": "This user has been suspended by a moderator.",
"account.unmute_notifications": "Unmute notifications from @{name}",
"account.view_full_profile": "View full profile",
"account_note.cancel": "Cancel",
"account_note.edit": "Edit",
"account_note.glitch_placeholder": "No comment provided",
"account_note.save": "Save",
"advanced_options.icon_title": "Advanced options",
"advanced_options.local-only.long": "Do not post to other instances",
"advanced_options.local-only.short": "Local-only",
@ -49,14 +42,9 @@
"confirmations.unfilter.filters": "Matching {count, plural, one {filter} other {filters}}",
"content-type.change": "Content type",
"direct.group_by_conversations": "Group by conversation",
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
"endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
"favourite_modal.combo": "You can press {combo} to skip this next time",
"firehose.column_settings.allow_local_only": "Show local-only posts in \"All\"",
"follow_recommendations.done": "Done",
"follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.",
"follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!",
"getting_started.onboarding": "Show me around",
"home.column_settings.advanced": "Advanced",
"home.column_settings.filter_regex": "Filter out by regular expressions",
"home.column_settings.show_direct": "Show private mentions",
@ -81,26 +69,6 @@
"notifications.filter.reactions": "Reactions",
"notifications.marked_clear": "Clear selected notifications",
"notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
"onboarding.done": "Done",
"onboarding.next": "Next",
"onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
"onboarding.page_four.home": "The home timeline shows posts from people you follow.",
"onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
"onboarding.page_one.federation": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
"onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
"onboarding.page_one.welcome": "Welcome to {domain}!",
"onboarding.page_six.admin": "Your instance's admin is {admin}.",
"onboarding.page_six.almost_done": "Almost done...",
"onboarding.page_six.appetoot": "Bon Appetoot!",
"onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
"onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.",
"onboarding.page_six.guidelines": "community guidelines",
"onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
"onboarding.page_six.various_app": "mobile apps",
"onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
"onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
"onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
"onboarding.skip": "Skip",
"settings.always_show_spoilers_field": "Always enable the Content Warning field",
"settings.auto_collapse": "Automatic collapsing",
"settings.auto_collapse_all": "Everything",
@ -191,15 +159,5 @@
"status.react": "React",
"status.sensitive_toggle": "Click to view",
"status.uncollapse": "Uncollapse",
"tooltips.reactions": "Reactions",
"web_app_crash.change_your_settings": "Change your {settings}",
"web_app_crash.content": "You could try any of the following:",
"web_app_crash.debug_info": "Debug information",
"web_app_crash.disable_addons": "Disable browser add-ons or built-in translation tools",
"web_app_crash.issue_tracker": "issue tracker",
"web_app_crash.reload": "Reload",
"web_app_crash.reload_page": "{reload} the current page",
"web_app_crash.report_issue": "Report a bug in the {issuetracker}",
"web_app_crash.settings": "settings",
"web_app_crash.title": "We're sorry, but something went wrong with the Mastodon app."
"suggestions.dismiss": "Dismiss suggestion"
}

View File

@ -1,44 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import {
ACCOUNT_NOTE_INIT_EDIT,
ACCOUNT_NOTE_CANCEL,
ACCOUNT_NOTE_CHANGE_COMMENT,
ACCOUNT_NOTE_SUBMIT_REQUEST,
ACCOUNT_NOTE_SUBMIT_FAIL,
ACCOUNT_NOTE_SUBMIT_SUCCESS,
} from '../actions/account_notes';
const initialState = ImmutableMap({
edit: ImmutableMap({
isSubmitting: false,
account_id: null,
comment: null,
}),
});
export default function account_notes(state = initialState, action) {
switch (action.type) {
case ACCOUNT_NOTE_INIT_EDIT:
return state.withMutations((state) => {
state.setIn(['edit', 'isSubmitting'], false);
state.setIn(['edit', 'account_id'], action.account.get('id'));
state.setIn(['edit', 'comment'], action.comment);
});
case ACCOUNT_NOTE_CHANGE_COMMENT:
return state.setIn(['edit', 'comment'], action.comment);
case ACCOUNT_NOTE_SUBMIT_REQUEST:
return state.setIn(['edit', 'isSubmitting'], true);
case ACCOUNT_NOTE_SUBMIT_FAIL:
return state.setIn(['edit', 'isSubmitting'], false);
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
case ACCOUNT_NOTE_CANCEL:
return state.withMutations((state) => {
state.setIn(['edit', 'isSubmitting'], false);
state.setIn(['edit', 'account_id'], null);
state.setIn(['edit', 'comment'], null);
});
default:
return state;
}
}

View File

@ -1,5 +1,7 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { me } from 'flavours/glitch/initial_state';
import {
ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_SUCCESS,
@ -20,6 +22,14 @@ const normalizeAccounts = (state, accounts) => {
return state;
};
const incrementFollowers = (state, accountId) =>
state.updateIn([accountId, 'followers_count'], num => num + 1)
.updateIn([me, 'following_count'], num => num + 1);
const decrementFollowers = (state, accountId) =>
state.updateIn([accountId, 'followers_count'], num => Math.max(0, num - 1))
.updateIn([me, 'following_count'], num => Math.max(0, num - 1));
const initialState = ImmutableMap();
export default function accountsCounters(state = initialState, action) {
@ -30,9 +40,9 @@ export default function accountsCounters(state = initialState, action) {
return normalizeAccounts(state, action.accounts);
case ACCOUNT_FOLLOW_SUCCESS:
return action.alreadyFollowing ? state :
state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
incrementFollowers(state, action.relationship.id);
case ACCOUNT_UNFOLLOW_SUCCESS:
return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
return decrementFollowers(state, action.relationship.id);
default:
return state;
}

View File

@ -1,4 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { List as ImmutableList } from 'immutable';
import {
ALERT_SHOW,
@ -8,17 +8,20 @@ import {
const initialState = ImmutableList([]);
let id = 0;
const addAlert = (state, alert) =>
state.push({
key: id++,
...alert,
});
export default function alerts(state = initialState, action) {
switch(action.type) {
case ALERT_SHOW:
return state.push(ImmutableMap({
key: state.size > 0 ? state.last().get('key') + 1 : 0,
title: action.title,
message: action.message,
message_values: action.message_values,
}));
return addAlert(state, action.alert);
case ALERT_DISMISS:
return state.filterNot(item => item.get('key') === action.alert.key);
return state.filterNot(item => item.key === action.alert.key);
case ALERT_CLEAR:
return state.clear();
default:

View File

@ -51,6 +51,7 @@ import {
COMPOSE_CHANGE_MEDIA_DESCRIPTION,
COMPOSE_CHANGE_MEDIA_FOCUS,
COMPOSE_SET_STATUS,
COMPOSE_FOCUS,
} from '../actions/compose';
import { REDRAFT } from '../actions/statuses';
import { STORE_HYDRATE } from '../actions/store';
@ -651,6 +652,8 @@ export default function compose(state = initialState, action) {
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
case COMPOSE_LANGUAGE_CHANGE:
return state.set('language', action.language);
case COMPOSE_FOCUS:
return state.set('focusDate', new Date()).update('text', text => text.length > 0 ? text : action.defaultText);
default:
return state;
}

View File

@ -3,7 +3,6 @@ import { Record as ImmutableRecord } from 'immutable';
import { loadingBarReducer } from 'react-redux-loading-bar';
import { combineReducers } from 'redux-immutable';
import account_notes from './account_notes';
import accounts from './accounts';
import accounts_counters from './accounts_counters';
import accounts_map from './accounts_map';
@ -87,7 +86,6 @@ const reducers = {
polls,
trends,
markers,
account_notes,
picture_in_picture,
history,
tags,

View File

@ -1,7 +1,7 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import {
ACCOUNT_NOTE_SUBMIT_SUCCESS,
submitAccountNote,
} from '../actions/account_notes';
import {
ACCOUNT_FOLLOW_SUCCESS,
@ -73,10 +73,11 @@ export default function relationships(state = initialState, action) {
case ACCOUNT_UNMUTE_SUCCESS:
case ACCOUNT_PIN_SUCCESS:
case ACCOUNT_UNPIN_SUCCESS:
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
return normalizeRelationship(state, action.relationship);
case RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships);
case submitAccountNote.fulfilled:
return normalizeRelationship(state, action.payload.relationship);
case DOMAIN_BLOCK_SUCCESS:
return setDomainBlocking(state, action.accounts, true);
case DOMAIN_UNBLOCK_SUCCESS:

View File

@ -85,26 +85,16 @@ export const makeGetPictureInPicture = () => {
}));
};
const getAlertsBase = state => state.get('alerts');
const ALERT_DEFAULTS = {
dismissAfter: 5000,
style: false,
};
export const getAlerts = createSelector([getAlertsBase], (base) => {
let arr = [];
base.forEach(item => {
arr.push({
message: item.get('message'),
message_values: item.get('message_values'),
title: item.get('title'),
key: item.get('key'),
dismissAfter: 5000,
barStyle: {
zIndex: 200,
},
});
});
return arr;
});
export const getAlerts = createSelector(state => state.get('alerts'), alerts =>
alerts.map(item => ({
...ALERT_DEFAULTS,
...item,
})).toArray());
export const makeGetNotification = () => createSelector([
(_, base) => base,

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