Restore vanilla components
This commit is contained in:
parent
45c44989c8
commit
e19fc6a9f8
272 changed files with 27052 additions and 20 deletions
659
app/javascript/mastodon/actions/accounts.js
Normal file
659
app/javascript/mastodon/actions/accounts.js
Normal file
|
@ -0,0 +1,659 @@
|
|||
import api, { getLinks } from '../api';
|
||||
|
||||
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
|
||||
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
|
||||
export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL';
|
||||
|
||||
export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
|
||||
export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
|
||||
export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL';
|
||||
|
||||
export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
|
||||
export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
|
||||
export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL';
|
||||
|
||||
export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
|
||||
export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
|
||||
export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL';
|
||||
|
||||
export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
|
||||
export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
|
||||
export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL';
|
||||
|
||||
export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
|
||||
export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
|
||||
export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL';
|
||||
|
||||
export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
|
||||
export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
|
||||
export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL';
|
||||
|
||||
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
|
||||
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
|
||||
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST';
|
||||
export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS';
|
||||
export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL';
|
||||
|
||||
export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
|
||||
export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
|
||||
export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL';
|
||||
|
||||
export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
|
||||
export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
|
||||
export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL';
|
||||
|
||||
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
|
||||
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
|
||||
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
|
||||
export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS';
|
||||
export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL';
|
||||
|
||||
export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST';
|
||||
export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
|
||||
export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL';
|
||||
|
||||
export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
|
||||
export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
|
||||
export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
|
||||
|
||||
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
|
||||
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
|
||||
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
|
||||
|
||||
export function fetchAccount(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchRelationships([id]));
|
||||
|
||||
if (getState().getIn(['accounts', id], null) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchAccountRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
|
||||
dispatch(fetchAccountSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchAccountFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountSuccess(account) {
|
||||
return {
|
||||
type: ACCOUNT_FETCH_SUCCESS,
|
||||
account,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchAccountFail(id, error) {
|
||||
return {
|
||||
type: ACCOUNT_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
skipAlert: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function followAccount(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(followAccountRequest(id));
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => {
|
||||
dispatch(followAccountSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(followAccountFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function unfollowAccount(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(unfollowAccountRequest(id));
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
|
||||
dispatch(unfollowAccountSuccess(response.data, getState().get('statuses')));
|
||||
}).catch(error => {
|
||||
dispatch(unfollowAccountFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function followAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_FOLLOW_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function followAccountSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_FOLLOW_SUCCESS,
|
||||
relationship,
|
||||
};
|
||||
};
|
||||
|
||||
export function followAccountFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_FOLLOW_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function unfollowAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_UNFOLLOW_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function unfollowAccountSuccess(relationship, statuses) {
|
||||
return {
|
||||
type: ACCOUNT_UNFOLLOW_SUCCESS,
|
||||
relationship,
|
||||
statuses,
|
||||
};
|
||||
};
|
||||
|
||||
export function unfollowAccountFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_UNFOLLOW_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function blockAccount(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(blockAccountRequest(id));
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
|
||||
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
|
||||
dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
|
||||
}).catch(error => {
|
||||
dispatch(blockAccountFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockAccount(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(unblockAccountRequest(id));
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
|
||||
dispatch(unblockAccountSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(unblockAccountFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function blockAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_BLOCK_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function blockAccountSuccess(relationship, statuses) {
|
||||
return {
|
||||
type: ACCOUNT_BLOCK_SUCCESS,
|
||||
relationship,
|
||||
statuses,
|
||||
};
|
||||
};
|
||||
|
||||
export function blockAccountFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_BLOCK_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_UNBLOCK_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockAccountSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_UNBLOCK_SUCCESS,
|
||||
relationship,
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockAccountFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_UNBLOCK_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export function muteAccount(id, notifications) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(muteAccountRequest(id));
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
|
||||
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
|
||||
dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
|
||||
}).catch(error => {
|
||||
dispatch(muteAccountFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function unmuteAccount(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(unmuteAccountRequest(id));
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
|
||||
dispatch(unmuteAccountSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(unmuteAccountFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function muteAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_MUTE_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function muteAccountSuccess(relationship, statuses) {
|
||||
return {
|
||||
type: ACCOUNT_MUTE_SUCCESS,
|
||||
relationship,
|
||||
statuses,
|
||||
};
|
||||
};
|
||||
|
||||
export function muteAccountFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_MUTE_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function unmuteAccountRequest(id) {
|
||||
return {
|
||||
type: ACCOUNT_UNMUTE_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function unmuteAccountSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_UNMUTE_SUCCESS,
|
||||
relationship,
|
||||
};
|
||||
};
|
||||
|
||||
export function unmuteAccountFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_UNMUTE_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export function fetchFollowers(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFollowersRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFollowersFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowersRequest(id) {
|
||||
return {
|
||||
type: FOLLOWERS_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowersSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FOLLOWERS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowersFail(id, error) {
|
||||
return {
|
||||
type: FOLLOWERS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowers(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'followers', id, 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowersRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowersFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowersRequest(id) {
|
||||
return {
|
||||
type: FOLLOWERS_EXPAND_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowersSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FOLLOWERS_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowersFail(id, error) {
|
||||
return {
|
||||
type: FOLLOWERS_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowing(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFollowingRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFollowingFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowingRequest(id) {
|
||||
return {
|
||||
type: FOLLOWING_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowingSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FOLLOWING_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowingFail(id, error) {
|
||||
return {
|
||||
type: FOLLOWING_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowing(id) {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'following', id, 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowingRequest(id));
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(expandFollowingFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowingRequest(id) {
|
||||
return {
|
||||
type: FOLLOWING_EXPAND_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowingSuccess(id, accounts, next) {
|
||||
return {
|
||||
type: FOLLOWING_EXPAND_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowingFail(id, error) {
|
||||
return {
|
||||
type: FOLLOWING_EXPAND_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationships(accountIds) {
|
||||
return (dispatch, getState) => {
|
||||
const loadedRelationships = getState().get('relationships');
|
||||
const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
|
||||
|
||||
if (newAccountIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchRelationshipsRequest(newAccountIds));
|
||||
|
||||
api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
|
||||
dispatch(fetchRelationshipsSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchRelationshipsFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationshipsRequest(ids) {
|
||||
return {
|
||||
type: RELATIONSHIPS_FETCH_REQUEST,
|
||||
ids,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationshipsSuccess(relationships) {
|
||||
return {
|
||||
type: RELATIONSHIPS_FETCH_SUCCESS,
|
||||
relationships,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchRelationshipsFail(error) {
|
||||
return {
|
||||
type: RELATIONSHIPS_FETCH_FAIL,
|
||||
error,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowRequests() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFollowRequestsRequest());
|
||||
|
||||
api(getState).get('/api/v1/follow_requests').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => dispatch(fetchFollowRequestsFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowRequestsRequest() {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowRequestsSuccess(accounts, next) {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_FETCH_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFollowRequestsFail(error) {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowRequests() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'follow_requests', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFollowRequestsRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => dispatch(expandFollowRequestsFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowRequestsRequest() {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_EXPAND_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowRequestsSuccess(accounts, next) {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_EXPAND_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFollowRequestsFail(error) {
|
||||
return {
|
||||
type: FOLLOW_REQUESTS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function authorizeFollowRequest(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(authorizeFollowRequestRequest(id));
|
||||
|
||||
api(getState)
|
||||
.post(`/api/v1/follow_requests/${id}/authorize`)
|
||||
.then(() => dispatch(authorizeFollowRequestSuccess(id)))
|
||||
.catch(error => dispatch(authorizeFollowRequestFail(id, error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function authorizeFollowRequestRequest(id) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_AUTHORIZE_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function authorizeFollowRequestSuccess(id) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function authorizeFollowRequestFail(id, error) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export function rejectFollowRequest(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(rejectFollowRequestRequest(id));
|
||||
|
||||
api(getState)
|
||||
.post(`/api/v1/follow_requests/${id}/reject`)
|
||||
.then(() => dispatch(rejectFollowRequestSuccess(id)))
|
||||
.catch(error => dispatch(rejectFollowRequestFail(id, error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function rejectFollowRequestRequest(id) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_REJECT_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function rejectFollowRequestSuccess(id) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_REJECT_SUCCESS,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function rejectFollowRequestFail(id, error) {
|
||||
return {
|
||||
type: FOLLOW_REQUEST_REJECT_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
24
app/javascript/mastodon/actions/alerts.js
Normal file
24
app/javascript/mastodon/actions/alerts.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
export const ALERT_DISMISS = 'ALERT_DISMISS';
|
||||
export const ALERT_CLEAR = 'ALERT_CLEAR';
|
||||
|
||||
export function dismissAlert(alert) {
|
||||
return {
|
||||
type: ALERT_DISMISS,
|
||||
alert,
|
||||
};
|
||||
};
|
||||
|
||||
export function clearAlert() {
|
||||
return {
|
||||
type: ALERT_CLEAR,
|
||||
};
|
||||
};
|
||||
|
||||
export function showAlert(title, message) {
|
||||
return {
|
||||
type: ALERT_SHOW,
|
||||
title,
|
||||
message,
|
||||
};
|
||||
};
|
82
app/javascript/mastodon/actions/blocks.js
Normal file
82
app/javascript/mastodon/actions/blocks.js
Normal file
|
@ -0,0 +1,82 @@
|
|||
import api, { getLinks } from '../api';
|
||||
import { fetchRelationships } from './accounts';
|
||||
|
||||
export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
|
||||
export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
|
||||
export const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL';
|
||||
|
||||
export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
|
||||
export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
|
||||
export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL';
|
||||
|
||||
export function fetchBlocks() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchBlocksRequest());
|
||||
|
||||
api(getState).get('/api/v1/blocks').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => dispatch(fetchBlocksFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchBlocksRequest() {
|
||||
return {
|
||||
type: BLOCKS_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchBlocksSuccess(accounts, next) {
|
||||
return {
|
||||
type: BLOCKS_FETCH_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchBlocksFail(error) {
|
||||
return {
|
||||
type: BLOCKS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandBlocks() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'blocks', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandBlocksRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => dispatch(expandBlocksFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function expandBlocksRequest() {
|
||||
return {
|
||||
type: BLOCKS_EXPAND_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandBlocksSuccess(accounts, next) {
|
||||
return {
|
||||
type: BLOCKS_EXPAND_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandBlocksFail(error) {
|
||||
return {
|
||||
type: BLOCKS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
25
app/javascript/mastodon/actions/bundles.js
Normal file
25
app/javascript/mastodon/actions/bundles.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST';
|
||||
export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS';
|
||||
export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL';
|
||||
|
||||
export function fetchBundleRequest(skipLoading) {
|
||||
return {
|
||||
type: BUNDLE_FETCH_REQUEST,
|
||||
skipLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchBundleSuccess(skipLoading) {
|
||||
return {
|
||||
type: BUNDLE_FETCH_SUCCESS,
|
||||
skipLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchBundleFail(error, skipLoading) {
|
||||
return {
|
||||
type: BUNDLE_FETCH_FAIL,
|
||||
error,
|
||||
skipLoading,
|
||||
};
|
||||
}
|
52
app/javascript/mastodon/actions/cards.js
Normal file
52
app/javascript/mastodon/actions/cards.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import api from '../api';
|
||||
|
||||
export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
|
||||
export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';
|
||||
export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL';
|
||||
|
||||
export function fetchStatusCard(id) {
|
||||
return (dispatch, getState) => {
|
||||
if (getState().getIn(['cards', id], null) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchStatusCardRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
|
||||
if (!response.data.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchStatusCardSuccess(id, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusCardFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchStatusCardRequest(id) {
|
||||
return {
|
||||
type: STATUS_CARD_FETCH_REQUEST,
|
||||
id,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchStatusCardSuccess(id, card) {
|
||||
return {
|
||||
type: STATUS_CARD_FETCH_SUCCESS,
|
||||
id,
|
||||
card,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchStatusCardFail(id, error) {
|
||||
return {
|
||||
type: STATUS_CARD_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
skipLoading: true,
|
||||
skipAlert: true,
|
||||
};
|
||||
};
|
40
app/javascript/mastodon/actions/columns.js
Normal file
40
app/javascript/mastodon/actions/columns.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { saveSettings } from './settings';
|
||||
|
||||
export const COLUMN_ADD = 'COLUMN_ADD';
|
||||
export const COLUMN_REMOVE = 'COLUMN_REMOVE';
|
||||
export const COLUMN_MOVE = 'COLUMN_MOVE';
|
||||
|
||||
export function addColumn(id, params) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: COLUMN_ADD,
|
||||
id,
|
||||
params,
|
||||
});
|
||||
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
};
|
||||
|
||||
export function removeColumn(uuid) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: COLUMN_REMOVE,
|
||||
uuid,
|
||||
});
|
||||
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
};
|
||||
|
||||
export function moveColumn(uuid, direction) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: COLUMN_MOVE,
|
||||
uuid,
|
||||
direction,
|
||||
});
|
||||
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
};
|
376
app/javascript/mastodon/actions/compose.js
Normal file
376
app/javascript/mastodon/actions/compose.js
Normal file
|
@ -0,0 +1,376 @@
|
|||
import api from '../api';
|
||||
import { throttle } from 'lodash';
|
||||
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
|
||||
import { useEmoji } from './emojis';
|
||||
|
||||
import {
|
||||
updateTimeline,
|
||||
refreshHomeTimeline,
|
||||
refreshCommunityTimeline,
|
||||
refreshPublicTimeline,
|
||||
} from './timelines';
|
||||
|
||||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
||||
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
|
||||
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
|
||||
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
|
||||
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
|
||||
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
|
||||
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
|
||||
export const COMPOSE_RESET = 'COMPOSE_RESET';
|
||||
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
|
||||
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
|
||||
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||
|
||||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
|
||||
|
||||
export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
|
||||
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
|
||||
|
||||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
|
||||
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
|
||||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
|
||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
|
||||
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
|
||||
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
|
||||
|
||||
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
|
||||
|
||||
export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
|
||||
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
|
||||
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
|
||||
|
||||
export function changeCompose(text) {
|
||||
return {
|
||||
type: COMPOSE_CHANGE,
|
||||
text: text,
|
||||
};
|
||||
};
|
||||
|
||||
export function replyCompose(status, router) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_REPLY,
|
||||
status: status,
|
||||
});
|
||||
|
||||
if (!getState().getIn(['compose', 'mounted'])) {
|
||||
router.push('/statuses/new');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function cancelReplyCompose() {
|
||||
return {
|
||||
type: COMPOSE_REPLY_CANCEL,
|
||||
};
|
||||
};
|
||||
|
||||
export function resetCompose() {
|
||||
return {
|
||||
type: COMPOSE_RESET,
|
||||
};
|
||||
};
|
||||
|
||||
export function mentionCompose(account, router) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: COMPOSE_MENTION,
|
||||
account: account,
|
||||
});
|
||||
|
||||
if (!getState().getIn(['compose', 'mounted'])) {
|
||||
router.push('/statuses/new');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function submitCompose() {
|
||||
return function (dispatch, getState) {
|
||||
const status = getState().getIn(['compose', 'text'], '');
|
||||
|
||||
if (!status || !status.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(submitComposeRequest());
|
||||
|
||||
api(getState).post('/api/v1/statuses', {
|
||||
status,
|
||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
|
||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
|
||||
visibility: getState().getIn(['compose', 'privacy']),
|
||||
}, {
|
||||
headers: {
|
||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||
},
|
||||
}).then(function (response) {
|
||||
dispatch(submitComposeSuccess({ ...response.data }));
|
||||
|
||||
// To make the app more responsive, immediately get the status into the columns
|
||||
|
||||
const insertOrRefresh = (timelineId, refreshAction) => {
|
||||
if (getState().getIn(['timelines', timelineId, 'online'])) {
|
||||
dispatch(updateTimeline(timelineId, { ...response.data }));
|
||||
} else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
|
||||
dispatch(refreshAction());
|
||||
}
|
||||
};
|
||||
|
||||
insertOrRefresh('home', refreshHomeTimeline);
|
||||
|
||||
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
|
||||
insertOrRefresh('community', refreshCommunityTimeline);
|
||||
insertOrRefresh('public', refreshPublicTimeline);
|
||||
}
|
||||
}).catch(function (error) {
|
||||
dispatch(submitComposeFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function submitComposeRequest() {
|
||||
return {
|
||||
type: COMPOSE_SUBMIT_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitComposeSuccess(status) {
|
||||
return {
|
||||
type: COMPOSE_SUBMIT_SUCCESS,
|
||||
status: status,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitComposeFail(error) {
|
||||
return {
|
||||
type: COMPOSE_SUBMIT_FAIL,
|
||||
error: error,
|
||||
};
|
||||
};
|
||||
|
||||
export function uploadCompose(files) {
|
||||
return function (dispatch, getState) {
|
||||
if (getState().getIn(['compose', 'media_attachments']).size > 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(uploadComposeRequest());
|
||||
|
||||
let data = new FormData();
|
||||
data.append('file', files[0]);
|
||||
|
||||
api(getState).post('/api/v1/media', data, {
|
||||
onUploadProgress: function (e) {
|
||||
dispatch(uploadComposeProgress(e.loaded, e.total));
|
||||
},
|
||||
}).then(function (response) {
|
||||
dispatch(uploadComposeSuccess(response.data));
|
||||
}).catch(function (error) {
|
||||
dispatch(uploadComposeFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function changeUploadCompose(id, description) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(changeUploadComposeRequest());
|
||||
|
||||
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
|
||||
dispatch(changeUploadComposeSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(changeUploadComposeFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function changeUploadComposeRequest() {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_CHANGE_REQUEST,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
export function changeUploadComposeSuccess(media) {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||
media: media,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeUploadComposeFail(error) {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_CHANGE_FAIL,
|
||||
error: error,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function uploadComposeRequest() {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_REQUEST,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function uploadComposeProgress(loaded, total) {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_PROGRESS,
|
||||
loaded: loaded,
|
||||
total: total,
|
||||
};
|
||||
};
|
||||
|
||||
export function uploadComposeSuccess(media) {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_SUCCESS,
|
||||
media: media,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function uploadComposeFail(error) {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_FAIL,
|
||||
error: error,
|
||||
skipLoading: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function undoUploadCompose(media_id) {
|
||||
return {
|
||||
type: COMPOSE_UPLOAD_UNDO,
|
||||
media_id: media_id,
|
||||
};
|
||||
};
|
||||
|
||||
export function clearComposeSuggestions() {
|
||||
return {
|
||||
type: COMPOSE_SUGGESTIONS_CLEAR,
|
||||
};
|
||||
};
|
||||
|
||||
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
|
||||
api(getState).get('/api/v1/accounts/search', {
|
||||
params: {
|
||||
q: token.slice(1),
|
||||
resolve: false,
|
||||
limit: 4,
|
||||
},
|
||||
}).then(response => {
|
||||
dispatch(readyComposeSuggestionsAccounts(token, response.data));
|
||||
});
|
||||
}, 200, { leading: true, trailing: true });
|
||||
|
||||
const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
|
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
|
||||
dispatch(readyComposeSuggestionsEmojis(token, results));
|
||||
};
|
||||
|
||||
export function fetchComposeSuggestions(token) {
|
||||
return (dispatch, getState) => {
|
||||
if (token[0] === ':') {
|
||||
fetchComposeSuggestionsEmojis(dispatch, getState, token);
|
||||
} else {
|
||||
fetchComposeSuggestionsAccounts(dispatch, getState, token);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function readyComposeSuggestionsEmojis(token, emojis) {
|
||||
return {
|
||||
type: COMPOSE_SUGGESTIONS_READY,
|
||||
token,
|
||||
emojis,
|
||||
};
|
||||
};
|
||||
|
||||
export function readyComposeSuggestionsAccounts(token, accounts) {
|
||||
return {
|
||||
type: COMPOSE_SUGGESTIONS_READY,
|
||||
token,
|
||||
accounts,
|
||||
};
|
||||
};
|
||||
|
||||
export function selectComposeSuggestion(position, token, suggestion) {
|
||||
return (dispatch, getState) => {
|
||||
let completion, startPosition;
|
||||
|
||||
if (typeof suggestion === 'object' && suggestion.id) {
|
||||
completion = suggestion.native || suggestion.colons;
|
||||
startPosition = position - 1;
|
||||
|
||||
dispatch(useEmoji(suggestion));
|
||||
} else {
|
||||
completion = getState().getIn(['accounts', suggestion, 'acct']);
|
||||
startPosition = position;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_SUGGESTION_SELECT,
|
||||
position: startPosition,
|
||||
token,
|
||||
completion,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function mountCompose() {
|
||||
return {
|
||||
type: COMPOSE_MOUNT,
|
||||
};
|
||||
};
|
||||
|
||||
export function unmountCompose() {
|
||||
return {
|
||||
type: COMPOSE_UNMOUNT,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeComposeSensitivity() {
|
||||
return {
|
||||
type: COMPOSE_SENSITIVITY_CHANGE,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeComposeSpoilerness() {
|
||||
return {
|
||||
type: COMPOSE_SPOILERNESS_CHANGE,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeComposeSpoilerText(text) {
|
||||
return {
|
||||
type: COMPOSE_SPOILER_TEXT_CHANGE,
|
||||
text,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeComposeVisibility(value) {
|
||||
return {
|
||||
type: COMPOSE_VISIBILITY_CHANGE,
|
||||
value,
|
||||
};
|
||||
};
|
||||
|
||||
export function insertEmojiCompose(position, emoji) {
|
||||
return {
|
||||
type: COMPOSE_EMOJI_INSERT,
|
||||
position,
|
||||
emoji,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeComposing(value) {
|
||||
return {
|
||||
type: COMPOSE_COMPOSING_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
117
app/javascript/mastodon/actions/domain_blocks.js
Normal file
117
app/javascript/mastodon/actions/domain_blocks.js
Normal file
|
@ -0,0 +1,117 @@
|
|||
import api, { getLinks } from '../api';
|
||||
|
||||
export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST';
|
||||
export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS';
|
||||
export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL';
|
||||
|
||||
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
|
||||
export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS';
|
||||
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL';
|
||||
|
||||
export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
|
||||
export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS';
|
||||
export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL';
|
||||
|
||||
export function blockDomain(domain, accountId) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(blockDomainRequest(domain));
|
||||
|
||||
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
|
||||
dispatch(blockDomainSuccess(domain, accountId));
|
||||
}).catch(err => {
|
||||
dispatch(blockDomainFail(domain, err));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function blockDomainRequest(domain) {
|
||||
return {
|
||||
type: DOMAIN_BLOCK_REQUEST,
|
||||
domain,
|
||||
};
|
||||
};
|
||||
|
||||
export function blockDomainSuccess(domain, accountId) {
|
||||
return {
|
||||
type: DOMAIN_BLOCK_SUCCESS,
|
||||
domain,
|
||||
accountId,
|
||||
};
|
||||
};
|
||||
|
||||
export function blockDomainFail(domain, error) {
|
||||
return {
|
||||
type: DOMAIN_BLOCK_FAIL,
|
||||
domain,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockDomain(domain, accountId) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(unblockDomainRequest(domain));
|
||||
|
||||
api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
|
||||
dispatch(unblockDomainSuccess(domain, accountId));
|
||||
}).catch(err => {
|
||||
dispatch(unblockDomainFail(domain, err));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockDomainRequest(domain) {
|
||||
return {
|
||||
type: DOMAIN_UNBLOCK_REQUEST,
|
||||
domain,
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockDomainSuccess(domain, accountId) {
|
||||
return {
|
||||
type: DOMAIN_UNBLOCK_SUCCESS,
|
||||
domain,
|
||||
accountId,
|
||||
};
|
||||
};
|
||||
|
||||
export function unblockDomainFail(domain, error) {
|
||||
return {
|
||||
type: DOMAIN_UNBLOCK_FAIL,
|
||||
domain,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchDomainBlocks() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchDomainBlocksRequest());
|
||||
|
||||
api(getState).get().then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(err => {
|
||||
dispatch(fetchDomainBlocksFail(err));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchDomainBlocksRequest() {
|
||||
return {
|
||||
type: DOMAIN_BLOCKS_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchDomainBlocksSuccess(domains, next) {
|
||||
return {
|
||||
type: DOMAIN_BLOCKS_FETCH_SUCCESS,
|
||||
domains,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchDomainBlocksFail(error) {
|
||||
return {
|
||||
type: DOMAIN_BLOCKS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
14
app/javascript/mastodon/actions/emojis.js
Normal file
14
app/javascript/mastodon/actions/emojis.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { saveSettings } from './settings';
|
||||
|
||||
export const EMOJI_USE = 'EMOJI_USE';
|
||||
|
||||
export function useEmoji(emoji) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: EMOJI_USE,
|
||||
emoji,
|
||||
});
|
||||
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
};
|
83
app/javascript/mastodon/actions/favourites.js
Normal file
83
app/javascript/mastodon/actions/favourites.js
Normal file
|
@ -0,0 +1,83 @@
|
|||
import api, { getLinks } from '../api';
|
||||
|
||||
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
|
||||
export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
|
||||
export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL';
|
||||
|
||||
export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
|
||||
export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
|
||||
export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL';
|
||||
|
||||
export function fetchFavouritedStatuses() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFavouritedStatusesRequest());
|
||||
|
||||
api(getState).get('/api/v1/favourites').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFavouritedStatusesFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritedStatusesRequest() {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritedStatusesSuccess(statuses, next) {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_FETCH_SUCCESS,
|
||||
statuses,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritedStatusesFail(error) {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFavouritedStatuses() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandFavouritedStatusesRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandFavouritedStatusesFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFavouritedStatusesRequest() {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_EXPAND_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFavouritedStatusesSuccess(statuses, next) {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
|
||||
statuses,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandFavouritedStatusesFail(error) {
|
||||
return {
|
||||
type: FAVOURITED_STATUSES_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
17
app/javascript/mastodon/actions/height_cache.js
Normal file
17
app/javascript/mastodon/actions/height_cache.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET';
|
||||
export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR';
|
||||
|
||||
export function setHeight (key, id, height) {
|
||||
return {
|
||||
type: HEIGHT_CACHE_SET,
|
||||
key,
|
||||
id,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
export function clearHeight () {
|
||||
return {
|
||||
type: HEIGHT_CACHE_CLEAR,
|
||||
};
|
||||
};
|
313
app/javascript/mastodon/actions/interactions.js
Normal file
313
app/javascript/mastodon/actions/interactions.js
Normal file
|
@ -0,0 +1,313 @@
|
|||
import api from '../api';
|
||||
|
||||
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
|
||||
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
|
||||
export const REBLOG_FAIL = 'REBLOG_FAIL';
|
||||
|
||||
export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
|
||||
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
|
||||
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
|
||||
|
||||
export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
|
||||
export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
|
||||
export const UNREBLOG_FAIL = 'UNREBLOG_FAIL';
|
||||
|
||||
export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
|
||||
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
|
||||
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL';
|
||||
|
||||
export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
|
||||
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
|
||||
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL';
|
||||
|
||||
export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
|
||||
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
|
||||
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
|
||||
|
||||
export const PIN_REQUEST = 'PIN_REQUEST';
|
||||
export const PIN_SUCCESS = 'PIN_SUCCESS';
|
||||
export const PIN_FAIL = 'PIN_FAIL';
|
||||
|
||||
export const UNPIN_REQUEST = 'UNPIN_REQUEST';
|
||||
export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
|
||||
export const UNPIN_FAIL = 'UNPIN_FAIL';
|
||||
|
||||
export function reblog(status) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(reblogRequest(status));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) {
|
||||
// The reblog API method returns a new status wrapped around the original. In this case we are only
|
||||
// interested in how the original is modified, hence passing it skipping the wrapper
|
||||
dispatch(reblogSuccess(status, response.data.reblog));
|
||||
}).catch(function (error) {
|
||||
dispatch(reblogFail(status, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function unreblog(status) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(unreblogRequest(status));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
|
||||
dispatch(unreblogSuccess(status, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(unreblogFail(status, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function reblogRequest(status) {
|
||||
return {
|
||||
type: REBLOG_REQUEST,
|
||||
status: status,
|
||||
};
|
||||
};
|
||||
|
||||
export function reblogSuccess(status, response) {
|
||||
return {
|
||||
type: REBLOG_SUCCESS,
|
||||
status: status,
|
||||
response: response,
|
||||
};
|
||||
};
|
||||
|
||||
export function reblogFail(status, error) {
|
||||
return {
|
||||
type: REBLOG_FAIL,
|
||||
status: status,
|
||||
error: error,
|
||||
};
|
||||
};
|
||||
|
||||
export function unreblogRequest(status) {
|
||||
return {
|
||||
type: UNREBLOG_REQUEST,
|
||||
status: status,
|
||||
};
|
||||
};
|
||||
|
||||
export function unreblogSuccess(status, response) {
|
||||
return {
|
||||
type: UNREBLOG_SUCCESS,
|
||||
status: status,
|
||||
response: response,
|
||||
};
|
||||
};
|
||||
|
||||
export function unreblogFail(status, error) {
|
||||
return {
|
||||
type: UNREBLOG_FAIL,
|
||||
status: status,
|
||||
error: error,
|
||||
};
|
||||
};
|
||||
|
||||
export function favourite(status) {
|
||||
return function (dispatch, getState) {
|
||||
dispatch(favouriteRequest(status));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) {
|
||||
dispatch(favouriteSuccess(status, response.data));
|
||||
}).catch(function (error) {
|
||||
dispatch(favouriteFail(status, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function unfavourite(status) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(unfavouriteRequest(status));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => {
|
||||
dispatch(unfavouriteSuccess(status, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(unfavouriteFail(status, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function favouriteRequest(status) {
|
||||
return {
|
||||
type: FAVOURITE_REQUEST,
|
||||
status: status,
|
||||
};
|
||||
};
|
||||
|
||||
export function favouriteSuccess(status, response) {
|
||||
return {
|
||||
type: FAVOURITE_SUCCESS,
|
||||
status: status,
|
||||
response: response,
|
||||
};
|
||||
};
|
||||
|
||||
export function favouriteFail(status, error) {
|
||||
return {
|
||||
type: FAVOURITE_FAIL,
|
||||
status: status,
|
||||
error: error,
|
||||
};
|
||||
};
|
||||
|
||||
export function unfavouriteRequest(status) {
|
||||
return {
|
||||
type: UNFAVOURITE_REQUEST,
|
||||
status: status,
|
||||
};
|
||||
};
|
||||
|
||||
export function unfavouriteSuccess(status, response) {
|
||||
return {
|
||||
type: UNFAVOURITE_SUCCESS,
|
||||
status: status,
|
||||
response: response,
|
||||
};
|
||||
};
|
||||
|
||||
export function unfavouriteFail(status, error) {
|
||||
return {
|
||||
type: UNFAVOURITE_FAIL,
|
||||
status: status,
|
||||
error: error,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogs(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchReblogsRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
|
||||
dispatch(fetchReblogsSuccess(id, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchReblogsFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogsRequest(id) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogsSuccess(id, accounts) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchReblogsFail(id, error) {
|
||||
return {
|
||||
type: REBLOGS_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavourites(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchFavouritesRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
|
||||
dispatch(fetchFavouritesSuccess(id, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchFavouritesFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesRequest(id) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesSuccess(id, accounts) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_SUCCESS,
|
||||
id,
|
||||
accounts,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchFavouritesFail(id, error) {
|
||||
return {
|
||||
type: FAVOURITES_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function pin(status) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(pinRequest(status));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
|
||||
dispatch(pinSuccess(status, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(pinFail(status, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function pinRequest(status) {
|
||||
return {
|
||||
type: PIN_REQUEST,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export function pinSuccess(status, response) {
|
||||
return {
|
||||
type: PIN_SUCCESS,
|
||||
status,
|
||||
response,
|
||||
};
|
||||
};
|
||||
|
||||
export function pinFail(status, error) {
|
||||
return {
|
||||
type: PIN_FAIL,
|
||||
status,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function unpin (status) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(unpinRequest(status));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
|
||||
dispatch(unpinSuccess(status, response.data));
|
||||
}).catch(error => {
|
||||
dispatch(unpinFail(status, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function unpinRequest(status) {
|
||||
return {
|
||||
type: UNPIN_REQUEST,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export function unpinSuccess(status, response) {
|
||||
return {
|
||||
type: UNPIN_SUCCESS,
|
||||
status,
|
||||
response,
|
||||
};
|
||||
};
|
||||
|
||||
export function unpinFail(status, error) {
|
||||
return {
|
||||
type: UNPIN_FAIL,
|
||||
status,
|
||||
error,
|
||||
};
|
||||
};
|
16
app/javascript/mastodon/actions/modal.js
Normal file
16
app/javascript/mastodon/actions/modal.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
export const MODAL_OPEN = 'MODAL_OPEN';
|
||||
export const MODAL_CLOSE = 'MODAL_CLOSE';
|
||||
|
||||
export function openModal(type, props) {
|
||||
return {
|
||||
type: MODAL_OPEN,
|
||||
modalType: type,
|
||||
modalProps: props,
|
||||
};
|
||||
};
|
||||
|
||||
export function closeModal() {
|
||||
return {
|
||||
type: MODAL_CLOSE,
|
||||
};
|
||||
};
|
103
app/javascript/mastodon/actions/mutes.js
Normal file
103
app/javascript/mastodon/actions/mutes.js
Normal file
|
@ -0,0 +1,103 @@
|
|||
import api, { getLinks } from '../api';
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { openModal } from '../../mastodon/actions/modal';
|
||||
|
||||
export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
|
||||
export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
|
||||
export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL';
|
||||
|
||||
export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
|
||||
export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
|
||||
export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
|
||||
|
||||
export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
|
||||
export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
|
||||
|
||||
export function fetchMutes() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchMutesRequest());
|
||||
|
||||
api(getState).get('/api/v1/mutes').then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => dispatch(fetchMutesFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchMutesRequest() {
|
||||
return {
|
||||
type: MUTES_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchMutesSuccess(accounts, next) {
|
||||
return {
|
||||
type: MUTES_FETCH_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchMutesFail(error) {
|
||||
return {
|
||||
type: MUTES_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandMutes() {
|
||||
return (dispatch, getState) => {
|
||||
const url = getState().getIn(['user_lists', 'mutes', 'next']);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(expandMutesRequest());
|
||||
|
||||
api(getState).get(url).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
|
||||
dispatch(fetchRelationships(response.data.map(item => item.id)));
|
||||
}).catch(error => dispatch(expandMutesFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function expandMutesRequest() {
|
||||
return {
|
||||
type: MUTES_EXPAND_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandMutesSuccess(accounts, next) {
|
||||
return {
|
||||
type: MUTES_EXPAND_SUCCESS,
|
||||
accounts,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandMutesFail(error) {
|
||||
return {
|
||||
type: MUTES_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function initMuteModal(account) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: MUTES_INIT_MODAL,
|
||||
account,
|
||||
});
|
||||
|
||||
dispatch(openModal('MUTE'));
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleHideNotifications() {
|
||||
return dispatch => {
|
||||
dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
|
||||
};
|
||||
}
|
190
app/javascript/mastodon/actions/notifications.js
Normal file
190
app/javascript/mastodon/actions/notifications.js
Normal file
|
@ -0,0 +1,190 @@
|
|||
import api, { getLinks } from '../api';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import IntlMessageFormat from 'intl-messageformat';
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
|
||||
|
||||
export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
|
||||
export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
|
||||
export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL';
|
||||
|
||||
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
|
||||
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
|
||||
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
|
||||
|
||||
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
|
||||
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
|
||||
|
||||
defineMessages({
|
||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||
});
|
||||
|
||||
const fetchRelatedRelationships = (dispatch, notifications) => {
|
||||
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
|
||||
|
||||
if (accountIds > 0) {
|
||||
dispatch(fetchRelationships(accountIds));
|
||||
}
|
||||
};
|
||||
|
||||
const unescapeHTML = (html) => {
|
||||
const wrapper = document.createElement('div');
|
||||
html = html.replace(/<br \/>|<br>|\n/, ' ');
|
||||
wrapper.innerHTML = html;
|
||||
return wrapper.textContent;
|
||||
};
|
||||
|
||||
export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||
return (dispatch, getState) => {
|
||||
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
||||
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
||||
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_UPDATE,
|
||||
notification,
|
||||
account: notification.account,
|
||||
status: notification.status,
|
||||
meta: playSound ? { sound: 'boop' } : undefined,
|
||||
});
|
||||
|
||||
fetchRelatedRelationships(dispatch, [notification]);
|
||||
|
||||
// Desktop notifications
|
||||
if (typeof window.Notification !== 'undefined' && showAlert) {
|
||||
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
|
||||
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
|
||||
|
||||
const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
|
||||
notify.addEventListener('click', () => {
|
||||
window.focus();
|
||||
notify.close();
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
|
||||
|
||||
export function refreshNotifications() {
|
||||
return (dispatch, getState) => {
|
||||
const params = {};
|
||||
const ids = getState().getIn(['notifications', 'items']);
|
||||
|
||||
let skipLoading = false;
|
||||
|
||||
if (ids.size > 0) {
|
||||
params.since_id = ids.first().get('id');
|
||||
}
|
||||
|
||||
if (getState().getIn(['notifications', 'loaded'])) {
|
||||
skipLoading = true;
|
||||
}
|
||||
|
||||
params.exclude_types = excludeTypesFromSettings(getState());
|
||||
|
||||
dispatch(refreshNotificationsRequest(skipLoading));
|
||||
|
||||
api(getState).get('/api/v1/notifications', { params }).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
}).catch(error => {
|
||||
dispatch(refreshNotificationsFail(error, skipLoading));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function refreshNotificationsRequest(skipLoading) {
|
||||
return {
|
||||
type: NOTIFICATIONS_REFRESH_REQUEST,
|
||||
skipLoading,
|
||||
};
|
||||
};
|
||||
|
||||
export function refreshNotificationsSuccess(notifications, skipLoading, next) {
|
||||
return {
|
||||
type: NOTIFICATIONS_REFRESH_SUCCESS,
|
||||
notifications,
|
||||
accounts: notifications.map(item => item.account),
|
||||
statuses: notifications.map(item => item.status).filter(status => !!status),
|
||||
skipLoading,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function refreshNotificationsFail(error, skipLoading) {
|
||||
return {
|
||||
type: NOTIFICATIONS_REFRESH_FAIL,
|
||||
error,
|
||||
skipLoading,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandNotifications() {
|
||||
return (dispatch, getState) => {
|
||||
const items = getState().getIn(['notifications', 'items'], ImmutableList());
|
||||
|
||||
if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = {
|
||||
max_id: items.last().get('id'),
|
||||
limit: 20,
|
||||
exclude_types: excludeTypesFromSettings(getState()),
|
||||
};
|
||||
|
||||
dispatch(expandNotificationsRequest());
|
||||
|
||||
api(getState).get('/api/v1/notifications', { params }).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
}).catch(error => {
|
||||
dispatch(expandNotificationsFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function expandNotificationsRequest() {
|
||||
return {
|
||||
type: NOTIFICATIONS_EXPAND_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandNotificationsSuccess(notifications, next) {
|
||||
return {
|
||||
type: NOTIFICATIONS_EXPAND_SUCCESS,
|
||||
notifications,
|
||||
accounts: notifications.map(item => item.account),
|
||||
statuses: notifications.map(item => item.status).filter(status => !!status),
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandNotificationsFail(error) {
|
||||
return {
|
||||
type: NOTIFICATIONS_EXPAND_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function clearNotifications() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: NOTIFICATIONS_CLEAR,
|
||||
});
|
||||
|
||||
api(getState).post('/api/v1/notifications/clear');
|
||||
};
|
||||
};
|
||||
|
||||
export function scrollTopNotifications(top) {
|
||||
return {
|
||||
type: NOTIFICATIONS_SCROLL_TOP,
|
||||
top,
|
||||
};
|
||||
};
|
14
app/javascript/mastodon/actions/onboarding.js
Normal file
14
app/javascript/mastodon/actions/onboarding.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { openModal } from './modal';
|
||||
import { changeSetting, saveSettings } from './settings';
|
||||
|
||||
export function showOnboardingOnce() {
|
||||
return (dispatch, getState) => {
|
||||
const alreadySeen = getState().getIn(['settings', 'onboarded']);
|
||||
|
||||
if (!alreadySeen) {
|
||||
dispatch(openModal('ONBOARDING'));
|
||||
dispatch(changeSetting(['onboarded'], true));
|
||||
dispatch(saveSettings());
|
||||
}
|
||||
};
|
||||
};
|
40
app/javascript/mastodon/actions/pin_statuses.js
Normal file
40
app/javascript/mastodon/actions/pin_statuses.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
import api from '../api';
|
||||
|
||||
export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
|
||||
export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
|
||||
export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
|
||||
|
||||
import { me } from '../initial_state';
|
||||
|
||||
export function fetchPinnedStatuses() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchPinnedStatusesRequest());
|
||||
|
||||
api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => {
|
||||
dispatch(fetchPinnedStatusesSuccess(response.data, null));
|
||||
}).catch(error => {
|
||||
dispatch(fetchPinnedStatusesFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchPinnedStatusesRequest() {
|
||||
return {
|
||||
type: PINNED_STATUSES_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchPinnedStatusesSuccess(statuses, next) {
|
||||
return {
|
||||
type: PINNED_STATUSES_FETCH_SUCCESS,
|
||||
statuses,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchPinnedStatusesFail(error) {
|
||||
return {
|
||||
type: PINNED_STATUSES_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
52
app/javascript/mastodon/actions/push_notifications.js
Normal file
52
app/javascript/mastodon/actions/push_notifications.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import axios from 'axios';
|
||||
|
||||
export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
|
||||
export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
|
||||
export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
|
||||
export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE';
|
||||
|
||||
export function setBrowserSupport (value) {
|
||||
return {
|
||||
type: SET_BROWSER_SUPPORT,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function setSubscription (subscription) {
|
||||
return {
|
||||
type: SET_SUBSCRIPTION,
|
||||
subscription,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearSubscription () {
|
||||
return {
|
||||
type: CLEAR_SUBSCRIPTION,
|
||||
};
|
||||
}
|
||||
|
||||
export function changeAlerts(key, value) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: ALERTS_CHANGE,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
}
|
||||
|
||||
export function saveSettings() {
|
||||
return (_, getState) => {
|
||||
const state = getState().get('push_notifications');
|
||||
const subscription = state.get('subscription');
|
||||
const alerts = state.get('alerts');
|
||||
|
||||
axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
|
||||
data: {
|
||||
alerts,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
80
app/javascript/mastodon/actions/reports.js
Normal file
80
app/javascript/mastodon/actions/reports.js
Normal file
|
@ -0,0 +1,80 @@
|
|||
import api from '../api';
|
||||
import { openModal, closeModal } from './modal';
|
||||
|
||||
export const REPORT_INIT = 'REPORT_INIT';
|
||||
export const REPORT_CANCEL = 'REPORT_CANCEL';
|
||||
|
||||
export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
|
||||
export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
|
||||
export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL';
|
||||
|
||||
export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
|
||||
export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
|
||||
|
||||
export function initReport(account, status) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: REPORT_INIT,
|
||||
account,
|
||||
status,
|
||||
});
|
||||
|
||||
dispatch(openModal('REPORT'));
|
||||
};
|
||||
};
|
||||
|
||||
export function cancelReport() {
|
||||
return {
|
||||
type: REPORT_CANCEL,
|
||||
};
|
||||
};
|
||||
|
||||
export function toggleStatusReport(statusId, checked) {
|
||||
return {
|
||||
type: REPORT_STATUS_TOGGLE,
|
||||
statusId,
|
||||
checked,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitReport() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(submitReportRequest());
|
||||
|
||||
api(getState).post('/api/v1/reports', {
|
||||
account_id: getState().getIn(['reports', 'new', 'account_id']),
|
||||
status_ids: getState().getIn(['reports', 'new', 'status_ids']),
|
||||
comment: getState().getIn(['reports', 'new', 'comment']),
|
||||
}).then(response => {
|
||||
dispatch(closeModal());
|
||||
dispatch(submitReportSuccess(response.data));
|
||||
}).catch(error => dispatch(submitReportFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function submitReportRequest() {
|
||||
return {
|
||||
type: REPORT_SUBMIT_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitReportSuccess(report) {
|
||||
return {
|
||||
type: REPORT_SUBMIT_SUCCESS,
|
||||
report,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitReportFail(error) {
|
||||
return {
|
||||
type: REPORT_SUBMIT_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeReportComment(comment) {
|
||||
return {
|
||||
type: REPORT_COMMENT_CHANGE,
|
||||
comment,
|
||||
};
|
||||
};
|
73
app/javascript/mastodon/actions/search.js
Normal file
73
app/javascript/mastodon/actions/search.js
Normal file
|
@ -0,0 +1,73 @@
|
|||
import api from '../api';
|
||||
|
||||
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
|
||||
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
|
||||
export const SEARCH_SHOW = 'SEARCH_SHOW';
|
||||
|
||||
export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
|
||||
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
|
||||
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
|
||||
|
||||
export function changeSearch(value) {
|
||||
return {
|
||||
type: SEARCH_CHANGE,
|
||||
value,
|
||||
};
|
||||
};
|
||||
|
||||
export function clearSearch() {
|
||||
return {
|
||||
type: SEARCH_CLEAR,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitSearch() {
|
||||
return (dispatch, getState) => {
|
||||
const value = getState().getIn(['search', 'value']);
|
||||
|
||||
if (value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchSearchRequest());
|
||||
|
||||
api(getState).get('/api/v1/search', {
|
||||
params: {
|
||||
q: value,
|
||||
resolve: true,
|
||||
},
|
||||
}).then(response => {
|
||||
dispatch(fetchSearchSuccess(response.data));
|
||||
}).catch(error => {
|
||||
dispatch(fetchSearchFail(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSearchRequest() {
|
||||
return {
|
||||
type: SEARCH_FETCH_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSearchSuccess(results) {
|
||||
return {
|
||||
type: SEARCH_FETCH_SUCCESS,
|
||||
results,
|
||||
accounts: results.accounts,
|
||||
statuses: results.statuses,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSearchFail(error) {
|
||||
return {
|
||||
type: SEARCH_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function showSearch() {
|
||||
return {
|
||||
type: SEARCH_SHOW,
|
||||
};
|
||||
};
|
31
app/javascript/mastodon/actions/settings.js
Normal file
31
app/javascript/mastodon/actions/settings.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
import axios from 'axios';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
export const SETTING_CHANGE = 'SETTING_CHANGE';
|
||||
export const SETTING_SAVE = 'SETTING_SAVE';
|
||||
|
||||
export function changeSetting(key, value) {
|
||||
return dispatch => {
|
||||
dispatch({
|
||||
type: SETTING_CHANGE,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
|
||||
dispatch(saveSettings());
|
||||
};
|
||||
};
|
||||
|
||||
const debouncedSave = debounce((dispatch, getState) => {
|
||||
if (getState().getIn(['settings', 'saved'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
|
||||
|
||||
axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
|
||||
}, 5000, { trailing: true });
|
||||
|
||||
export function saveSettings() {
|
||||
return (dispatch, getState) => debouncedSave(dispatch, getState);
|
||||
};
|
217
app/javascript/mastodon/actions/statuses.js
Normal file
217
app/javascript/mastodon/actions/statuses.js
Normal file
|
@ -0,0 +1,217 @@
|
|||
import api from '../api';
|
||||
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
import { fetchStatusCard } from './cards';
|
||||
|
||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
|
||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
|
||||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
|
||||
|
||||
export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
|
||||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
|
||||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
|
||||
|
||||
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
|
||||
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
|
||||
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
|
||||
|
||||
export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
|
||||
export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
|
||||
export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
|
||||
|
||||
export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
|
||||
export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
|
||||
export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
|
||||
|
||||
export function fetchStatusRequest(id, skipLoading) {
|
||||
return {
|
||||
type: STATUS_FETCH_REQUEST,
|
||||
id,
|
||||
skipLoading,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchStatus(id) {
|
||||
return (dispatch, getState) => {
|
||||
const skipLoading = getState().getIn(['statuses', id], null) !== null;
|
||||
|
||||
dispatch(fetchContext(id));
|
||||
dispatch(fetchStatusCard(id));
|
||||
|
||||
if (skipLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchStatusRequest(id, skipLoading));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}`).then(response => {
|
||||
dispatch(fetchStatusSuccess(response.data, skipLoading));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error, skipLoading));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchStatusSuccess(status, skipLoading) {
|
||||
return {
|
||||
type: STATUS_FETCH_SUCCESS,
|
||||
status,
|
||||
skipLoading,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchStatusFail(id, error, skipLoading) {
|
||||
return {
|
||||
type: STATUS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
skipLoading,
|
||||
skipAlert: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function deleteStatus(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(deleteStatusRequest(id));
|
||||
|
||||
api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
|
||||
dispatch(deleteStatusSuccess(id));
|
||||
dispatch(deleteFromTimelines(id));
|
||||
}).catch(error => {
|
||||
dispatch(deleteStatusFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function deleteStatusRequest(id) {
|
||||
return {
|
||||
type: STATUS_DELETE_REQUEST,
|
||||
id: id,
|
||||
};
|
||||
};
|
||||
|
||||
export function deleteStatusSuccess(id) {
|
||||
return {
|
||||
type: STATUS_DELETE_SUCCESS,
|
||||
id: id,
|
||||
};
|
||||
};
|
||||
|
||||
export function deleteStatusFail(id, error) {
|
||||
return {
|
||||
type: STATUS_DELETE_FAIL,
|
||||
id: id,
|
||||
error: error,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContext(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(fetchContextRequest(id));
|
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
|
||||
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
|
||||
|
||||
}).catch(error => {
|
||||
if (error.response && error.response.status === 404) {
|
||||
dispatch(deleteFromTimelines(id));
|
||||
}
|
||||
|
||||
dispatch(fetchContextFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContextRequest(id) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContextSuccess(id, ancestors, descendants) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_SUCCESS,
|
||||
id,
|
||||
ancestors,
|
||||
descendants,
|
||||
statuses: ancestors.concat(descendants),
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchContextFail(id, error) {
|
||||
return {
|
||||
type: CONTEXT_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
skipAlert: true,
|
||||
};
|
||||
};
|
||||
|
||||
export function muteStatus(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(muteStatusRequest(id));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => {
|
||||
dispatch(muteStatusSuccess(id));
|
||||
}).catch(error => {
|
||||
dispatch(muteStatusFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function muteStatusRequest(id) {
|
||||
return {
|
||||
type: STATUS_MUTE_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function muteStatusSuccess(id) {
|
||||
return {
|
||||
type: STATUS_MUTE_SUCCESS,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function muteStatusFail(id, error) {
|
||||
return {
|
||||
type: STATUS_MUTE_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function unmuteStatus(id) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(unmuteStatusRequest(id));
|
||||
|
||||
api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => {
|
||||
dispatch(unmuteStatusSuccess(id));
|
||||
}).catch(error => {
|
||||
dispatch(unmuteStatusFail(id, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function unmuteStatusRequest(id) {
|
||||
return {
|
||||
type: STATUS_UNMUTE_REQUEST,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function unmuteStatusSuccess(id) {
|
||||
return {
|
||||
type: STATUS_UNMUTE_SUCCESS,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
export function unmuteStatusFail(id, error) {
|
||||
return {
|
||||
type: STATUS_UNMUTE_FAIL,
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
17
app/javascript/mastodon/actions/store.js
Normal file
17
app/javascript/mastodon/actions/store.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { Iterable, fromJS } from 'immutable';
|
||||
|
||||
export const STORE_HYDRATE = 'STORE_HYDRATE';
|
||||
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
|
||||
|
||||
const convertState = rawState =>
|
||||
fromJS(rawState, (k, v) =>
|
||||
Iterable.isIndexed(v) ? v.toList() : v.toMap());
|
||||
|
||||
export function hydrateStore(rawState) {
|
||||
const state = convertState(rawState);
|
||||
|
||||
return {
|
||||
type: STORE_HYDRATE,
|
||||
state,
|
||||
};
|
||||
};
|
53
app/javascript/mastodon/actions/streaming.js
Normal file
53
app/javascript/mastodon/actions/streaming.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { connectStream } from '../stream';
|
||||
import {
|
||||
updateTimeline,
|
||||
deleteFromTimelines,
|
||||
refreshHomeTimeline,
|
||||
connectTimeline,
|
||||
disconnectTimeline,
|
||||
} from './timelines';
|
||||
import { updateNotifications, refreshNotifications } from './notifications';
|
||||
import { getLocale } from '../locales';
|
||||
|
||||
const { messages } = getLocale();
|
||||
|
||||
export function connectTimelineStream (timelineId, path, pollingRefresh = null) {
|
||||
|
||||
return connectStream (path, pollingRefresh, (dispatch, getState) => {
|
||||
const locale = getState().getIn(['meta', 'locale']);
|
||||
return {
|
||||
onConnect() {
|
||||
dispatch(connectTimeline(timelineId));
|
||||
},
|
||||
|
||||
onDisconnect() {
|
||||
dispatch(disconnectTimeline(timelineId));
|
||||
},
|
||||
|
||||
onReceive (data) {
|
||||
switch(data.event) {
|
||||
case 'update':
|
||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
|
||||
break;
|
||||
case 'delete':
|
||||
dispatch(deleteFromTimelines(data.payload));
|
||||
break;
|
||||
case 'notification':
|
||||
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function refreshHomeTimelineAndNotification (dispatch) {
|
||||
dispatch(refreshHomeTimeline());
|
||||
dispatch(refreshNotifications());
|
||||
}
|
||||
|
||||
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
|
||||
export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
|
||||
export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
|
||||
export const connectPublicStream = () => connectTimelineStream('public', 'public');
|
||||
export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
|
206
app/javascript/mastodon/actions/timelines.js
Normal file
206
app/javascript/mastodon/actions/timelines.js
Normal file
|
@ -0,0 +1,206 @@
|
|||
import api, { getLinks } from '../api';
|
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||
|
||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
|
||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
|
||||
|
||||
export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST';
|
||||
export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS';
|
||||
export const TIMELINE_REFRESH_FAIL = 'TIMELINE_REFRESH_FAIL';
|
||||
|
||||
export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
|
||||
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
|
||||
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
|
||||
|
||||
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
|
||||
|
||||
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
||||
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
|
||||
|
||||
export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
|
||||
|
||||
export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
|
||||
return {
|
||||
type: TIMELINE_REFRESH_SUCCESS,
|
||||
timeline,
|
||||
statuses,
|
||||
skipLoading,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function updateTimeline(timeline, status) {
|
||||
return (dispatch, getState) => {
|
||||
const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
|
||||
const parents = [];
|
||||
|
||||
if (status.in_reply_to_id) {
|
||||
let parent = getState().getIn(['statuses', status.in_reply_to_id]);
|
||||
|
||||
while (parent && parent.get('in_reply_to_id')) {
|
||||
parents.push(parent.get('id'));
|
||||
parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_UPDATE,
|
||||
timeline,
|
||||
status,
|
||||
references,
|
||||
});
|
||||
|
||||
if (parents.length > 0) {
|
||||
dispatch({
|
||||
type: TIMELINE_CONTEXT_UPDATE,
|
||||
status,
|
||||
references: parents,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function deleteFromTimelines(id) {
|
||||
return (dispatch, getState) => {
|
||||
const accountId = getState().getIn(['statuses', id, 'account']);
|
||||
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
|
||||
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
|
||||
|
||||
dispatch({
|
||||
type: TIMELINE_DELETE,
|
||||
id,
|
||||
accountId,
|
||||
references,
|
||||
reblogOf,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function refreshTimelineRequest(timeline, skipLoading) {
|
||||
return {
|
||||
type: TIMELINE_REFRESH_REQUEST,
|
||||
timeline,
|
||||
skipLoading,
|
||||
};
|
||||
};
|
||||
|
||||
export function refreshTimeline(timelineId, path, params = {}) {
|
||||
return function (dispatch, getState) {
|
||||
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
|
||||
|
||||
if (timeline.get('isLoading') || timeline.get('online')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = timeline.get('items', ImmutableList());
|
||||
const newestId = ids.size > 0 ? ids.first() : null;
|
||||
|
||||
let skipLoading = timeline.get('loaded');
|
||||
|
||||
if (newestId !== null) {
|
||||
params.since_id = newestId;
|
||||
}
|
||||
|
||||
dispatch(refreshTimelineRequest(timelineId, skipLoading));
|
||||
|
||||
api(getState).get(path, { params }).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(refreshTimelineFail(timelineId, error, skipLoading));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
|
||||
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
|
||||
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
|
||||
export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
|
||||
export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
|
||||
export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
|
||||
|
||||
export function refreshTimelineFail(timeline, error, skipLoading) {
|
||||
return {
|
||||
type: TIMELINE_REFRESH_FAIL,
|
||||
timeline,
|
||||
error,
|
||||
skipLoading,
|
||||
skipAlert: error.response && error.response.status === 404,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandTimeline(timelineId, path, params = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
|
||||
const ids = timeline.get('items', ImmutableList());
|
||||
|
||||
if (timeline.get('isLoading') || ids.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
params.max_id = ids.last();
|
||||
params.limit = 10;
|
||||
|
||||
dispatch(expandTimelineRequest(timelineId));
|
||||
|
||||
api(getState).get(path, { params }).then(response => {
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null));
|
||||
}).catch(error => {
|
||||
dispatch(expandTimelineFail(timelineId, error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home');
|
||||
export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public');
|
||||
export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
|
||||
export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
|
||||
export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
|
||||
export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
|
||||
|
||||
export function expandTimelineRequest(timeline) {
|
||||
return {
|
||||
type: TIMELINE_EXPAND_REQUEST,
|
||||
timeline,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandTimelineSuccess(timeline, statuses, next) {
|
||||
return {
|
||||
type: TIMELINE_EXPAND_SUCCESS,
|
||||
timeline,
|
||||
statuses,
|
||||
next,
|
||||
};
|
||||
};
|
||||
|
||||
export function expandTimelineFail(timeline, error) {
|
||||
return {
|
||||
type: TIMELINE_EXPAND_FAIL,
|
||||
timeline,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function scrollTopTimeline(timeline, top) {
|
||||
return {
|
||||
type: TIMELINE_SCROLL_TOP,
|
||||
timeline,
|
||||
top,
|
||||
};
|
||||
};
|
||||
|
||||
export function connectTimeline(timeline) {
|
||||
return {
|
||||
type: TIMELINE_CONNECT,
|
||||
timeline,
|
||||
};
|
||||
};
|
||||
|
||||
export function disconnectTimeline(timeline) {
|
||||
return {
|
||||
type: TIMELINE_DISCONNECT,
|
||||
timeline,
|
||||
};
|
||||
};
|
26
app/javascript/mastodon/api.js
Normal file
26
app/javascript/mastodon/api.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import axios from 'axios';
|
||||
import LinkHeader from './link_header';
|
||||
|
||||
export const getLinks = response => {
|
||||
const value = response.headers.link;
|
||||
|
||||
if (!value) {
|
||||
return { refs: [] };
|
||||
}
|
||||
|
||||
return LinkHeader.parse(value);
|
||||
};
|
||||
|
||||
export default getState => axios.create({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`,
|
||||
},
|
||||
|
||||
transformResponse: [function (data) {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch(Exception) {
|
||||
return data;
|
||||
}
|
||||
}],
|
||||
});
|
18
app/javascript/mastodon/base_polyfills.js
Normal file
18
app/javascript/mastodon/base_polyfills.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import 'intl';
|
||||
import 'intl/locale-data/jsonp/en';
|
||||
import 'es6-symbol/implement';
|
||||
import includes from 'array-includes';
|
||||
import assign from 'object-assign';
|
||||
import isNaN from 'is-nan';
|
||||
|
||||
if (!Array.prototype.includes) {
|
||||
includes.shim();
|
||||
}
|
||||
|
||||
if (!Object.assign) {
|
||||
Object.assign = assign;
|
||||
}
|
||||
|
||||
if (!Number.isNaN) {
|
||||
Number.isNaN = isNaN;
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<Avatar /> Autoplay renders a animated avatar 1`] = `
|
||||
<div
|
||||
className="account__avatar"
|
||||
onMouseEnter={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"backgroundImage": "url(/animated/alice.gif)",
|
||||
"backgroundSize": "100px 100px",
|
||||
"height": "100px",
|
||||
"width": "100px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Avatar /> Still renders a still avatar 1`] = `
|
||||
<div
|
||||
className="account__avatar"
|
||||
onMouseEnter={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"backgroundImage": "url(/static/alice.jpg)",
|
||||
"backgroundSize": "100px 100px",
|
||||
"height": "100px",
|
||||
"width": "100px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
|
@ -0,0 +1,24 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<AvatarOverlay renders a overlay avatar 1`] = `
|
||||
<div
|
||||
className="account__avatar-overlay"
|
||||
>
|
||||
<div
|
||||
className="account__avatar-overlay-base"
|
||||
style={
|
||||
Object {
|
||||
"backgroundImage": "url(/static/alice.jpg)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="account__avatar-overlay-overlay"
|
||||
style={
|
||||
Object {
|
||||
"backgroundImage": "url(/static/eve.jpg)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,114 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = `
|
||||
<button
|
||||
className="button button-secondary"
|
||||
disabled={undefined}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders a button element 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
disabled={undefined}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
disabled={true}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders class="button--block" if props.block given 1`] = `
|
||||
<button
|
||||
className="button button--block"
|
||||
disabled={undefined}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the children 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
disabled={undefined}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
>
|
||||
<p>
|
||||
children
|
||||
</p>
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the given text 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
disabled={undefined}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
>
|
||||
foo
|
||||
</button>
|
||||
`;
|
||||
|
||||
exports[`<Button /> renders the props.text instead of children 1`] = `
|
||||
<button
|
||||
className="button"
|
||||
disabled={undefined}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"height": "36px",
|
||||
"lineHeight": "36px",
|
||||
"padding": "0 16px",
|
||||
}
|
||||
}
|
||||
>
|
||||
foo
|
||||
</button>
|
||||
`;
|
|
@ -0,0 +1,23 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<DisplayName /> renders display name + account name 1`] = `
|
||||
<span
|
||||
className="display-name"
|
||||
>
|
||||
<strong
|
||||
className="display-name__html"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<p>Foo</p>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
<span
|
||||
className="display-name__account"
|
||||
>
|
||||
@
|
||||
bar@baz
|
||||
</span>
|
||||
</span>
|
||||
`;
|
36
app/javascript/mastodon/components/__tests__/avatar-test.js
Normal file
36
app/javascript/mastodon/components/__tests__/avatar-test.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { fromJS } from 'immutable';
|
||||
import Avatar from '../avatar';
|
||||
|
||||
describe('<Avatar />', () => {
|
||||
const account = fromJS({
|
||||
username: 'alice',
|
||||
acct: 'alice',
|
||||
display_name: 'Alice',
|
||||
avatar: '/animated/alice.gif',
|
||||
avatar_static: '/static/alice.jpg',
|
||||
});
|
||||
|
||||
const size = 100;
|
||||
|
||||
describe('Autoplay', () => {
|
||||
it('renders a animated avatar', () => {
|
||||
const component = renderer.create(<Avatar account={account} animate size={size} />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Still', () => {
|
||||
it('renders a still avatar', () => {
|
||||
const component = renderer.create(<Avatar account={account} size={size} />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
// TODO add autoplay test if possible
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { fromJS } from 'immutable';
|
||||
import AvatarOverlay from '../avatar_overlay';
|
||||
|
||||
describe('<AvatarOverlay', () => {
|
||||
const account = fromJS({
|
||||
username: 'alice',
|
||||
acct: 'alice',
|
||||
display_name: 'Alice',
|
||||
avatar: '/animated/alice.gif',
|
||||
avatar_static: '/static/alice.jpg',
|
||||
});
|
||||
|
||||
const friend = fromJS({
|
||||
username: 'eve',
|
||||
acct: 'eve@blackhat.lair',
|
||||
display_name: 'Evelyn',
|
||||
avatar: '/animated/eve.gif',
|
||||
avatar_static: '/static/eve.jpg',
|
||||
});
|
||||
|
||||
it('renders a overlay avatar', () => {
|
||||
const component = renderer.create(<AvatarOverlay account={account} friend={friend} />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
75
app/javascript/mastodon/components/__tests__/button-test.js
Normal file
75
app/javascript/mastodon/components/__tests__/button-test.js
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import Button from '../button';
|
||||
|
||||
describe('<Button />', () => {
|
||||
it('renders a button element', () => {
|
||||
const component = renderer.create(<Button />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders the given text', () => {
|
||||
const text = 'foo';
|
||||
const component = renderer.create(<Button text={text} />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('handles click events using the given handler', () => {
|
||||
const handler = jest.fn();
|
||||
const button = shallow(<Button onClick={handler} />);
|
||||
button.find('button').simulate('click');
|
||||
|
||||
expect(handler.mock.calls.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('does not handle click events if props.disabled given', () => {
|
||||
const handler = jest.fn();
|
||||
const button = shallow(<Button onClick={handler} disabled />);
|
||||
button.find('button').simulate('click');
|
||||
|
||||
expect(handler.mock.calls.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('renders a disabled attribute if props.disabled given', () => {
|
||||
const component = renderer.create(<Button disabled />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders the children', () => {
|
||||
const children = <p>children</p>;
|
||||
const component = renderer.create(<Button>{children}</Button>);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders the props.text instead of children', () => {
|
||||
const text = 'foo';
|
||||
const children = <p>children</p>;
|
||||
const component = renderer.create(<Button text={text}>{children}</Button>);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders class="button--block" if props.block given', () => {
|
||||
const component = renderer.create(<Button block />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('adds class "button-secondary" if props.secondary given', () => {
|
||||
const component = renderer.create(<Button secondary />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { fromJS } from 'immutable';
|
||||
import DisplayName from '../display_name';
|
||||
|
||||
describe('<DisplayName />', () => {
|
||||
it('renders display name + account name', () => {
|
||||
const account = fromJS({
|
||||
username: 'bar',
|
||||
acct: 'bar@baz',
|
||||
display_name_html: '<p>Foo</p>',
|
||||
});
|
||||
const component = renderer.create(<DisplayName account={account} />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
116
app/javascript/mastodon/components/account.js
Normal file
116
app/javascript/mastodon/components/account.js
Normal file
|
@ -0,0 +1,116 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import Avatar from './avatar';
|
||||
import DisplayName from './display_name';
|
||||
import Permalink from './permalink';
|
||||
import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
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}' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class Account extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleFollow = () => {
|
||||
this.props.onFollow(this.props.account);
|
||||
}
|
||||
|
||||
handleBlock = () => {
|
||||
this.props.onBlock(this.props.account);
|
||||
}
|
||||
|
||||
handleMute = () => {
|
||||
this.props.onMute(this.props.account);
|
||||
}
|
||||
|
||||
handleMuteNotifications = () => {
|
||||
this.props.onMuteNotifications(this.props.account, true);
|
||||
}
|
||||
|
||||
handleUnmuteNotifications = () => {
|
||||
this.props.onMuteNotifications(this.props.account, false);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, intl, hidden } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<div>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
|
||||
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)} />;
|
||||
} else if (blocking) {
|
||||
buttons = <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
||||
} else if (muting) {
|
||||
let hidingNotificationsButton;
|
||||
if (muting.get('notifications')) {
|
||||
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />;
|
||||
} else {
|
||||
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />;
|
||||
}
|
||||
buttons = (
|
||||
<div>
|
||||
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
|
||||
{hidingNotificationsButton}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</Permalink>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
33
app/javascript/mastodon/components/attachment_list.js
Normal file
33
app/javascript/mastodon/components/attachment_list.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
|
||||
|
||||
export default class AttachmentList extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { media } = this.props;
|
||||
|
||||
return (
|
||||
<div className='attachment-list'>
|
||||
<div className='attachment-list__icon'>
|
||||
<i className='fa fa-link' />
|
||||
</div>
|
||||
|
||||
<ul className='attachment-list__list'>
|
||||
{media.map(attachment =>
|
||||
<li key={attachment.get('id')}>
|
||||
<a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
42
app/javascript/mastodon/components/autosuggest_emoji.js
Normal file
42
app/javascript/mastodon/components/autosuggest_emoji.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
|
||||
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
|
||||
export default class AutosuggestEmoji extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
emoji: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { emoji } = this.props;
|
||||
let url;
|
||||
|
||||
if (emoji.custom) {
|
||||
url = emoji.imageUrl;
|
||||
} else {
|
||||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
|
||||
|
||||
if (!mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
url = `${assetHost}/emoji/${mapping.filename}.svg`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-emoji'>
|
||||
<img
|
||||
className='emojione'
|
||||
src={url}
|
||||
alt={emoji.native || emoji.colons}
|
||||
/>
|
||||
|
||||
{emoji.colons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
222
app/javascript/mastodon/components/autosuggest_textarea.js
Normal file
222
app/javascript/mastodon/components/autosuggest_textarea.js
Normal file
|
@ -0,0 +1,222 @@
|
|||
import React from 'react';
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
import AutosuggestEmoji from './autosuggest_emoji';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isRtl } from '../rtl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||
let word;
|
||||
|
||||
let left = str.slice(0, caretPosition).search(/\S+$/);
|
||||
let right = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
word = str.slice(left);
|
||||
} else {
|
||||
word = str.slice(left, right + caretPosition);
|
||||
}
|
||||
|
||||
if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
word = word.trim().toLowerCase();
|
||||
|
||||
if (word.length > 0) {
|
||||
return [left + 1, word];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
||||
|
||||
export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
onSuggestionSelected: PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyUp: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
onPaste: PropTypes.func.isRequired,
|
||||
autoFocus: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
autoFocus: true,
|
||||
};
|
||||
|
||||
state = {
|
||||
suggestionsHidden: false,
|
||||
selectedSuggestion: 0,
|
||||
lastToken: null,
|
||||
tokenStart: 0,
|
||||
};
|
||||
|
||||
onChange = (e) => {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
||||
|
||||
if (token !== null && this.state.lastToken !== token) {
|
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
|
||||
this.props.onSuggestionsFetchRequested(token);
|
||||
} else if (token === null) {
|
||||
this.setState({ lastToken: null });
|
||||
this.props.onSuggestionsClearRequested();
|
||||
}
|
||||
|
||||
this.props.onChange(e);
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
const { suggestions, disabled } = this.props;
|
||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
if (!suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ suggestionsHidden: true });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
// Select suggestion
|
||||
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (e.defaultPrevented || !this.props.onKeyDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
|
||||
onKeyUp = e => {
|
||||
if (e.key === 'Escape' && this.state.suggestionsHidden) {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
}
|
||||
|
||||
if (this.props.onKeyUp) {
|
||||
this.props.onKeyUp(e);
|
||||
}
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.setState({ suggestionsHidden: true });
|
||||
}
|
||||
|
||||
onSuggestionClick = (e) => {
|
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.textarea.focus();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTextarea = (c) => {
|
||||
this.textarea = c;
|
||||
}
|
||||
|
||||
onPaste = (e) => {
|
||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
this.props.onPaste(e.clipboardData.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
renderSuggestion = (suggestion, i) => {
|
||||
const { selectedSuggestion } = this.state;
|
||||
let inner, key;
|
||||
|
||||
if (typeof suggestion === 'object') {
|
||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||
key = suggestion.id;
|
||||
} else {
|
||||
inner = <AutosuggestAccountContainer id={suggestion} />;
|
||||
key = suggestion;
|
||||
}
|
||||
|
||||
return (
|
||||
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
const style = { direction: 'ltr' };
|
||||
|
||||
if (isRtl(value)) {
|
||||
style.direction = 'rtl';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='autosuggest-textarea'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
|
||||
<Textarea
|
||||
inputRef={this.setTextarea}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={this.onKeyUp}
|
||||
onBlur={this.onBlur}
|
||||
onPaste={this.onPaste}
|
||||
style={style}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
71
app/javascript/mastodon/components/avatar.js
Normal file
71
app/javascript/mastodon/components/avatar.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
export default class Avatar extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
style: PropTypes.object,
|
||||
animate: PropTypes.bool,
|
||||
inline: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
animate: false,
|
||||
size: 20,
|
||||
inline: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
hovering: false,
|
||||
};
|
||||
|
||||
handleMouseEnter = () => {
|
||||
if (this.props.animate) return;
|
||||
this.setState({ hovering: true });
|
||||
}
|
||||
|
||||
handleMouseLeave = () => {
|
||||
if (this.props.animate) return;
|
||||
this.setState({ hovering: false });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, size, animate, inline } = this.props;
|
||||
const { hovering } = this.state;
|
||||
|
||||
const src = account.get('avatar');
|
||||
const staticSrc = account.get('avatar_static');
|
||||
|
||||
let className = 'account__avatar';
|
||||
|
||||
if (inline) {
|
||||
className = className + ' account__avatar-inline';
|
||||
}
|
||||
|
||||
const style = {
|
||||
...this.props.style,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundSize: `${size}px ${size}px`,
|
||||
};
|
||||
|
||||
if (hovering || animate) {
|
||||
style.backgroundImage = `url(${src})`;
|
||||
} else {
|
||||
style.backgroundImage = `url(${staticSrc})`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
30
app/javascript/mastodon/components/avatar_overlay.js
Normal file
30
app/javascript/mastodon/components/avatar_overlay.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
export default class AvatarOverlay extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
friend: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { account, friend } = this.props;
|
||||
|
||||
const baseStyle = {
|
||||
backgroundImage: `url(${account.get('avatar_static')})`,
|
||||
};
|
||||
|
||||
const overlayStyle = {
|
||||
backgroundImage: `url(${friend.get('avatar_static')})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='account__avatar-overlay'>
|
||||
<div className='account__avatar-overlay-base' style={baseStyle} />
|
||||
<div className='account__avatar-overlay-overlay' style={overlayStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
63
app/javascript/mastodon/components/button.js
Normal file
63
app/javascript/mastodon/components/button.js
Normal file
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class Button extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
text: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
block: PropTypes.bool,
|
||||
secondary: PropTypes.bool,
|
||||
size: PropTypes.number,
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
size: 36,
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
if (!this.props.disabled) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.node.focus();
|
||||
}
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
padding: `0 ${this.props.size / 2.25}px`,
|
||||
height: `${this.props.size}px`,
|
||||
lineHeight: `${this.props.size}px`,
|
||||
...this.props.style,
|
||||
};
|
||||
|
||||
const className = classNames('button', this.props.className, {
|
||||
'button-secondary': this.props.secondary,
|
||||
'button--block': this.props.block,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.handleClick}
|
||||
ref={this.setRef}
|
||||
style={style}
|
||||
>
|
||||
{this.props.text || this.props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
22
app/javascript/mastodon/components/collapsable.js
Normal file
22
app/javascript/mastodon/components/collapsable.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import Motion from '../features/ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const Collapsable = ({ fullHeight, isVisible, children }) => (
|
||||
<Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}>
|
||||
{({ opacity, height }) =>
|
||||
<div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
</Motion>
|
||||
);
|
||||
|
||||
Collapsable.propTypes = {
|
||||
fullHeight: PropTypes.number.isRequired,
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default Collapsable;
|
52
app/javascript/mastodon/components/column.js
Normal file
52
app/javascript/mastodon/components/column.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
import { scrollTop } from '../scroll';
|
||||
|
||||
export default class Column extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
scrollTop () {
|
||||
const scrollable = this.node.querySelector('.scrollable');
|
||||
|
||||
if (!scrollable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._interruptScrollAnimation = scrollTop(scrollable);
|
||||
}
|
||||
|
||||
handleWheel = () => {
|
||||
if (typeof this._interruptScrollAnimation !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
this._interruptScrollAnimation();
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.node.removeEventListener('wheel', this.handleWheel);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children } = this.props;
|
||||
|
||||
return (
|
||||
<div role='region' className='column' ref={this.setRef}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
28
app/javascript/mastodon/components/column_back_button.js
Normal file
28
app/javascript/mastodon/components/column_back_button.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class ColumnBackButton extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (window.history && window.history.length === 1) {
|
||||
this.context.router.history.push('/');
|
||||
} else {
|
||||
this.context.router.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<button onClick={this.handleClick} className='column-back-button'>
|
||||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class ColumnBackButtonSlim extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (window.history && window.history.length === 1) this.context.router.history.push('/');
|
||||
else this.context.router.history.goBack();
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div className='column-back-button--slim'>
|
||||
<div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
|
||||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
159
app/javascript/mastodon/components/column_header.js
Normal file
159
app/javascript/mastodon/components/column_header.js
Normal file
|
@ -0,0 +1,159 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
|
||||
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class ColumnHeader extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
active: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
focusable: PropTypes.bool,
|
||||
showBackButton: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
pinned: PropTypes.bool,
|
||||
onPin: PropTypes.func,
|
||||
onMove: PropTypes.func,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
focusable: true,
|
||||
}
|
||||
|
||||
state = {
|
||||
collapsed: true,
|
||||
animating: false,
|
||||
};
|
||||
|
||||
handleToggleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.setState({ collapsed: !this.state.collapsed, animating: true });
|
||||
}
|
||||
|
||||
handleTitleClick = () => {
|
||||
this.props.onClick();
|
||||
}
|
||||
|
||||
handleMoveLeft = () => {
|
||||
this.props.onMove(-1);
|
||||
}
|
||||
|
||||
handleMoveRight = () => {
|
||||
this.props.onMove(1);
|
||||
}
|
||||
|
||||
handleBackClick = () => {
|
||||
if (window.history && window.history.length === 1) this.context.router.history.push('/');
|
||||
else this.context.router.history.goBack();
|
||||
}
|
||||
|
||||
handleTransitionEnd = () => {
|
||||
this.setState({ animating: false });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { title, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage } } = this.props;
|
||||
const { collapsed, animating } = this.state;
|
||||
|
||||
const wrapperClassName = classNames('column-header__wrapper', {
|
||||
'active': active,
|
||||
});
|
||||
|
||||
const buttonClassName = classNames('column-header', {
|
||||
'active': active,
|
||||
});
|
||||
|
||||
const collapsibleClassName = classNames('column-header__collapsible', {
|
||||
'collapsed': collapsed,
|
||||
'animating': animating,
|
||||
});
|
||||
|
||||
const collapsibleButtonClassName = classNames('column-header__button', {
|
||||
'active': !collapsed,
|
||||
});
|
||||
|
||||
let extraContent, pinButton, moveButtons, backButton, collapseButton;
|
||||
|
||||
if (children) {
|
||||
extraContent = (
|
||||
<div key='extra-content' className='column-header__collapsible__extra'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (multiColumn && pinned) {
|
||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
|
||||
|
||||
moveButtons = (
|
||||
<div key='move-buttons' className='column-header__setting-arrows'>
|
||||
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button>
|
||||
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button>
|
||||
</div>
|
||||
);
|
||||
} else if (multiColumn) {
|
||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
|
||||
}
|
||||
|
||||
if (!pinned && (multiColumn || showBackButton)) {
|
||||
backButton = (
|
||||
<button onClick={this.handleBackClick} className='column-header__back-button'>
|
||||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const collapsedContent = [
|
||||
extraContent,
|
||||
];
|
||||
|
||||
if (multiColumn) {
|
||||
collapsedContent.push(moveButtons);
|
||||
collapsedContent.push(pinButton);
|
||||
}
|
||||
|
||||
if (children || multiColumn) {
|
||||
collapseButton = <button className={collapsibleButtonClassName} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
|
||||
<i className={`fa fa-fw fa-${icon} column-header__icon`} />
|
||||
<span className='column-header__title'>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
<div className='column-header__buttons'>
|
||||
{backButton}
|
||||
{collapseButton}
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
|
||||
<div className='column-header__collapsible-inner'>
|
||||
{(!collapsed || animating) && collapsedContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
20
app/javascript/mastodon/components/display_name.js
Normal file
20
app/javascript/mastodon/components/display_name.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
export default class DisplayName extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const displayNameHtml = { __html: this.props.account.get('display_name_html') };
|
||||
|
||||
return (
|
||||
<span className='display-name'>
|
||||
<strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
211
app/javascript/mastodon/components/dropdown_menu.js
Normal file
211
app/javascript/mastodon/components/dropdown_menu.js
Normal file
|
@ -0,0 +1,211 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import IconButton from './icon_button';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import Motion from '../features/ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
|
||||
class DropdownMenu extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
placement: PropTypes.string,
|
||||
arrowOffsetLeft: PropTypes.string,
|
||||
arrowOffsetTop: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
placement: 'bottom',
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
this.props.onClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action();
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
renderItem (option, i) {
|
||||
if (option === null) {
|
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, href = '#' } = option;
|
||||
|
||||
return (
|
||||
<li className='dropdown-menu__item' key={`${text}-${i}`}>
|
||||
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}>
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
|
||||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
|
||||
|
||||
<ul>
|
||||
{items.map((option, i) => this.renderItem(option, i))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default class Dropdown extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
ariaLabel: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
status: ImmutablePropTypes.map,
|
||||
isUserTouching: PropTypes.func,
|
||||
isModalOpen: PropTypes.bool.isRequired,
|
||||
onModalOpen: PropTypes.func,
|
||||
onModalClose: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
ariaLabel: 'Menu',
|
||||
};
|
||||
|
||||
state = {
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
|
||||
const { status, items } = this.props;
|
||||
|
||||
this.props.onModalOpen({
|
||||
status,
|
||||
actions: items,
|
||||
onClick: this.handleItemClick,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ expanded: !this.state.expanded });
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
if (this.props.onModalClose) {
|
||||
this.props.onModalClose();
|
||||
}
|
||||
|
||||
this.setState({ expanded: false });
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
switch(e.key) {
|
||||
case 'Enter':
|
||||
this.handleClick();
|
||||
break;
|
||||
case 'Escape':
|
||||
this.handleClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleItemClick = e => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const { action, to } = this.props.items[i];
|
||||
|
||||
this.handleClose();
|
||||
|
||||
if (typeof action === 'function') {
|
||||
e.preventDefault();
|
||||
action();
|
||||
} else if (to) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
}
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { icon, items, size, ariaLabel, disabled } = this.props;
|
||||
const { expanded } = this.state;
|
||||
|
||||
return (
|
||||
<div onKeyDown={this.handleKeyDown}>
|
||||
<IconButton
|
||||
icon={icon}
|
||||
title={ariaLabel}
|
||||
active={expanded}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
ref={this.setTargetRef}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
|
||||
<Overlay show={expanded} placement='bottom' target={this.findTarget}>
|
||||
<DropdownMenu items={items} onClose={this.handleClose} />
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
54
app/javascript/mastodon/components/extended_video_player.js
Normal file
54
app/javascript/mastodon/components/extended_video_player.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class ExtendedVideoPlayer extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
time: PropTypes.number,
|
||||
controls: PropTypes.bool.isRequired,
|
||||
muted: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
handleLoadedData = () => {
|
||||
if (this.props.time) {
|
||||
this.video.currentTime = this.props.time;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.video.addEventListener('loadeddata', this.handleLoadedData);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.video.removeEventListener('loadeddata', this.handleLoadedData);
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.video = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { src, muted, controls, alt } = this.props;
|
||||
|
||||
return (
|
||||
<div className='extended-video-player'>
|
||||
<video
|
||||
ref={this.setRef}
|
||||
src={src}
|
||||
autoPlay
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
aria-label={alt}
|
||||
muted={muted}
|
||||
controls={controls}
|
||||
loop={!controls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
114
app/javascript/mastodon/components/icon_button.js
Normal file
114
app/javascript/mastodon/components/icon_button.js
Normal file
|
@ -0,0 +1,114 @@
|
|||
import React from 'react';
|
||||
import Motion from '../features/ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class IconButton extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
size: PropTypes.number,
|
||||
active: PropTypes.bool,
|
||||
pressed: PropTypes.bool,
|
||||
expanded: PropTypes.bool,
|
||||
style: PropTypes.object,
|
||||
activeStyle: PropTypes.object,
|
||||
disabled: PropTypes.bool,
|
||||
inverted: PropTypes.bool,
|
||||
animate: PropTypes.bool,
|
||||
overlay: PropTypes.bool,
|
||||
tabIndex: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
size: 18,
|
||||
active: false,
|
||||
disabled: false,
|
||||
animate: false,
|
||||
overlay: false,
|
||||
tabIndex: '0',
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.props.disabled) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const style = {
|
||||
fontSize: `${this.props.size}px`,
|
||||
width: `${this.props.size * 1.28571429}px`,
|
||||
height: `${this.props.size * 1.28571429}px`,
|
||||
lineHeight: `${this.props.size}px`,
|
||||
...this.props.style,
|
||||
...(this.props.active ? this.props.activeStyle : {}),
|
||||
};
|
||||
|
||||
const {
|
||||
active,
|
||||
animate,
|
||||
className,
|
||||
disabled,
|
||||
expanded,
|
||||
icon,
|
||||
inverted,
|
||||
overlay,
|
||||
pressed,
|
||||
tabIndex,
|
||||
title,
|
||||
} = this.props;
|
||||
|
||||
const classes = classNames(className, 'icon-button', {
|
||||
active,
|
||||
disabled,
|
||||
inverted,
|
||||
overlayed: overlay,
|
||||
});
|
||||
|
||||
if (!animate) {
|
||||
// Perf optimization: avoid unnecessary <Motion> components unless
|
||||
// we actually need to animate.
|
||||
return (
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<i className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
|
||||
{({ rotate }) =>
|
||||
<button
|
||||
aria-label={title}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
className={classes}
|
||||
onClick={this.handleClick}
|
||||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
|
||||
</button>
|
||||
}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
|
||||
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
|
||||
import { is } from 'immutable';
|
||||
|
||||
// Diff these props in the "rendered" state
|
||||
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
|
||||
// Diff these props in the "unrendered" state
|
||||
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
|
||||
|
||||
export default class IntersectionObserverArticle extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
intersectionObserverWrapper: PropTypes.object.isRequired,
|
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
saveHeightKey: PropTypes.string,
|
||||
cachedHeight: PropTypes.number,
|
||||
onHeightChange: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
isHidden: false, // set to true in requestIdleCallback to trigger un-render
|
||||
}
|
||||
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
|
||||
const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
|
||||
if (!!isUnrendered !== !!willBeUnrendered) {
|
||||
// If we're going from rendered to unrendered (or vice versa) then update
|
||||
return true;
|
||||
}
|
||||
// Otherwise, diff based on props
|
||||
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
|
||||
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { intersectionObserverWrapper, id } = this.props;
|
||||
|
||||
intersectionObserverWrapper.observe(
|
||||
id,
|
||||
this.node,
|
||||
this.handleIntersection
|
||||
);
|
||||
|
||||
this.componentMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
const { intersectionObserverWrapper, id } = this.props;
|
||||
intersectionObserverWrapper.unobserve(id, this.node);
|
||||
|
||||
this.componentMounted = false;
|
||||
}
|
||||
|
||||
handleIntersection = (entry) => {
|
||||
this.entry = entry;
|
||||
|
||||
scheduleIdleTask(this.calculateHeight);
|
||||
this.setState(this.updateStateAfterIntersection);
|
||||
}
|
||||
|
||||
updateStateAfterIntersection = (prevState) => {
|
||||
if (prevState.isIntersecting && !this.entry.isIntersecting) {
|
||||
scheduleIdleTask(this.hideIfNotIntersecting);
|
||||
}
|
||||
return {
|
||||
isIntersecting: this.entry.isIntersecting,
|
||||
isHidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
calculateHeight = () => {
|
||||
const { onHeightChange, saveHeightKey, id } = this.props;
|
||||
// save the height of the fully-rendered element (this is expensive
|
||||
// on Chrome, where we need to fall back to getBoundingClientRect)
|
||||
this.height = getRectFromEntry(this.entry).height;
|
||||
|
||||
if (onHeightChange && saveHeightKey) {
|
||||
onHeightChange(saveHeightKey, id, this.height);
|
||||
}
|
||||
}
|
||||
|
||||
hideIfNotIntersecting = () => {
|
||||
if (!this.componentMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When the browser gets a chance, test if we're still not intersecting,
|
||||
// and if so, set our isHidden to true to trigger an unrender. The point of
|
||||
// this is to save DOM nodes and avoid using up too much memory.
|
||||
// See: https://github.com/tootsuite/mastodon/issues/2900
|
||||
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
|
||||
}
|
||||
|
||||
handleRef = (node) => {
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children, id, index, listLength, cachedHeight } = this.props;
|
||||
const { isIntersecting, isHidden } = this.state;
|
||||
|
||||
if (!isIntersecting && (isHidden || cachedHeight)) {
|
||||
return (
|
||||
<article
|
||||
ref={this.handleRef}
|
||||
aria-posinset={index}
|
||||
aria-setsize={listLength}
|
||||
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
|
||||
data-id={id}
|
||||
tabIndex='0'
|
||||
>
|
||||
{children && React.cloneElement(children, { hidden: true })}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'>
|
||||
{children && React.cloneElement(children, { hidden: false })}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
26
app/javascript/mastodon/components/load_more.js
Normal file
26
app/javascript/mastodon/components/load_more.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class LoadMore extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onClick: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
visible: true,
|
||||
}
|
||||
|
||||
render() {
|
||||
const { visible } = this.props;
|
||||
|
||||
return (
|
||||
<button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
|
||||
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
11
app/javascript/mastodon/components/loading_indicator.js
Normal file
11
app/javascript/mastodon/components/loading_indicator.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const LoadingIndicator = () => (
|
||||
<div className='loading-indicator'>
|
||||
<div className='loading-indicator__figure' />
|
||||
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LoadingIndicator;
|
278
app/javascript/mastodon/components/media_gallery.js
Normal file
278
app/javascript/mastodon/components/media_gallery.js
Normal file
|
@ -0,0 +1,278 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { is } from 'immutable';
|
||||
import IconButton from './icon_button';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { isIOS } from '../is_mobile';
|
||||
import classNames from 'classnames';
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
|
||||
});
|
||||
|
||||
class Item extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
attachment: ImmutablePropTypes.map.isRequired,
|
||||
standalone: PropTypes.bool,
|
||||
index: PropTypes.number.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
standalone: false,
|
||||
index: 0,
|
||||
size: 1,
|
||||
};
|
||||
|
||||
handleMouseEnter = (e) => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.play();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseLeave = (e) => {
|
||||
if (this.hoverToPlay()) {
|
||||
e.target.pause();
|
||||
e.target.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
hoverToPlay () {
|
||||
const { attachment } = this.props;
|
||||
return !autoPlayGif && attachment.get('type') === 'gifv';
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
const { index, onClick } = this.props;
|
||||
|
||||
if (this.context.router && e.button === 0) {
|
||||
e.preventDefault();
|
||||
onClick(index);
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { attachment, index, size, standalone } = this.props;
|
||||
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
let top = 'auto';
|
||||
let left = 'auto';
|
||||
let bottom = 'auto';
|
||||
let right = 'auto';
|
||||
|
||||
if (size === 1) {
|
||||
width = 100;
|
||||
}
|
||||
|
||||
if (size === 4 || (size === 3 && index > 0)) {
|
||||
height = 50;
|
||||
}
|
||||
|
||||
if (size === 2) {
|
||||
if (index === 0) {
|
||||
right = '2px';
|
||||
} else {
|
||||
left = '2px';
|
||||
}
|
||||
} else if (size === 3) {
|
||||
if (index === 0) {
|
||||
right = '2px';
|
||||
} else if (index > 0) {
|
||||
left = '2px';
|
||||
}
|
||||
|
||||
if (index === 1) {
|
||||
bottom = '2px';
|
||||
} else if (index > 1) {
|
||||
top = '2px';
|
||||
}
|
||||
} else if (size === 4) {
|
||||
if (index === 0 || index === 2) {
|
||||
right = '2px';
|
||||
}
|
||||
|
||||
if (index === 1 || index === 3) {
|
||||
left = '2px';
|
||||
}
|
||||
|
||||
if (index < 2) {
|
||||
bottom = '2px';
|
||||
} else {
|
||||
top = '2px';
|
||||
}
|
||||
}
|
||||
|
||||
let thumbnail = '';
|
||||
|
||||
if (attachment.get('type') === 'image') {
|
||||
const previewUrl = attachment.get('preview_url');
|
||||
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
||||
|
||||
const originalUrl = attachment.get('url');
|
||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||
|
||||
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
||||
|
||||
const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
|
||||
const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
|
||||
|
||||
thumbnail = (
|
||||
<a
|
||||
className='media-gallery__item-thumbnail'
|
||||
href={attachment.get('remote_url') || originalUrl}
|
||||
onClick={this.handleClick}
|
||||
target='_blank'
|
||||
>
|
||||
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
const autoPlay = !isIOS() && autoPlayGif;
|
||||
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.get('description')}
|
||||
role='application'
|
||||
src={attachment.get('url')}
|
||||
onClick={this.handleClick}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
autoPlay={autoPlay}
|
||||
loop
|
||||
muted
|
||||
/>
|
||||
|
||||
<span className='media-gallery__gifv__label'>GIF</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
|
||||
{thumbnail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
export default class MediaGallery extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
sensitive: PropTypes.bool,
|
||||
standalone: PropTypes.bool,
|
||||
media: ImmutablePropTypes.list.isRequired,
|
||||
size: PropTypes.object,
|
||||
height: PropTypes.number.isRequired,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
standalone: false,
|
||||
};
|
||||
|
||||
state = {
|
||||
visible: !this.props.sensitive,
|
||||
};
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (!is(nextProps.media, this.props.media)) {
|
||||
this.setState({ visible: !nextProps.sensitive });
|
||||
}
|
||||
}
|
||||
|
||||
handleOpen = () => {
|
||||
this.setState({ visible: !this.state.visible });
|
||||
}
|
||||
|
||||
handleClick = (index) => {
|
||||
this.props.onOpenMedia(this.props.media, index);
|
||||
}
|
||||
|
||||
handleRef = (node) => {
|
||||
if (node && this.isStandaloneEligible()) {
|
||||
// offsetWidth triggers a layout, so only calculate when we need to
|
||||
this.setState({
|
||||
width: node.offsetWidth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isStandaloneEligible() {
|
||||
const { media, standalone } = this.props;
|
||||
return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { media, intl, sensitive, height } = this.props;
|
||||
const { width, visible } = this.state;
|
||||
|
||||
let children;
|
||||
|
||||
const style = {};
|
||||
|
||||
if (this.isStandaloneEligible()) {
|
||||
if (!visible && width) {
|
||||
// only need to forcibly set the height in "sensitive" mode
|
||||
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
|
||||
} else {
|
||||
// layout automatically, using image's natural aspect ratio
|
||||
style.height = '';
|
||||
}
|
||||
} else {
|
||||
// crop the image
|
||||
style.height = height;
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
children = (
|
||||
<button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
|
||||
<span className='media-spoiler__warning'>{warning}</span>
|
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
const size = media.take(4).size;
|
||||
|
||||
if (this.isStandaloneEligible()) {
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery' style={style}>
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
|
||||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
12
app/javascript/mastodon/components/missing_indicator.js
Normal file
12
app/javascript/mastodon/components/missing_indicator.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const MissingIndicator = () => (
|
||||
<div className='missing-indicator'>
|
||||
<div>
|
||||
<FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MissingIndicator;
|
34
app/javascript/mastodon/components/permalink.js
Normal file
34
app/javascript/mastodon/components/permalink.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class Permalink extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
href: PropTypes.string.isRequired,
|
||||
to: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(this.props.to);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { href, children, className, ...other } = this.props;
|
||||
|
||||
return (
|
||||
<a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
147
app/javascript/mastodon/components/relative_timestamp.js
Normal file
147
app/javascript/mastodon/components/relative_timestamp.js
Normal file
|
@ -0,0 +1,147 @@
|
|||
import React from 'react';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const messages = defineMessages({
|
||||
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
|
||||
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
|
||||
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
|
||||
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
|
||||
days: { id: 'relative_time.days', defaultMessage: '{number}d' },
|
||||
});
|
||||
|
||||
const dateFormatOptions = {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
const shortDateFormatOptions = {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
};
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 1000 * 60;
|
||||
const HOUR = 1000 * 60 * 60;
|
||||
const DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
const MAX_DELAY = 2147483647;
|
||||
|
||||
const selectUnits = delta => {
|
||||
const absDelta = Math.abs(delta);
|
||||
|
||||
if (absDelta < MINUTE) {
|
||||
return 'second';
|
||||
} else if (absDelta < HOUR) {
|
||||
return 'minute';
|
||||
} else if (absDelta < DAY) {
|
||||
return 'hour';
|
||||
}
|
||||
|
||||
return 'day';
|
||||
};
|
||||
|
||||
const getUnitDelay = units => {
|
||||
switch (units) {
|
||||
case 'second':
|
||||
return SECOND;
|
||||
case 'minute':
|
||||
return MINUTE;
|
||||
case 'hour':
|
||||
return HOUR;
|
||||
case 'day':
|
||||
return DAY;
|
||||
default:
|
||||
return MAX_DELAY;
|
||||
}
|
||||
};
|
||||
|
||||
@injectIntl
|
||||
export default class RelativeTimestamp extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
timestamp: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
now: this.props.intl.now(),
|
||||
};
|
||||
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
// As of right now the locale doesn't change without a new page load,
|
||||
// but we might as well check in case that ever changes.
|
||||
return this.props.timestamp !== nextProps.timestamp ||
|
||||
this.props.intl.locale !== nextProps.intl.locale ||
|
||||
this.state.now !== nextState.now;
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (this.props.timestamp !== nextProps.timestamp) {
|
||||
this.setState({ now: this.props.intl.now() });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this._scheduleNextUpdate(this.props, this.state);
|
||||
}
|
||||
|
||||
componentWillUpdate (nextProps, nextState) {
|
||||
this._scheduleNextUpdate(nextProps, nextState);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
clearTimeout(this._timer);
|
||||
}
|
||||
|
||||
_scheduleNextUpdate (props, state) {
|
||||
clearTimeout(this._timer);
|
||||
|
||||
const { timestamp } = props;
|
||||
const delta = (new Date(timestamp)).getTime() - state.now;
|
||||
const unitDelay = getUnitDelay(selectUnits(delta));
|
||||
const unitRemainder = Math.abs(delta % unitDelay);
|
||||
const updateInterval = 1000 * 10;
|
||||
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
|
||||
|
||||
this._timer = setTimeout(() => {
|
||||
this.setState({ now: this.props.intl.now() });
|
||||
}, delay);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { timestamp, intl } = this.props;
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const delta = this.state.now - date.getTime();
|
||||
|
||||
let relativeTime;
|
||||
|
||||
if (delta < 10 * SECOND) {
|
||||
relativeTime = intl.formatMessage(messages.just_now);
|
||||
} else if (delta < 3 * DAY) {
|
||||
if (delta < MINUTE) {
|
||||
relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
|
||||
} else if (delta < HOUR) {
|
||||
relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
|
||||
} else if (delta < DAY) {
|
||||
relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
|
||||
} else {
|
||||
relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
|
||||
}
|
||||
} else {
|
||||
relativeTime = intl.formatDate(date, shortDateFormatOptions);
|
||||
}
|
||||
|
||||
return (
|
||||
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
|
||||
{relativeTime}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
198
app/javascript/mastodon/components/scrollable_list.js
Normal file
198
app/javascript/mastodon/components/scrollable_list.js
Normal file
|
@ -0,0 +1,198 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
import { ScrollContainer } from 'react-router-scroll-4';
|
||||
import PropTypes from 'prop-types';
|
||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
||||
import LoadMore from './load_more';
|
||||
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
|
||||
import { throttle } from 'lodash';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import classNames from 'classnames';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
||||
|
||||
export default class ScrollableList extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
scrollKey: PropTypes.string.isRequired,
|
||||
onScrollToBottom: PropTypes.func,
|
||||
onScrollToTop: PropTypes.func,
|
||||
onScroll: PropTypes.func,
|
||||
trackScroll: PropTypes.bool,
|
||||
shouldUpdateScroll: PropTypes.func,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
prepend: PropTypes.node,
|
||||
emptyMessage: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
trackScroll: true,
|
||||
};
|
||||
|
||||
state = {
|
||||
lastMouseMove: null,
|
||||
};
|
||||
|
||||
intersectionObserverWrapper = new IntersectionObserverWrapper();
|
||||
|
||||
handleScroll = throttle(() => {
|
||||
if (this.node) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = this.node;
|
||||
const offset = scrollHeight - scrollTop - clientHeight;
|
||||
this._oldScrollPosition = scrollHeight - scrollTop;
|
||||
|
||||
if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
|
||||
this.props.onScrollToBottom();
|
||||
} else if (scrollTop < 100 && this.props.onScrollToTop) {
|
||||
this.props.onScrollToTop();
|
||||
} else if (this.props.onScroll) {
|
||||
this.props.onScroll();
|
||||
}
|
||||
}
|
||||
}, 150, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
handleMouseMove = throttle(() => {
|
||||
this._lastMouseMove = new Date();
|
||||
}, 300);
|
||||
|
||||
handleMouseLeave = () => {
|
||||
this._lastMouseMove = null;
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.attachScrollListener();
|
||||
this.attachIntersectionObserver();
|
||||
attachFullscreenListener(this.onFullScreenChange);
|
||||
|
||||
// Handle initial scroll posiiton
|
||||
this.handleScroll();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
const someItemInserted = React.Children.count(prevProps.children) > 0 &&
|
||||
React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
|
||||
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
|
||||
|
||||
// Reset the scroll position when a new child comes in in order not to
|
||||
// jerk the scrollbar around if you're already scrolled down the page.
|
||||
if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
|
||||
const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
|
||||
|
||||
if (this.node.scrollTop !== newScrollTop) {
|
||||
this.node.scrollTop = newScrollTop;
|
||||
}
|
||||
} else {
|
||||
this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.detachScrollListener();
|
||||
this.detachIntersectionObserver();
|
||||
detachFullscreenListener(this.onFullScreenChange);
|
||||
}
|
||||
|
||||
onFullScreenChange = () => {
|
||||
this.setState({ fullscreen: isFullscreen() });
|
||||
}
|
||||
|
||||
attachIntersectionObserver () {
|
||||
this.intersectionObserverWrapper.connect({
|
||||
root: this.node,
|
||||
rootMargin: '300% 0px',
|
||||
});
|
||||
}
|
||||
|
||||
detachIntersectionObserver () {
|
||||
this.intersectionObserverWrapper.disconnect();
|
||||
}
|
||||
|
||||
attachScrollListener () {
|
||||
this.node.addEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
detachScrollListener () {
|
||||
this.node.removeEventListener('scroll', this.handleScroll);
|
||||
}
|
||||
|
||||
getFirstChildKey (props) {
|
||||
const { children } = props;
|
||||
let firstChild = children;
|
||||
if (children instanceof ImmutableList) {
|
||||
firstChild = children.get(0);
|
||||
} else if (Array.isArray(children)) {
|
||||
firstChild = children[0];
|
||||
}
|
||||
return firstChild && firstChild.key;
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
handleLoadMore = (e) => {
|
||||
e.preventDefault();
|
||||
this.props.onScrollToBottom();
|
||||
}
|
||||
|
||||
_recentlyMoved () {
|
||||
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
|
||||
const { fullscreen } = this.state;
|
||||
const childrenCount = React.Children.count(children);
|
||||
|
||||
const loadMore = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
|
||||
let scrollableArea = null;
|
||||
|
||||
if (isLoading || childrenCount > 0 || !emptyMessage) {
|
||||
scrollableArea = (
|
||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
|
||||
<div role='feed' className='item-list'>
|
||||
{prepend}
|
||||
|
||||
{React.Children.map(this.props.children, (child, index) => (
|
||||
<IntersectionObserverArticleContainer
|
||||
key={child.key}
|
||||
id={child.key}
|
||||
index={index}
|
||||
listLength={childrenCount}
|
||||
intersectionObserverWrapper={this.intersectionObserverWrapper}
|
||||
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
|
||||
>
|
||||
{child}
|
||||
</IntersectionObserverArticleContainer>
|
||||
))}
|
||||
|
||||
{loadMore}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
scrollableArea = (
|
||||
<div className='empty-column-indicator' ref={this.setRef}>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (trackScroll) {
|
||||
return (
|
||||
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
|
||||
{scrollableArea}
|
||||
</ScrollContainer>
|
||||
);
|
||||
} else {
|
||||
return scrollableArea;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
34
app/javascript/mastodon/components/setting_text.js
Normal file
34
app/javascript/mastodon/components/setting_text.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
export default class SettingText extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
settingKey: PropTypes.array.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
this.props.onChange(this.props.settingKey, e.target.value);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { settings, settingKey, label } = this.props;
|
||||
|
||||
return (
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{label}</span>
|
||||
<input
|
||||
className='setting-text'
|
||||
value={settings.getIn(settingKey)}
|
||||
onChange={this.handleChange}
|
||||
placeholder={label}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
246
app/javascript/mastodon/components/status.js
Normal file
246
app/javascript/mastodon/components/status.js
Normal file
|
@ -0,0 +1,246 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import Avatar from './avatar';
|
||||
import AvatarOverlay from './avatar_overlay';
|
||||
import RelativeTimestamp from './relative_timestamp';
|
||||
import DisplayName from './display_name';
|
||||
import StatusContent from './status_content';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { MediaGallery, Video } from '../features/ui/util/async-components';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import classNames from 'classnames';
|
||||
|
||||
// We use the component (and not the container) since we do not want
|
||||
// to use the progress bar to show download progress
|
||||
import Bundle from '../features/ui/components/bundle';
|
||||
|
||||
export default class Status extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map,
|
||||
account: ImmutablePropTypes.map,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
onOpenMedia: PropTypes.func,
|
||||
onOpenVideo: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
onEmbed: PropTypes.func,
|
||||
onHeightChange: PropTypes.func,
|
||||
muted: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
onMoveUp: PropTypes.func,
|
||||
onMoveDown: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
isExpanded: false,
|
||||
}
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
updateOnProps = [
|
||||
'status',
|
||||
'account',
|
||||
'muted',
|
||||
'hidden',
|
||||
]
|
||||
|
||||
updateOnStates = ['isExpanded']
|
||||
|
||||
handleClick = () => {
|
||||
if (!this.context.router) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = this.props;
|
||||
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
|
||||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (this.context.router && e.button === 0) {
|
||||
const id = e.currentTarget.getAttribute('data-id');
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleExpandedToggle = () => {
|
||||
this.setState({ isExpanded: !this.state.isExpanded });
|
||||
};
|
||||
|
||||
renderLoadingMediaGallery () {
|
||||
return <div className='media_gallery' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingVideoPlayer () {
|
||||
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
handleOpenVideo = startTime => {
|
||||
this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime);
|
||||
}
|
||||
|
||||
handleHotkeyReply = e => {
|
||||
e.preventDefault();
|
||||
this.props.onReply(this._properStatus(), this.context.router.history);
|
||||
}
|
||||
|
||||
handleHotkeyFavourite = () => {
|
||||
this.props.onFavourite(this._properStatus());
|
||||
}
|
||||
|
||||
handleHotkeyBoost = e => {
|
||||
this.props.onReblog(this._properStatus(), e);
|
||||
}
|
||||
|
||||
handleHotkeyMention = e => {
|
||||
e.preventDefault();
|
||||
this.props.onMention(this._properStatus().get('account'), this.context.router.history);
|
||||
}
|
||||
|
||||
handleHotkeyOpen = () => {
|
||||
this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
|
||||
}
|
||||
|
||||
handleHotkeyOpenProfile = () => {
|
||||
this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
|
||||
}
|
||||
|
||||
handleHotkeyMoveUp = () => {
|
||||
this.props.onMoveUp(this.props.status.get('id'));
|
||||
}
|
||||
|
||||
handleHotkeyMoveDown = () => {
|
||||
this.props.onMoveDown(this.props.status.get('id'));
|
||||
}
|
||||
|
||||
_properStatus () {
|
||||
const { status } = this.props;
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
return status.get('reblog');
|
||||
} else {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
let media = null;
|
||||
let statusAvatar, prepend;
|
||||
|
||||
const { hidden } = this.props;
|
||||
const { isExpanded } = this.state;
|
||||
|
||||
let { status, account, ...other } = this.props;
|
||||
|
||||
if (status === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<div>
|
||||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
|
||||
{status.get('content')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
|
||||
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
account = status.get('account');
|
||||
status = status.get('reblog');
|
||||
}
|
||||
|
||||
if (status.get('media_attachments').size > 0 && !this.props.muted) {
|
||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
||||
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const video = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
|
||||
{Component => <Component
|
||||
preview={video.get('preview_url')}
|
||||
src={video.get('url')}
|
||||
width={239}
|
||||
height={110}
|
||||
sensitive={status.get('sensitive')}
|
||||
onOpenVideo={this.handleOpenVideo}
|
||||
/>}
|
||||
</Bundle>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
|
||||
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />}
|
||||
</Bundle>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (account === undefined || account === null) {
|
||||
statusAvatar = <Avatar account={status.get('account')} size={48} />;
|
||||
}else{
|
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
|
||||
const handlers = this.props.muted ? {} : {
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
mention: this.handleHotkeyMention,
|
||||
open: this.handleHotkeyOpen,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
|
||||
{prepend}
|
||||
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
|
||||
<div className='status__info'>
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
|
||||
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</div>
|
||||
|
||||
<DisplayName account={status.get('account')} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
|
||||
|
||||
{media}
|
||||
|
||||
<StatusActionBar status={status} account={account} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
188
app/javascript/mastodon/components/status_action_bar.js
Normal file
188
app/javascript/mastodon/components/status_action_bar.js
Normal file
|
@ -0,0 +1,188 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from './icon_button';
|
||||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||
share: { id: 'status.share', defaultMessage: 'Share' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
|
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
|
||||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
onReport: PropTypes.func,
|
||||
onEmbed: PropTypes.func,
|
||||
onMuteConversation: PropTypes.func,
|
||||
onPin: PropTypes.func,
|
||||
withDismiss: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
updateOnProps = [
|
||||
'status',
|
||||
'withDismiss',
|
||||
]
|
||||
|
||||
handleReplyClick = () => {
|
||||
this.props.onReply(this.props.status, this.context.router.history);
|
||||
}
|
||||
|
||||
handleShareClick = () => {
|
||||
navigator.share({
|
||||
text: this.props.status.get('search_index'),
|
||||
url: this.props.status.get('url'),
|
||||
});
|
||||
}
|
||||
|
||||
handleFavouriteClick = () => {
|
||||
this.props.onFavourite(this.props.status);
|
||||
}
|
||||
|
||||
handleReblogClick = (e) => {
|
||||
this.props.onReblog(this.props.status, e);
|
||||
}
|
||||
|
||||
handleDeleteClick = () => {
|
||||
this.props.onDelete(this.props.status);
|
||||
}
|
||||
|
||||
handlePinClick = () => {
|
||||
this.props.onPin(this.props.status);
|
||||
}
|
||||
|
||||
handleMentionClick = () => {
|
||||
this.props.onMention(this.props.status.get('account'), this.context.router.history);
|
||||
}
|
||||
|
||||
handleMuteClick = () => {
|
||||
this.props.onMute(this.props.status.get('account'));
|
||||
}
|
||||
|
||||
handleBlockClick = () => {
|
||||
this.props.onBlock(this.props.status.get('account'));
|
||||
}
|
||||
|
||||
handleOpen = () => {
|
||||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
|
||||
}
|
||||
|
||||
handleEmbed = () => {
|
||||
this.props.onEmbed(this.props.status);
|
||||
}
|
||||
|
||||
handleReport = () => {
|
||||
this.props.onReport(this.props.status);
|
||||
}
|
||||
|
||||
handleConversationMuteClick = () => {
|
||||
this.props.onMuteConversation(this.props.status);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, intl, withDismiss } = this.props;
|
||||
|
||||
const mutingConversation = status.get('muted');
|
||||
const anonymousAccess = !me;
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
|
||||
let menu = [];
|
||||
let reblogIcon = 'retweet';
|
||||
let replyIcon;
|
||||
let replyTitle;
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
|
||||
|
||||
if (publicStatus) {
|
||||
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
|
||||
if (status.getIn(['account', 'id']) === me || withDismiss) {
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
menu.push(null);
|
||||
}
|
||||
|
||||
if (status.getIn(['account', 'id']) === me) {
|
||||
if (publicStatus) {
|
||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
|
||||
}
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
|
||||
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
|
||||
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
|
||||
}
|
||||
|
||||
if (status.get('visibility') === 'direct') {
|
||||
reblogIcon = 'envelope';
|
||||
} else if (status.get('visibility') === 'private') {
|
||||
reblogIcon = 'lock';
|
||||
}
|
||||
|
||||
if (status.get('in_reply_to_id', null) === null) {
|
||||
replyIcon = 'reply';
|
||||
replyTitle = intl.formatMessage(messages.reply);
|
||||
} else {
|
||||
replyIcon = 'reply-all';
|
||||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
|
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
|
||||
<IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
|
||||
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
||||
{shareButton}
|
||||
|
||||
<div className='status__action-bar-dropdown'>
|
||||
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
185
app/javascript/mastodon/components/status_content.js
Normal file
185
app/javascript/mastodon/components/status_content.js
Normal file
|
@ -0,0 +1,185 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isRtl } from '../rtl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Permalink from './permalink';
|
||||
import classnames from 'classnames';
|
||||
|
||||
export default class StatusContent extends React.PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
expanded: PropTypes.bool,
|
||||
onExpandedToggle: PropTypes.func,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
_updateStatusLinks () {
|
||||
const node = this.node;
|
||||
const links = node.querySelectorAll('a');
|
||||
|
||||
for (var i = 0; i < links.length; ++i) {
|
||||
let link = links[i];
|
||||
if (link.classList.contains('status-link')) {
|
||||
continue;
|
||||
}
|
||||
link.classList.add('status-link');
|
||||
|
||||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
|
||||
|
||||
if (mention) {
|
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||
link.setAttribute('title', mention.get('acct'));
|
||||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
} else {
|
||||
link.setAttribute('title', link.href);
|
||||
}
|
||||
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this._updateStatusLinks();
|
||||
}
|
||||
|
||||
componentDidUpdate () {
|
||||
this._updateStatusLinks();
|
||||
}
|
||||
|
||||
onMentionClick = (mention, e) => {
|
||||
if (this.context.router && e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${mention.get('id')}`);
|
||||
}
|
||||
}
|
||||
|
||||
onHashtagClick = (hashtag, e) => {
|
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase();
|
||||
|
||||
if (this.context.router && e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/timelines/tag/${hashtag}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseDown = (e) => {
|
||||
this.startXY = [e.clientX, e.clientY];
|
||||
}
|
||||
|
||||
handleMouseUp = (e) => {
|
||||
if (!this.startXY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [ startX, startY ] = this.startXY;
|
||||
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
|
||||
|
||||
if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
|
||||
this.props.onClick();
|
||||
}
|
||||
|
||||
this.startXY = null;
|
||||
}
|
||||
|
||||
handleSpoilerClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.props.onExpandedToggle) {
|
||||
// The parent manages the state
|
||||
this.props.onExpandedToggle();
|
||||
} else {
|
||||
this.setState({ hidden: !this.state.hidden });
|
||||
}
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status } = this.props;
|
||||
|
||||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
|
||||
|
||||
const content = { __html: status.get('contentHtml') };
|
||||
const spoilerContent = { __html: status.get('spoilerHtml') };
|
||||
const directionStyle = { direction: 'ltr' };
|
||||
const classNames = classnames('status__content', {
|
||||
'status__content--with-action': this.props.onClick && this.context.router,
|
||||
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
|
||||
});
|
||||
|
||||
if (isRtl(status.get('search_index'))) {
|
||||
directionStyle.direction = 'rtl';
|
||||
}
|
||||
|
||||
if (status.get('spoiler_text').length > 0) {
|
||||
let mentionsPlaceholder = '';
|
||||
|
||||
const mentionLinks = status.get('mentions').map(item => (
|
||||
<Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'>
|
||||
@<span>{item.get('username')}</span>
|
||||
</Permalink>
|
||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
|
||||
|
||||
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
|
||||
|
||||
if (hidden) {
|
||||
mentionsPlaceholder = <div>{mentionLinks}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
|
||||
<span dangerouslySetInnerHTML={spoilerContent} />
|
||||
{' '}
|
||||
<button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>{toggleText}</button>
|
||||
</p>
|
||||
|
||||
{mentionsPlaceholder}
|
||||
|
||||
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
|
||||
</div>
|
||||
);
|
||||
} else if (this.props.onClick) {
|
||||
return (
|
||||
<div
|
||||
ref={this.setRef}
|
||||
tabIndex='0'
|
||||
className={classNames}
|
||||
style={directionStyle}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
dangerouslySetInnerHTML={content}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
tabIndex='0'
|
||||
ref={this.setRef}
|
||||
className='status__content'
|
||||
style={directionStyle}
|
||||
dangerouslySetInnerHTML={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
72
app/javascript/mastodon/components/status_list.js
Normal file
72
app/javascript/mastodon/components/status_list.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import StatusContainer from '../containers/status_container';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ScrollableList from './scrollable_list';
|
||||
|
||||
export default class StatusList extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
scrollKey: PropTypes.string.isRequired,
|
||||
statusIds: ImmutablePropTypes.list.isRequired,
|
||||
onScrollToBottom: PropTypes.func,
|
||||
onScrollToTop: PropTypes.func,
|
||||
onScroll: PropTypes.func,
|
||||
trackScroll: PropTypes.bool,
|
||||
shouldUpdateScroll: PropTypes.func,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
prepend: PropTypes.node,
|
||||
emptyMessage: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
trackScroll: true,
|
||||
};
|
||||
|
||||
handleMoveUp = id => {
|
||||
const elementIndex = this.props.statusIds.indexOf(id) - 1;
|
||||
this._selectChild(elementIndex);
|
||||
}
|
||||
|
||||
handleMoveDown = id => {
|
||||
const elementIndex = this.props.statusIds.indexOf(id) + 1;
|
||||
this._selectChild(elementIndex);
|
||||
}
|
||||
|
||||
_selectChild (index) {
|
||||
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { statusIds, ...other } = this.props;
|
||||
const { isLoading } = other;
|
||||
|
||||
const scrollableContent = (isLoading || statusIds.size > 0) ? (
|
||||
statusIds.map((statusId) => (
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
/>
|
||||
))
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<ScrollableList {...other} ref={this.setRef}>
|
||||
{scrollableContent}
|
||||
</ScrollableList>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
72
app/javascript/mastodon/containers/account_container.js
Normal file
72
app/javascript/mastodon/containers/account_container.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { makeGetAccount } from '../selectors';
|
||||
import Account from '../components/account';
|
||||
import {
|
||||
followAccount,
|
||||
unfollowAccount,
|
||||
blockAccount,
|
||||
unblockAccount,
|
||||
muteAccount,
|
||||
unmuteAccount,
|
||||
} from '../actions/accounts';
|
||||
import { openModal } from '../actions/modal';
|
||||
import { initMuteModal } from '../actions/mutes';
|
||||
import { unfollowModal } from '../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
account: getAccount(state, props.id),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
|
||||
onFollow (account) {
|
||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
||||
if (unfollowModal) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||
}));
|
||||
} else {
|
||||
dispatch(unfollowAccount(account.get('id')));
|
||||
}
|
||||
} else {
|
||||
dispatch(followAccount(account.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onBlock (account) {
|
||||
if (account.getIn(['relationship', 'blocking'])) {
|
||||
dispatch(unblockAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(blockAccount(account.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onMute (account) {
|
||||
if (account.getIn(['relationship', 'muting'])) {
|
||||
dispatch(unmuteAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
onMuteNotifications (account, notifications) {
|
||||
dispatch(muteAccount(account.get('id'), notifications));
|
||||
},
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
|
18
app/javascript/mastodon/containers/card_container.js
Normal file
18
app/javascript/mastodon/containers/card_container.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Card from '../features/status/components/card';
|
||||
import { fromJS } from 'immutable';
|
||||
|
||||
export default class CardContainer extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
locale: PropTypes.string,
|
||||
card: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { card, ...props } = this.props;
|
||||
return <Card card={fromJS(card)} {...props} />;
|
||||
}
|
||||
|
||||
}
|
38
app/javascript/mastodon/containers/compose_container.js
Normal file
38
app/javascript/mastodon/containers/compose_container.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import configureStore from '../store/configureStore';
|
||||
import { hydrateStore } from '../actions/store';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import { getLocale } from '../locales';
|
||||
import Compose from '../features/standalone/compose';
|
||||
import initialState from '../initial_state';
|
||||
|
||||
const { localeData, messages } = getLocale();
|
||||
addLocaleData(localeData);
|
||||
|
||||
const store = configureStore();
|
||||
|
||||
if (initialState) {
|
||||
store.dispatch(hydrateStore(initialState));
|
||||
}
|
||||
|
||||
export default class TimelineContainer extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
locale: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { locale } = this.props;
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<Provider store={store}>
|
||||
<Compose />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { openModal, closeModal } from '../actions/modal';
|
||||
import { connect } from 'react-redux';
|
||||
import DropdownMenu from '../components/dropdown_menu';
|
||||
import { isUserTouching } from '../is_mobile';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isModalOpen: state.get('modal').modalType === 'ACTIONS',
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
isUserTouching,
|
||||
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
|
||||
onModalClose: () => dispatch(closeModal()),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
|
|
@ -0,0 +1,17 @@
|
|||
import { connect } from 'react-redux';
|
||||
import IntersectionObserverArticle from '../components/intersection_observer_article';
|
||||
import { setHeight } from '../actions/height_cache';
|
||||
|
||||
const makeMapStateToProps = (state, props) => ({
|
||||
cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
|
||||
onHeightChange (key, id, height) {
|
||||
dispatch(setHeight(key, id, height));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle);
|
70
app/javascript/mastodon/containers/mastodon.js
Normal file
70
app/javascript/mastodon/containers/mastodon.js
Normal file
|
@ -0,0 +1,70 @@
|
|||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import configureStore from '../store/configureStore';
|
||||
import { showOnboardingOnce } from '../actions/onboarding';
|
||||
import { BrowserRouter, Route } from 'react-router-dom';
|
||||
import { ScrollContext } from 'react-router-scroll-4';
|
||||
import UI from '../features/ui';
|
||||
import { hydrateStore } from '../actions/store';
|
||||
import { connectUserStream } from '../actions/streaming';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import { getLocale } from '../locales';
|
||||
import initialState from '../initial_state';
|
||||
|
||||
const { localeData, messages } = getLocale();
|
||||
addLocaleData(localeData);
|
||||
|
||||
export const store = configureStore();
|
||||
const hydrateAction = hydrateStore(initialState);
|
||||
store.dispatch(hydrateAction);
|
||||
|
||||
export default class Mastodon extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
locale: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.disconnect = store.dispatch(connectUserStream());
|
||||
|
||||
// Desktop notifications
|
||||
// Ask after 1 minute
|
||||
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
|
||||
window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
|
||||
}
|
||||
|
||||
// Protocol handler
|
||||
// Ask after 5 minutes
|
||||
if (typeof navigator.registerProtocolHandler !== 'undefined') {
|
||||
const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
|
||||
window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
store.dispatch(showOnboardingOnce());
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.disconnect) {
|
||||
this.disconnect();
|
||||
this.disconnect = null;
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { locale } = this.props;
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<Provider store={store}>
|
||||
<BrowserRouter basename='/web'>
|
||||
<ScrollContext>
|
||||
<Route path='/' component={UI} />
|
||||
</ScrollContext>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import { getLocale } from '../locales';
|
||||
import MediaGallery from '../components/media_gallery';
|
||||
import { fromJS } from 'immutable';
|
||||
|
||||
const { localeData, messages } = getLocale();
|
||||
addLocaleData(localeData);
|
||||
|
||||
export default class MediaGalleryContainer extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
locale: PropTypes.string.isRequired,
|
||||
media: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
handleOpenMedia = () => {}
|
||||
|
||||
render () {
|
||||
const { locale, media, ...props } = this.props;
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<MediaGallery
|
||||
{...props}
|
||||
media={fromJS(media)}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
133
app/javascript/mastodon/containers/status_container.js
Normal file
133
app/javascript/mastodon/containers/status_container.js
Normal file
|
@ -0,0 +1,133 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import Status from '../components/status';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
import {
|
||||
replyCompose,
|
||||
mentionCompose,
|
||||
} from '../actions/compose';
|
||||
import {
|
||||
reblog,
|
||||
favourite,
|
||||
unreblog,
|
||||
unfavourite,
|
||||
pin,
|
||||
unpin,
|
||||
} from '../actions/interactions';
|
||||
import { blockAccount } from '../actions/accounts';
|
||||
import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses';
|
||||
import { initMuteModal } from '../actions/mutes';
|
||||
import { initReport } from '../actions/reports';
|
||||
import { openModal } from '../actions/modal';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { boostModal, deleteModal } from '../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
|
||||
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
status: getStatus(state, props.id),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
|
||||
onReply (status, router) {
|
||||
dispatch(replyCompose(status, router));
|
||||
},
|
||||
|
||||
onModalReblog (status) {
|
||||
dispatch(reblog(status));
|
||||
},
|
||||
|
||||
onReblog (status, e) {
|
||||
if (status.get('reblogged')) {
|
||||
dispatch(unreblog(status));
|
||||
} else {
|
||||
if (e.shiftKey || !boostModal) {
|
||||
this.onModalReblog(status);
|
||||
} else {
|
||||
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onFavourite (status) {
|
||||
if (status.get('favourited')) {
|
||||
dispatch(unfavourite(status));
|
||||
} else {
|
||||
dispatch(favourite(status));
|
||||
}
|
||||
},
|
||||
|
||||
onPin (status) {
|
||||
if (status.get('pinned')) {
|
||||
dispatch(unpin(status));
|
||||
} else {
|
||||
dispatch(pin(status));
|
||||
}
|
||||
},
|
||||
|
||||
onEmbed (status) {
|
||||
dispatch(openModal('EMBED', { url: status.get('url') }));
|
||||
},
|
||||
|
||||
onDelete (status) {
|
||||
if (!deleteModal) {
|
||||
dispatch(deleteStatus(status.get('id')));
|
||||
} else {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => dispatch(deleteStatus(status.get('id'))),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
onMention (account, router) {
|
||||
dispatch(mentionCompose(account, router));
|
||||
},
|
||||
|
||||
onOpenMedia (media, index) {
|
||||
dispatch(openModal('MEDIA', { media, index }));
|
||||
},
|
||||
|
||||
onOpenVideo (media, time) {
|
||||
dispatch(openModal('VIDEO', { media, time }));
|
||||
},
|
||||
|
||||
onBlock (account) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => dispatch(blockAccount(account.get('id'))),
|
||||
}));
|
||||
},
|
||||
|
||||
onReport (status) {
|
||||
dispatch(initReport(status.get('account'), status));
|
||||
},
|
||||
|
||||
onMute (account) {
|
||||
dispatch(initMuteModal(account));
|
||||
},
|
||||
|
||||
onMuteConversation (status) {
|
||||
if (status.get('muted')) {
|
||||
dispatch(unmuteStatus(status.get('id')));
|
||||
} else {
|
||||
dispatch(muteStatus(status.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
|
48
app/javascript/mastodon/containers/timeline_container.js
Normal file
48
app/javascript/mastodon/containers/timeline_container.js
Normal file
|
@ -0,0 +1,48 @@
|
|||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import configureStore from '../store/configureStore';
|
||||
import { hydrateStore } from '../actions/store';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import { getLocale } from '../locales';
|
||||
import PublicTimeline from '../features/standalone/public_timeline';
|
||||
import HashtagTimeline from '../features/standalone/hashtag_timeline';
|
||||
import initialState from '../initial_state';
|
||||
|
||||
const { localeData, messages } = getLocale();
|
||||
addLocaleData(localeData);
|
||||
|
||||
const store = configureStore();
|
||||
|
||||
if (initialState) {
|
||||
store.dispatch(hydrateStore(initialState));
|
||||
}
|
||||
|
||||
export default class TimelineContainer extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
locale: PropTypes.string.isRequired,
|
||||
hashtag: PropTypes.string,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { locale, hashtag } = this.props;
|
||||
|
||||
let timeline;
|
||||
|
||||
if (hashtag) {
|
||||
timeline = <HashtagTimeline hashtag={hashtag} />;
|
||||
} else {
|
||||
timeline = <PublicTimeline />;
|
||||
}
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<Provider store={store}>
|
||||
{timeline}
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
26
app/javascript/mastodon/containers/video_container.js
Normal file
26
app/javascript/mastodon/containers/video_container.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||
import { getLocale } from '../locales';
|
||||
import Video from '../features/video';
|
||||
|
||||
const { localeData, messages } = getLocale();
|
||||
addLocaleData(localeData);
|
||||
|
||||
export default class VideoContainer extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
locale: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { locale, ...props } = this.props;
|
||||
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<Video {...props} />
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
5
app/javascript/mastodon/extra_polyfills.js
Normal file
5
app/javascript/mastodon/extra_polyfills.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import 'intersection-observer';
|
||||
import 'requestidlecallback';
|
||||
import objectFitImages from 'object-fit-images';
|
||||
|
||||
objectFitImages();
|
|
@ -0,0 +1,133 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
|
||||
import { me } from '../../../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
|
||||
media: { id: 'account.media', defaultMessage: 'Media' },
|
||||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class ActionBar extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onFollow: PropTypes.func,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMention: PropTypes.func.isRequired,
|
||||
onReport: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func.isRequired,
|
||||
onBlockDomain: PropTypes.func.isRequired,
|
||||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleShare = () => {
|
||||
navigator.share({
|
||||
url: this.props.account.get('url'),
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, intl } = this.props;
|
||||
|
||||
let menu = [];
|
||||
let extraInfo = '';
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
|
||||
if ('share' in navigator) {
|
||||
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
|
||||
}
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` });
|
||||
menu.push(null);
|
||||
|
||||
if (account.get('id') === me) {
|
||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
||||
} else {
|
||||
if (account.getIn(['relationship', 'muting'])) {
|
||||
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
|
||||
}
|
||||
|
||||
if (account.getIn(['relationship', 'blocking'])) {
|
||||
menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
|
||||
}
|
||||
|
||||
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
|
||||
}
|
||||
|
||||
if (account.get('acct') !== account.get('username')) {
|
||||
const domain = account.get('acct').split('@')[1];
|
||||
|
||||
extraInfo = (
|
||||
<div className='account__disclaimer'>
|
||||
<FormattedMessage
|
||||
id='account.disclaimer_full'
|
||||
defaultMessage="Information below may reflect the user's profile incompletely."
|
||||
/>
|
||||
{' '}
|
||||
<a target='_blank' rel='noopener' href={account.get('url')}>
|
||||
<FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
menu.push(null);
|
||||
|
||||
if (account.getIn(['relationship', 'domain_blocking'])) {
|
||||
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain });
|
||||
} else {
|
||||
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{extraInfo}
|
||||
|
||||
<div className='account__action-bar'>
|
||||
<div className='account__action-bar-dropdown'>
|
||||
<DropdownMenuContainer items={menu} icon='bars' size={24} direction='right' />
|
||||
</div>
|
||||
|
||||
<div className='account__action-bar-links'>
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
|
||||
<span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
|
||||
<strong><FormattedNumber value={account.get('statuses_count')} /></strong>
|
||||
</Link>
|
||||
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
|
||||
<span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
|
||||
<strong><FormattedNumber value={account.get('following_count')} /></strong>
|
||||
</Link>
|
||||
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
|
||||
<span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
|
||||
<strong><FormattedNumber value={account.get('followers_count')} /></strong>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
128
app/javascript/mastodon/features/account/components/header.js
Normal file
128
app/javascript/mastodon/features/account/components/header.js
Normal file
|
@ -0,0 +1,128 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { autoPlayGif, me } from '../../../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
|
||||
});
|
||||
|
||||
class Avatar extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
isHovered: false,
|
||||
};
|
||||
|
||||
handleMouseOver = () => {
|
||||
if (this.state.isHovered) return;
|
||||
this.setState({ isHovered: true });
|
||||
}
|
||||
|
||||
handleMouseOut = () => {
|
||||
if (!this.state.isHovered) return;
|
||||
this.setState({ isHovered: false });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account } = this.props;
|
||||
const { isHovered } = this.state;
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ radius }) =>
|
||||
<a
|
||||
href={account.get('url')}
|
||||
className='account__header__avatar'
|
||||
role='presentation'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }}
|
||||
onMouseOver={this.handleMouseOver}
|
||||
onMouseOut={this.handleMouseOut}
|
||||
onFocus={this.handleMouseOver}
|
||||
onBlur={this.handleMouseOut}
|
||||
>
|
||||
<span style={{ display: 'none' }}>{account.get('acct')}</span>
|
||||
</a>
|
||||
}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
export default class Header extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account, intl } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let info = '';
|
||||
let actionBtn = '';
|
||||
let lockedIcon = '';
|
||||
|
||||
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
|
||||
info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
|
||||
}
|
||||
|
||||
if (me !== account.get('id')) {
|
||||
if (account.getIn(['relationship', 'requested'])) {
|
||||
actionBtn = (
|
||||
<div className='account--action-button'>
|
||||
<IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />
|
||||
</div>
|
||||
);
|
||||
} else if (!account.getIn(['relationship', 'blocking'])) {
|
||||
actionBtn = (
|
||||
<div className='account--action-button'>
|
||||
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.get('locked')) {
|
||||
lockedIcon = <i className='fa fa-lock' />;
|
||||
}
|
||||
|
||||
const content = { __html: account.get('note_emojified') };
|
||||
const displayNameHtml = { __html: account.get('display_name_html') };
|
||||
|
||||
return (
|
||||
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
|
||||
<div>
|
||||
<Avatar account={account} />
|
||||
|
||||
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
|
||||
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
|
||||
<div className='account__header__content' dangerouslySetInnerHTML={content} />
|
||||
|
||||
{info}
|
||||
{actionBtn}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Permalink from '../../../components/permalink';
|
||||
|
||||
export default class MediaItem extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { media } = this.props;
|
||||
const status = media.get('status');
|
||||
|
||||
let content, style;
|
||||
|
||||
if (media.get('type') === 'gifv') {
|
||||
content = <span className='media-gallery__gifv__label'>GIF</span>;
|
||||
}
|
||||
|
||||
if (!status.get('sensitive')) {
|
||||
style = { backgroundImage: `url(${media.get('preview_url')})` };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account-gallery__item'>
|
||||
<Permalink
|
||||
to={`/statuses/${status.get('id')}`}
|
||||
href={status.get('url')}
|
||||
style={style}
|
||||
>
|
||||
{content}
|
||||
</Permalink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
111
app/javascript/mastodon/features/account_gallery/index.js
Normal file
111
app/javascript/mastodon/features/account_gallery/index.js
Normal file
|
@ -0,0 +1,111 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { fetchAccount } from '../../actions/accounts';
|
||||
import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from '../../actions/timelines';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { getAccountGallery } from '../../selectors';
|
||||
import MediaItem from './components/media_item';
|
||||
import HeaderContainer from '../account_timeline/containers/header_container';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { ScrollContainer } from 'react-router-scroll-4';
|
||||
import LoadMore from '../../components/load_more';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
medias: getAccountGallery(state, props.params.accountId),
|
||||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
|
||||
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
export default class AccountGallery extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
medias: ImmutablePropTypes.list.isRequired,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||
this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId));
|
||||
this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
handleScrollToBottom = () => {
|
||||
if (this.props.hasMore) {
|
||||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
handleScroll = (e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
const offset = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
if (150 > offset && !this.props.isLoading) {
|
||||
this.handleScrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadMore = (e) => {
|
||||
e.preventDefault();
|
||||
this.handleScrollToBottom();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { medias, isLoading, hasMore } = this.props;
|
||||
|
||||
let loadMore = null;
|
||||
|
||||
if (!medias && isLoading) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && medias.size > 0 && hasMore) {
|
||||
loadMore = <LoadMore onClick={this.handleLoadMore} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<ScrollContainer scrollKey='account_gallery'>
|
||||
<div className='scrollable' onScroll={this.handleScroll}>
|
||||
<HeaderContainer accountId={this.props.params.accountId} />
|
||||
|
||||
<div className='account-section-headline'>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</div>
|
||||
|
||||
<div className='account-gallery__container'>
|
||||
{medias.map(media =>
|
||||
<MediaItem
|
||||
key={media.get('id')}
|
||||
media={media}
|
||||
/>
|
||||
)}
|
||||
{loadMore}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import InnerHeader from '../../account/components/header';
|
||||
import ActionBar from '../../account/components/action_bar';
|
||||
import MissingIndicator from '../../../components/missing_indicator';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
export default class Header extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMention: PropTypes.func.isRequired,
|
||||
onReport: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func.isRequired,
|
||||
onBlockDomain: PropTypes.func.isRequired,
|
||||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
handleFollow = () => {
|
||||
this.props.onFollow(this.props.account);
|
||||
}
|
||||
|
||||
handleBlock = () => {
|
||||
this.props.onBlock(this.props.account);
|
||||
}
|
||||
|
||||
handleMention = () => {
|
||||
this.props.onMention(this.props.account, this.context.router.history);
|
||||
}
|
||||
|
||||
handleReport = () => {
|
||||
this.props.onReport(this.props.account);
|
||||
}
|
||||
|
||||
handleMute = () => {
|
||||
this.props.onMute(this.props.account);
|
||||
}
|
||||
|
||||
handleBlockDomain = () => {
|
||||
const domain = this.props.account.get('acct').split('@')[1];
|
||||
|
||||
if (!domain) return;
|
||||
|
||||
this.props.onBlockDomain(domain, this.props.account.get('id'));
|
||||
}
|
||||
|
||||
handleUnblockDomain = () => {
|
||||
const domain = this.props.account.get('acct').split('@')[1];
|
||||
|
||||
if (!domain) return;
|
||||
|
||||
this.props.onUnblockDomain(domain, this.props.account.get('id'));
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account } = this.props;
|
||||
|
||||
if (account === null) {
|
||||
return <MissingIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account-timeline__header'>
|
||||
<InnerHeader
|
||||
account={account}
|
||||
onFollow={this.handleFollow}
|
||||
/>
|
||||
|
||||
<ActionBar
|
||||
account={account}
|
||||
onBlock={this.handleBlock}
|
||||
onMention={this.handleMention}
|
||||
onReport={this.handleReport}
|
||||
onMute={this.handleMute}
|
||||
onBlockDomain={this.handleBlockDomain}
|
||||
onUnblockDomain={this.handleUnblockDomain}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
import Header from '../components/header';
|
||||
import {
|
||||
followAccount,
|
||||
unfollowAccount,
|
||||
blockAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
} from '../../../actions/accounts';
|
||||
import { mentionCompose } from '../../../actions/compose';
|
||||
import { initMuteModal } from '../../../actions/mutes';
|
||||
import { initReport } from '../../../actions/reports';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { unfollowModal } from '../../../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
||||
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
|
||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({
|
||||
account: getAccount(state, accountId),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
|
||||
onFollow (account) {
|
||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
||||
if (unfollowModal) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||
}));
|
||||
} else {
|
||||
dispatch(unfollowAccount(account.get('id')));
|
||||
}
|
||||
} else {
|
||||
dispatch(followAccount(account.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onBlock (account) {
|
||||
if (account.getIn(['relationship', 'blocking'])) {
|
||||
dispatch(unblockAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.blockConfirm),
|
||||
onConfirm: () => dispatch(blockAccount(account.get('id'))),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
onMention (account, router) {
|
||||
dispatch(mentionCompose(account, router));
|
||||
},
|
||||
|
||||
onReport (account) {
|
||||
dispatch(initReport(account));
|
||||
},
|
||||
|
||||
onMute (account) {
|
||||
if (account.getIn(['relationship', 'muting'])) {
|
||||
dispatch(unmuteAccount(account.get('id')));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
},
|
||||
|
||||
onBlockDomain (domain, accountId) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
|
||||
confirm: intl.formatMessage(messages.blockDomainConfirm),
|
||||
onConfirm: () => dispatch(blockDomain(domain, accountId)),
|
||||
}));
|
||||
},
|
||||
|
||||
onUnblockDomain (domain, accountId) {
|
||||
dispatch(unblockDomain(domain, accountId));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
|
77
app/javascript/mastodon/features/account_timeline/index.js
Normal file
77
app/javascript/mastodon/features/account_timeline/index.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { fetchAccount } from '../../actions/accounts';
|
||||
import { refreshAccountTimeline, expandAccountTimeline } from '../../actions/timelines';
|
||||
import StatusList from '../../components/status_list';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import Column from '../ui/components/column';
|
||||
import HeaderContainer from './containers/header_container';
|
||||
import ColumnBackButton from '../../components/column_back_button';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
|
||||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
|
||||
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
export default class AccountTimeline extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
statusIds: ImmutablePropTypes.list,
|
||||
isLoading: PropTypes.bool,
|
||||
hasMore: PropTypes.bool,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchAccount(this.props.params.accountId));
|
||||
this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
|
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId));
|
||||
this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
handleScrollToBottom = () => {
|
||||
if (!this.props.isLoading && this.props.hasMore) {
|
||||
this.props.dispatch(expandAccountTimeline(this.props.params.accountId));
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { statusIds, isLoading, hasMore } = this.props;
|
||||
|
||||
if (!statusIds && isLoading) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<StatusList
|
||||
prepend={<HeaderContainer accountId={this.props.params.accountId} />}
|
||||
scrollKey='account_timeline'
|
||||
statusIds={statusIds}
|
||||
isLoading={isLoading}
|
||||
hasMore={hasMore}
|
||||
onScrollToBottom={this.handleScrollToBottom}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
70
app/javascript/mastodon/features/blocks/index.js
Normal file
70
app/javascript/mastodon/features/blocks/index.js
Normal file
|
@ -0,0 +1,70 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import LoadingIndicator from '../../components/loading_indicator';
|
||||
import { ScrollContainer } from 'react-router-scroll-4';
|
||||
import Column from '../ui/components/column';
|
||||
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
||||
import AccountContainer from '../../containers/account_container';
|
||||
import { fetchBlocks, expandBlocks } from '../../actions/blocks';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.blocks', defaultMessage: 'Blocked users' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
accountIds: state.getIn(['user_lists', 'blocks', 'items']),
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
@injectIntl
|
||||
export default class Blocks extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
params: PropTypes.object.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
accountIds: ImmutablePropTypes.list,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchBlocks());
|
||||
}
|
||||
|
||||
handleScroll = (e) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
|
||||
if (scrollTop === scrollHeight - clientHeight) {
|
||||
this.props.dispatch(expandBlocks());
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, accountIds } = this.props;
|
||||
|
||||
if (!accountIds) {
|
||||
return (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column icon='ban' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
<ScrollContainer scrollKey='blocks'>
|
||||
<div className='scrollable' onScroll={this.handleScroll}>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} />
|
||||
)}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import SettingText from '../../../components/setting_text';
|
||||
|
||||
const messages = defineMessages({
|
||||
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
|
||||
settings: { id: 'home.settings', defaultMessage: 'Column settings' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class ColumnSettings extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
settings: ImmutablePropTypes.map.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { settings, onChange, intl } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { connect } from 'react-redux';
|
||||
import ColumnSettings from '../components/column_settings';
|
||||
import { changeSetting } from '../../../actions/settings';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
settings: state.getIn(['settings', 'community']),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onChange (key, checked) {
|
||||
dispatch(changeSetting(['community', ...key], checked));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
|
107
app/javascript/mastodon/features/community_timeline/index.js
Normal file
107
app/javascript/mastodon/features/community_timeline/index.js
Normal file
|
@ -0,0 +1,107 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import StatusListContainer from '../ui/containers/status_list_container';
|
||||
import Column from '../../components/column';
|
||||
import ColumnHeader from '../../components/column_header';
|
||||
import {
|
||||
refreshCommunityTimeline,
|
||||
expandCommunityTimeline,
|
||||
} from '../../actions/timelines';
|
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||
import { connectCommunityStream } from '../../actions/streaming';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.community', defaultMessage: 'Local timeline' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
|
||||
});
|
||||
|
||||
@connect(mapStateToProps)
|
||||
@injectIntl
|
||||
export default class CommunityTimeline extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
columnId: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hasUnread: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
};
|
||||
|
||||
handlePin = () => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
|
||||
if (columnId) {
|
||||
dispatch(removeColumn(columnId));
|
||||
} else {
|
||||
dispatch(addColumn('COMMUNITY', {}));
|
||||
}
|
||||
}
|
||||
|
||||
handleMove = (dir) => {
|
||||
const { columnId, dispatch } = this.props;
|
||||
dispatch(moveColumn(columnId, dir));
|
||||
}
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
const { dispatch } = this.props;
|
||||
|
||||
dispatch(refreshCommunityTimeline());
|
||||
this.disconnect = dispatch(connectCommunityStream());
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.disconnect) {
|
||||
this.disconnect();
|
||||
this.disconnect = null;
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
this.props.dispatch(expandCommunityTimeline());
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, hasUnread, columnId, multiColumn } = this.props;
|
||||
const pinned = !!columnId;
|
||||
|
||||
return (
|
||||
<Column ref={this.setRef}>
|
||||
<ColumnHeader
|
||||
icon='users'
|
||||
active={hasUnread}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onPin={this.handlePin}
|
||||
onMove={this.handleMove}
|
||||
onClick={this.handleHeaderClick}
|
||||
pinned={pinned}
|
||||
multiColumn={multiColumn}
|
||||
>
|
||||
<ColumnSettingsContainer />
|
||||
</ColumnHeader>
|
||||
|
||||
<StatusListContainer
|
||||
trackScroll={!pinned}
|
||||
scrollKey={`community_timeline-${columnId}`}
|
||||
timelineId='community'
|
||||
loadMore={this.handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
export default class AutosuggestAccount extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account } = this.props;
|
||||
|
||||
return (
|
||||
<div className='autosuggest-account'>
|
||||
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { length } from 'stringz';
|
||||
|
||||
export default class CharacterCounter extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
text: PropTypes.string.isRequired,
|
||||
max: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
checkRemainingText (diff) {
|
||||
if (diff < 0) {
|
||||
return <span className='character-counter character-counter--over'>{diff}</span>;
|
||||
}
|
||||
|
||||
return <span className='character-counter'>{diff}</span>;
|
||||
}
|
||||
|
||||
render () {
|
||||
const diff = this.props.max - length(this.props.text);
|
||||
return this.checkRemainingText(diff);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,212 @@
|
|||
import React from 'react';
|
||||
import CharacterCounter from './character_counter';
|
||||
import Button from '../../../components/button';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
|
||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||
import UploadButtonContainer from '../containers/upload_button_container';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import Collapsable from '../../../components/collapsable';
|
||||
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||
import UploadFormContainer from '../containers/upload_form_container';
|
||||
import WarningContainer from '../containers/warning_container';
|
||||
import { isMobile } from '../../../is_mobile';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { length } from 'stringz';
|
||||
import { countableText } from '../util/counter';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
|
||||
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
|
||||
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
|
||||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class ComposeForm extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
suggestion_token: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
spoiler: PropTypes.bool,
|
||||
privacy: PropTypes.string,
|
||||
spoiler_text: PropTypes.string,
|
||||
focusDate: PropTypes.instanceOf(Date),
|
||||
preselectDate: PropTypes.instanceOf(Date),
|
||||
is_submitting: PropTypes.bool,
|
||||
is_uploading: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onClearSuggestions: PropTypes.func.isRequired,
|
||||
onFetchSuggestions: PropTypes.func.isRequired,
|
||||
onSuggestionSelected: PropTypes.func.isRequired,
|
||||
onChangeSpoilerText: PropTypes.func.isRequired,
|
||||
onPaste: PropTypes.func.isRequired,
|
||||
onPickEmoji: PropTypes.func.isRequired,
|
||||
showSearch: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
showSearch: false,
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
this.props.onChange(e.target.value);
|
||||
}
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
this.handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit = () => {
|
||||
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
|
||||
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
|
||||
// Update the state to match the current text
|
||||
this.props.onChange(this.autosuggestTextarea.textarea.value);
|
||||
}
|
||||
|
||||
this.props.onSubmit();
|
||||
}
|
||||
|
||||
onSuggestionsClearRequested = () => {
|
||||
this.props.onClearSuggestions();
|
||||
}
|
||||
|
||||
onSuggestionsFetchRequested = (token) => {
|
||||
this.props.onFetchSuggestions(token);
|
||||
}
|
||||
|
||||
onSuggestionSelected = (tokenStart, token, value) => {
|
||||
this._restoreCaret = null;
|
||||
this.props.onSuggestionSelected(tokenStart, token, value);
|
||||
}
|
||||
|
||||
handleChangeSpoilerText = (e) => {
|
||||
this.props.onChangeSpoilerText(e.target.value);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
// If this is the update where we've finished uploading,
|
||||
// save the last caret position so we can restore it below!
|
||||
if (!nextProps.is_uploading && this.props.is_uploading) {
|
||||
this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
// This statement does several things:
|
||||
// - If we're beginning a reply, and,
|
||||
// - Replying to zero or one users, places the cursor at the end of the textbox.
|
||||
// - Replying to more than one user, selects any usernames past the first;
|
||||
// this provides a convenient shortcut to drop everyone else from the conversation.
|
||||
// - If we've just finished uploading an image, and have a saved caret position,
|
||||
// restores the cursor to that position after the text changes!
|
||||
if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
|
||||
let selectionEnd, selectionStart;
|
||||
|
||||
if (this.props.preselectDate !== prevProps.preselectDate) {
|
||||
selectionEnd = this.props.text.length;
|
||||
selectionStart = this.props.text.search(/\s/) + 1;
|
||||
} else if (typeof this._restoreCaret === 'number') {
|
||||
selectionStart = this._restoreCaret;
|
||||
selectionEnd = this._restoreCaret;
|
||||
} else {
|
||||
selectionEnd = this.props.text.length;
|
||||
selectionStart = selectionEnd;
|
||||
}
|
||||
|
||||
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
|
||||
this.autosuggestTextarea.textarea.focus();
|
||||
} else if(prevProps.is_submitting && !this.props.is_submitting) {
|
||||
this.autosuggestTextarea.textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setAutosuggestTextarea = (c) => {
|
||||
this.autosuggestTextarea = c;
|
||||
}
|
||||
|
||||
handleEmojiPick = (data) => {
|
||||
const position = this.autosuggestTextarea.textarea.selectionStart;
|
||||
const emojiChar = data.native;
|
||||
this._restoreCaret = position + emojiChar.length + 1;
|
||||
this.props.onPickEmoji(position, data);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, onPaste, showSearch } = this.props;
|
||||
const disabled = this.props.is_submitting;
|
||||
const text = [this.props.spoiler_text, countableText(this.props.text)].join('');
|
||||
|
||||
let publishText = '';
|
||||
|
||||
if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
|
||||
publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
|
||||
} else {
|
||||
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='compose-form'>
|
||||
<Collapsable isVisible={this.props.spoiler} fullHeight={50}>
|
||||
<div className='spoiler-input'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
|
||||
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' />
|
||||
</label>
|
||||
</div>
|
||||
</Collapsable>
|
||||
|
||||
<WarningContainer />
|
||||
|
||||
<ReplyIndicatorContainer />
|
||||
|
||||
<div className='compose-form__autosuggest-wrapper'>
|
||||
<AutosuggestTextarea
|
||||
ref={this.setAutosuggestTextarea}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
disabled={disabled}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
onPaste={onPaste}
|
||||
autoFocus={!showSearch && !isMobile(window.innerWidth)}
|
||||
/>
|
||||
|
||||
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
|
||||
</div>
|
||||
|
||||
<div className='compose-form__modifiers'>
|
||||
<UploadFormContainer />
|
||||
</div>
|
||||
|
||||
<div className='compose-form__buttons-wrapper'>
|
||||
<div className='compose-form__buttons'>
|
||||
<UploadButtonContainer />
|
||||
<PrivacyDropdownContainer />
|
||||
<SensitiveButtonContainer />
|
||||
<SpoilerButtonContainer />
|
||||
</div>
|
||||
|
||||
<div className='compose-form__publish'>
|
||||
<div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
|
||||
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0)} block /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,376 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import classNames from 'classnames';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
import { buildCustomEmojis } from '../../emoji/emoji';
|
||||
|
||||
const messages = defineMessages({
|
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
|
||||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
|
||||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
|
||||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
|
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
|
||||
people: { id: 'emoji_button.people', defaultMessage: 'People' },
|
||||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
|
||||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
|
||||
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
|
||||
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
|
||||
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
|
||||
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
|
||||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
|
||||
});
|
||||
|
||||
const assetHost = process.env.CDN_HOST || '';
|
||||
let EmojiPicker, Emoji; // load asynchronously
|
||||
|
||||
const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
|
||||
const categoriesSort = [
|
||||
'recent',
|
||||
'custom',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'objects',
|
||||
'symbols',
|
||||
'flags',
|
||||
];
|
||||
|
||||
class ModifierPickerMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleClick = e => {
|
||||
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.active) {
|
||||
this.attachListeners();
|
||||
} else {
|
||||
this.removeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.removeListeners();
|
||||
}
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
attachListeners () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
removeListeners () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { active } = this.props;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
|
||||
<button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
|
||||
<button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ModifierPicker extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
modifier: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
onOpen: PropTypes.func,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
if (this.props.active) {
|
||||
this.props.onClose();
|
||||
} else {
|
||||
this.props.onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
handleSelect = modifier => {
|
||||
this.props.onChange(modifier);
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { active, modifier } = this.props;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown__modifiers'>
|
||||
<Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
|
||||
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
class EmojiPickerMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
custom_emojis: ImmutablePropTypes.list,
|
||||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
|
||||
loading: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onPick: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
placement: PropTypes.string,
|
||||
arrowOffsetLeft: PropTypes.string,
|
||||
arrowOffsetTop: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
loading: true,
|
||||
placement: 'bottom',
|
||||
frequentlyUsedEmojis: [],
|
||||
};
|
||||
|
||||
state = {
|
||||
modifierOpen: false,
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
getI18n = () => {
|
||||
const { intl } = this.props;
|
||||
|
||||
return {
|
||||
search: intl.formatMessage(messages.emoji_search),
|
||||
notfound: intl.formatMessage(messages.emoji_not_found),
|
||||
categories: {
|
||||
search: intl.formatMessage(messages.search_results),
|
||||
recent: intl.formatMessage(messages.recent),
|
||||
people: intl.formatMessage(messages.people),
|
||||
nature: intl.formatMessage(messages.nature),
|
||||
foods: intl.formatMessage(messages.food),
|
||||
activity: intl.formatMessage(messages.activity),
|
||||
places: intl.formatMessage(messages.travel),
|
||||
objects: intl.formatMessage(messages.objects),
|
||||
symbols: intl.formatMessage(messages.symbols),
|
||||
flags: intl.formatMessage(messages.flags),
|
||||
custom: intl.formatMessage(messages.custom),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
handleClick = emoji => {
|
||||
if (!emoji.native) {
|
||||
emoji.native = emoji.colons;
|
||||
}
|
||||
|
||||
this.props.onClose();
|
||||
this.props.onPick(emoji);
|
||||
}
|
||||
|
||||
handleModifierOpen = () => {
|
||||
this.setState({ modifierOpen: true });
|
||||
}
|
||||
|
||||
handleModifierClose = () => {
|
||||
this.setState({ modifierOpen: false });
|
||||
}
|
||||
|
||||
handleModifierChange = modifier => {
|
||||
this.props.onSkinTone(modifier);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ width: 299 }} />;
|
||||
}
|
||||
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { modifierOpen } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
|
||||
<EmojiPicker
|
||||
perLine={8}
|
||||
emojiSize={22}
|
||||
sheetSize={32}
|
||||
custom={buildCustomEmojis(custom_emojis)}
|
||||
color=''
|
||||
emoji=''
|
||||
set='twitter'
|
||||
title={title}
|
||||
i18n={this.getI18n()}
|
||||
onClick={this.handleClick}
|
||||
include={categoriesSort}
|
||||
recent={frequentlyUsedEmojis}
|
||||
skin={skinTone}
|
||||
showPreview={false}
|
||||
backgroundImageFn={backgroundImageFn}
|
||||
emojiTooltip
|
||||
/>
|
||||
|
||||
<ModifierPicker
|
||||
active={modifierOpen}
|
||||
modifier={skinTone}
|
||||
onOpen={this.handleModifierOpen}
|
||||
onClose={this.handleModifierClose}
|
||||
onChange={this.handleModifierChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
export default class EmojiPickerDropdown extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
custom_emojis: ImmutablePropTypes.list,
|
||||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
|
||||
intl: PropTypes.object.isRequired,
|
||||
onPickEmoji: PropTypes.func.isRequired,
|
||||
onSkinTone: PropTypes.func.isRequired,
|
||||
skinTone: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
active: false,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
setRef = (c) => {
|
||||
this.dropdown = c;
|
||||
}
|
||||
|
||||
onShowDropdown = () => {
|
||||
this.setState({ active: true });
|
||||
|
||||
if (!EmojiPicker) {
|
||||
this.setState({ loading: true });
|
||||
|
||||
EmojiPickerAsync().then(EmojiMart => {
|
||||
EmojiPicker = EmojiMart.Picker;
|
||||
Emoji = EmojiMart.Emoji;
|
||||
|
||||
this.setState({ loading: false });
|
||||
}).catch(() => {
|
||||
this.setState({ loading: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onHideDropdown = () => {
|
||||
this.setState({ active: false });
|
||||
}
|
||||
|
||||
onToggle = (e) => {
|
||||
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
|
||||
if (this.state.active) {
|
||||
this.onHideDropdown();
|
||||
} else {
|
||||
this.onShowDropdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
if (e.key === 'Escape') {
|
||||
this.onHideDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
setTargetRef = c => {
|
||||
this.target = c;
|
||||
}
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
|
||||
const title = intl.formatMessage(messages.emoji);
|
||||
const { active, loading } = this.state;
|
||||
|
||||
return (
|
||||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
|
||||
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
|
||||
<img
|
||||
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
||||
alt='🙂'
|
||||
src={`${assetHost}/emoji/1f602.svg`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Overlay show={active} placement='bottom' target={this.findTarget}>
|
||||
<EmojiPickerMenu
|
||||
custom_emojis={this.props.custom_emojis}
|
||||
loading={loading}
|
||||
onClose={this.onHideDropdown}
|
||||
onPick={onPickEmoji}
|
||||
onSkinTone={onSkinTone}
|
||||
skinTone={skinTone}
|
||||
frequentlyUsedEmojis={frequentlyUsedEmojis}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import Permalink from '../../../components/permalink';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
export default class NavigationBar extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div className='navigation-bar'>
|
||||
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
|
||||
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
|
||||
<Avatar account={this.props.account} size={40} />
|
||||
</Permalink>
|
||||
|
||||
<div className='navigation-bar__profile'>
|
||||
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
|
||||
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
|
||||
</Permalink>
|
||||
|
||||
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
|
||||
</div>
|
||||
|
||||
<IconButton title='' icon='close' onClick={this.props.onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import detectPassiveEvents from 'detect-passive-events';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
|
||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
|
||||
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
|
||||
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
|
||||
});
|
||||
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
|
||||
|
||||
class PrivacyDropdownMenu extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
style: PropTypes.object,
|
||||
items: PropTypes.array.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleDocumentClick = e => {
|
||||
if (this.node && !this.node.contains(e.target)) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
if (e.key === 'Escape') {
|
||||
this.props.onClose();
|
||||
} else if (!e.key || e.key === 'Enter') {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
this.props.onClose();
|
||||
this.props.onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
document.addEventListener('click', this.handleDocumentClick, false);
|
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
document.removeEventListener('click', this.handleDocumentClick, false);
|
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { style, items, value } = this.props;
|
||||
|
||||
return (
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
|
||||
{items.map(item =>
|
||||
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
|
||||
<div className='privacy-dropdown__option__icon'>
|
||||
<i className={`fa fa-fw fa-${item.icon}`} />
|
||||
</div>
|
||||
|
||||
<div className='privacy-dropdown__option__content'>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
export default class PrivacyDropdown extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
isUserTouching: PropTypes.func,
|
||||
isModalOpen: PropTypes.bool.isRequired,
|
||||
onModalOpen: PropTypes.func,
|
||||
onModalClose: PropTypes.func,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
open: false,
|
||||
};
|
||||
|
||||
handleToggle = () => {
|
||||
if (this.props.isUserTouching()) {
|
||||
if (this.state.open) {
|
||||
this.props.onModalClose();
|
||||
} else {
|
||||
this.props.onModalOpen({
|
||||
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
|
||||
onClick: this.handleModalActionClick,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.setState({ open: !this.state.open });
|
||||
}
|
||||
}
|
||||
|
||||
handleModalActionClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
|
||||
|
||||
this.props.onModalClose();
|
||||
this.props.onChange(value);
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
switch(e.key) {
|
||||
case 'Enter':
|
||||
this.handleToggle();
|
||||
break;
|
||||
case 'Escape':
|
||||
this.handleClose();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({ open: false });
|
||||
}
|
||||
|
||||
handleChange = value => {
|
||||
this.props.onChange(value);
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
const { intl: { formatMessage } } = this.props;
|
||||
|
||||
this.options = [
|
||||
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
|
||||
{ icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
|
||||
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
|
||||
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
|
||||
];
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value, intl } = this.props;
|
||||
const { open } = this.state;
|
||||
|
||||
const valueOption = this.options.find(item => item.value === value);
|
||||
|
||||
return (
|
||||
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
|
||||
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
|
||||
<IconButton
|
||||
className='privacy-dropdown__value-icon'
|
||||
icon={valueOption.icon}
|
||||
title={intl.formatMessage(messages.change_privacy)}
|
||||
size={18}
|
||||
expanded={open}
|
||||
active={open}
|
||||
inverted
|
||||
onClick={this.handleToggle}
|
||||
style={{ height: null, lineHeight: '27px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Overlay show={open} placement='bottom' target={this}>
|
||||
<PrivacyDropdownMenu
|
||||
items={this.options}
|
||||
value={value}
|
||||
onClose={this.handleClose}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import Avatar from '../../../components/avatar';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import DisplayName from '../../../components/display_name';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class ReplyIndicator extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.props.onCancel();
|
||||
}
|
||||
|
||||
handleAccountClick = (e) => {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { status, intl } = this.props;
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = { __html: status.get('contentHtml') };
|
||||
|
||||
return (
|
||||
<div className='reply-indicator'>
|
||||
<div className='reply-indicator__header'>
|
||||
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
|
||||
|
||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
|
||||
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
|
||||
<DisplayName account={status.get('account')} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
129
app/javascript/mastodon/features/compose/components/search.js
Normal file
129
app/javascript/mastodon/features/compose/components/search.js
Normal file
|
@ -0,0 +1,129 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import Overlay from 'react-overlays/lib/Overlay';
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
});
|
||||
|
||||
class SearchPopout extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
style: PropTypes.object,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { style } = this.props;
|
||||
|
||||
return (
|
||||
<div style={{ ...style, position: 'absolute', width: 285 }}>
|
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
|
||||
{({ opacity, scaleX, scaleY }) => (
|
||||
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
|
||||
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
|
||||
|
||||
<ul>
|
||||
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
|
||||
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
|
||||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
|
||||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
|
||||
</ul>
|
||||
|
||||
<FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@injectIntl
|
||||
export default class Search extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
submitted: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
onShow: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
this.props.onChange(e.target.value);
|
||||
}
|
||||
|
||||
handleClear = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.props.value.length > 0 || this.props.submitted) {
|
||||
this.props.onClear();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.props.onSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
noop () {
|
||||
|
||||
}
|
||||
|
||||
handleFocus = () => {
|
||||
this.setState({ expanded: true });
|
||||
this.props.onShow();
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ expanded: false });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, value, submitted } = this.props;
|
||||
const { expanded } = this.state;
|
||||
const hasValue = value.length > 0 || submitted;
|
||||
|
||||
return (
|
||||
<div className='search'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
|
||||
<input
|
||||
className='search__input'
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
onKeyUp={this.handleKeyDown}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
|
||||
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
|
||||
<i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
|
||||
</div>
|
||||
|
||||
<Overlay show={expanded && !hasValue} placement='bottom' target={this}>
|
||||
<SearchPopout />
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import AccountContainer from '../../../containers/account_container';
|
||||
import StatusContainer from '../../../containers/status_container';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
export default class SearchResults extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
results: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { results } = this.props;
|
||||
|
||||
let accounts, statuses, hashtags;
|
||||
let count = 0;
|
||||
|
||||
if (results.get('accounts') && results.get('accounts').size > 0) {
|
||||
count += results.get('accounts').size;
|
||||
accounts = (
|
||||
<div className='search-results__section'>
|
||||
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.get('statuses') && results.get('statuses').size > 0) {
|
||||
count += results.get('statuses').size;
|
||||
statuses = (
|
||||
<div className='search-results__section'>
|
||||
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.get('hashtags') && results.get('hashtags').size > 0) {
|
||||
count += results.get('hashtags').size;
|
||||
hashtags = (
|
||||
<div className='search-results__section'>
|
||||
{results.get('hashtags').map(hashtag =>
|
||||
<Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
|
||||
#{hashtag}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='search-results'>
|
||||
<div className='search-results__header'>
|
||||
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
|
||||
</div>
|
||||
|
||||
{accounts}
|
||||
{statuses}
|
||||
{hashtags}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class TextIconButton extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
active: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
ariaControls: PropTypes.string,
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
this.props.onClick();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { label, title, active, ariaControls } = this.props;
|
||||
|
||||
return (
|
||||
<button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const messages = defineMessages({
|
||||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
|
||||
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
|
||||
});
|
||||
|
||||
@injectIntl
|
||||
export default class Upload extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
media: ImmutablePropTypes.map.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onUndo: PropTypes.func.isRequired,
|
||||
onDescriptionChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
hovered: false,
|
||||
focused: false,
|
||||
dirtyDescription: null,
|
||||
};
|
||||
|
||||
handleUndoClick = () => {
|
||||
this.props.onUndo(this.props.media.get('id'));
|
||||
}
|
||||
|
||||
handleInputChange = e => {
|
||||
this.setState({ dirtyDescription: e.target.value });
|
||||
}
|
||||
|
||||
handleMouseEnter = () => {
|
||||
this.setState({ hovered: true });
|
||||
}
|
||||
|
||||
handleMouseLeave = () => {
|
||||
this.setState({ hovered: false });
|
||||
}
|
||||
|
||||
handleInputFocus = () => {
|
||||
this.setState({ focused: true });
|
||||
}
|
||||
|
||||
handleInputBlur = () => {
|
||||
const { dirtyDescription } = this.state;
|
||||
|
||||
this.setState({ focused: false, dirtyDescription: null });
|
||||
|
||||
if (dirtyDescription !== null) {
|
||||
this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, media } = this.props;
|
||||
const active = this.state.hovered || this.state.focused;
|
||||
const description = this.state.dirtyDescription || media.get('description') || '';
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ scale }) => (
|
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
|
||||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
|
||||
|
||||
<div className={classNames('compose-form__upload-description', { active })}>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
|
||||
|
||||
<input
|
||||
placeholder={intl.formatMessage(messages.description)}
|
||||
type='text'
|
||||
value={description}
|
||||
maxLength={420}
|
||||
onFocus={this.handleInputFocus}
|
||||
onChange={this.handleInputChange}
|
||||
onBlur={this.handleInputBlur}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import React from 'react';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
const messages = defineMessages({
|
||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const mapStateToProps = state => ({
|
||||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
const iconStyle = {
|
||||
height: null,
|
||||
lineHeight: '27px',
|
||||
};
|
||||
|
||||
@connect(makeMapStateToProps)
|
||||
@injectIntl
|
||||
export default class UploadButton extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
disabled: PropTypes.bool,
|
||||
onSelectFile: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
resetFileKey: PropTypes.number,
|
||||
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleChange = (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
this.props.onSelectFile(e.target.files);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
this.fileElement.click();
|
||||
}
|
||||
|
||||
setRef = (c) => {
|
||||
this.fileElement = c;
|
||||
}
|
||||
|
||||
render () {
|
||||
|
||||
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload-button'>
|
||||
<IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span>
|
||||
<input
|
||||
key={resetFileKey}
|
||||
ref={this.setRef}
|
||||
type='file'
|
||||
multiple={false}
|
||||
accept={acceptContentTypes.toArray().join(',')}
|
||||
onChange={this.handleChange}
|
||||
disabled={disabled}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import UploadProgressContainer from '../containers/upload_progress_container';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import UploadContainer from '../containers/upload_container';
|
||||
|
||||
export default class UploadForm extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
mediaIds: ImmutablePropTypes.list.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { mediaIds } = this.props;
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload-wrapper'>
|
||||
<UploadProgressContainer />
|
||||
|
||||
<div className='compose-form__uploads-wrapper'>
|
||||
{mediaIds.map(id => (
|
||||
<UploadContainer id={id} key={id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class UploadProgress extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
active: PropTypes.bool,
|
||||
progress: PropTypes.number,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { active, progress } = this.props;
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='upload-progress'>
|
||||
<div className='upload-progress__icon'>
|
||||
<i className='fa fa-upload' />
|
||||
</div>
|
||||
|
||||
<div className='upload-progress__message'>
|
||||
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
|
||||
|
||||
<div className='upload-progress__backdrop'>
|
||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
|
||||
{({ width }) =>
|
||||
<div className='upload-progress__tracker' style={{ width: `${width}%` }} />
|
||||
}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue