0
0
Fork 0

Replace sprockets/browserify with Webpack (#2617)

* Replace browserify with webpack

* Add react-intl-translations-manager

* Do not minify in development, add offline-plugin for ServiceWorker background cache updates

* Adjust tests and dependencies

* Fix production deployments

* Fix tests

* More optimizations

* Improve travis cache for npm stuff

* Re-run travis

* Add back support for custom.scss as before

* Remove offline-plugin and babili

* Fix issue with Immutable.List().unshift(...values) not working as expected

* Make travis load schema instead of running all migrations in sequence

* Fix missing React import in WarningContainer. Optimize rendering performance by using ImmutablePureComponent instead of
React.PureComponent. ImmutablePureComponent uses Immutable.is() to compare props. Replace dynamic callback bindings in
<UI />

* Add react definitions to places that use JSX

* Add Procfile.dev for running rails, webpack and streaming API at the same time
This commit is contained in:
Eugen Rochko 2017-05-03 02:04:16 +02:00 committed by GitHub
parent 26bc591572
commit f5bf5ebb82
343 changed files with 5299 additions and 2081 deletions

View file

View file

@ -0,0 +1,762 @@
import api, { getLinks } from '../api'
import Immutable from 'immutable';
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 ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST';
export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL';
export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST';
export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS';
export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_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 fetchAccountTimeline(id, replace = false) {
return (dispatch, getState) => {
const ids = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = '';
let skipLoading = false;
if (newestId !== null && !replace) {
params = `?since_id=${newestId}`;
skipLoading = true;
}
dispatch(fetchAccountTimelineRequest(id, skipLoading));
api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading));
}).catch(error => {
dispatch(fetchAccountTimelineFail(id, error, skipLoading));
});
};
};
export function expandAccountTimeline(id) {
return (dispatch, getState) => {
const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last();
dispatch(expandAccountTimelineRequest(id));
api(getState).get(`/api/v1/accounts/${id}/statuses`, {
params: {
limit: 10,
max_id: lastId
}
}).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandAccountTimelineSuccess(id, response.data, next));
}).catch(error => {
dispatch(expandAccountTimelineFail(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));
}).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) {
return {
type: ACCOUNT_UNFOLLOW_SUCCESS,
relationship
};
};
export function unfollowAccountFail(error) {
return {
type: ACCOUNT_UNFOLLOW_FAIL,
error
};
};
export function fetchAccountTimelineRequest(id, skipLoading) {
return {
type: ACCOUNT_TIMELINE_FETCH_REQUEST,
id,
skipLoading
};
};
export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) {
return {
type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
id,
statuses,
replace,
skipLoading
};
};
export function fetchAccountTimelineFail(id, error, skipLoading) {
return {
type: ACCOUNT_TIMELINE_FETCH_FAIL,
id,
error,
skipLoading,
skipAlert: error.response.status === 404
};
};
export function expandAccountTimelineRequest(id) {
return {
type: ACCOUNT_TIMELINE_EXPAND_REQUEST,
id
};
};
export function expandAccountTimelineSuccess(id, statuses, next) {
return {
type: ACCOUNT_TIMELINE_EXPAND_SUCCESS,
id,
statuses,
next
};
};
export function expandAccountTimelineFail(id, error) {
return {
type: ACCOUNT_TIMELINE_EXPAND_FAIL,
id,
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) {
return (dispatch, getState) => {
dispatch(muteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/mute`).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(response => 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(response => 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
};
};

View 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
};
};

View 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
};
};

View 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
};
};

View file

@ -0,0 +1,279 @@
import api from '../api';
import { updateTimeline } from './timelines';
import * as emojione from 'emojione';
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_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_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
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 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 = emojione.shortnameToUnicode(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
dispatch(updateTimeline('home', { ...response.data }));
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
if (getState().getIn(['timelines', 'community', 'loaded'])) {
dispatch(updateTimeline('community', { ...response.data }));
}
if (getState().getIn(['timelines', 'public', 'loaded'])) {
dispatch(updateTimeline('public', { ...response.data }));
}
}
}).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 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
};
};
export function fetchComposeSuggestions(token) {
return (dispatch, getState) => {
api(getState).get('/api/v1/accounts/search', {
params: {
q: token,
resolve: false,
limit: 4
}
}).then(response => {
dispatch(readyComposeSuggestions(token, response.data));
});
};
};
export function readyComposeSuggestions(token, accounts) {
return {
type: COMPOSE_SUGGESTIONS_READY,
token,
accounts
};
};
export function selectComposeSuggestion(position, token, accountId) {
return (dispatch, getState) => {
const completion = getState().getIn(['accounts', accountId, 'acct']);
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
position,
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
};
};

View 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
};
};

View file

@ -0,0 +1,235 @@
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 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
};
};

View 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
};
};

View file

@ -0,0 +1,82 @@
import api, { getLinks } from '../api'
import { fetchRelationships } from './accounts';
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 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
};
};

View file

@ -0,0 +1,165 @@
import api, { getLinks } from '../api'
import Immutable from 'immutable';
import IntlMessageFormat from 'intl-messageformat';
import { fetchRelationships } from './accounts';
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';
const fetchRelatedRelationships = (dispatch, notifications) => {
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
if (accountIds > 0) {
dispatch(fetchRelationships(accountIds));
}
};
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 : $('<p>').html(notification.status ? notification.status.content : '').text();
new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
}
};
};
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
export function refreshNotifications() {
return (dispatch, getState) => {
dispatch(refreshNotificationsRequest());
const params = {};
const ids = getState().getIn(['notifications', 'items']);
if (ids.size > 0) {
params.since_id = ids.first().get('id');
}
params.exclude_types = excludeTypesFromSettings(getState());
api(getState).get('/api/v1/notifications', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(refreshNotificationsSuccess(response.data, next ? next.uri : null));
fetchRelatedRelationships(dispatch, response.data);
}).catch(error => {
dispatch(refreshNotificationsFail(error));
});
};
};
export function refreshNotificationsRequest() {
return {
type: NOTIFICATIONS_REFRESH_REQUEST
};
};
export function refreshNotificationsSuccess(notifications, next) {
return {
type: NOTIFICATIONS_REFRESH_SUCCESS,
notifications,
accounts: notifications.map(item => item.account),
statuses: notifications.map(item => item.status).filter(status => !!status),
next
};
};
export function refreshNotificationsFail(error) {
return {
type: NOTIFICATIONS_REFRESH_FAIL,
error
};
};
export function expandNotifications() {
return (dispatch, getState) => {
const url = getState().getIn(['notifications', 'next'], null);
if (url === null || getState().getIn(['notifications', 'isLoading'])) {
return;
}
dispatch(expandNotificationsRequest());
const params = {};
params.exclude_types = excludeTypesFromSettings(getState());
api(getState).get(url, 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
};
};

View 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());
}
};
};

View file

@ -0,0 +1,72 @@
import api from '../api';
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 {
type: REPORT_INIT,
account,
status
};
};
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(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
};
};

View 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
};
};

View file

@ -0,0 +1,19 @@
import axios from 'axios';
export const SETTING_CHANGE = 'SETTING_CHANGE';
export function changeSetting(key, value) {
return {
type: SETTING_CHANGE,
key,
value
};
};
export function saveSettings() {
return (_, getState) => {
axios.put('/api/web/settings', {
data: getState().get('settings').toJS()
});
};
};

View file

@ -0,0 +1,141 @@
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 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(response => {
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.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
};
};

View file

@ -0,0 +1,17 @@
import Immutable from 'immutable';
export const STORE_HYDRATE = 'STORE_HYDRATE';
const convertState = rawState =>
Immutable.fromJS(rawState, (k, v) =>
Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
Number.isNaN(x * 1) ? x : x * 1));
export function hydrateStore(rawState) {
const state = convertState(rawState);
return {
type: STORE_HYDRATE,
state
};
};

View file

@ -0,0 +1,186 @@
import api, { getLinks } from '../api'
import Immutable 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 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) : [];
dispatch({
type: TIMELINE_UPDATE,
timeline,
status,
references
});
};
};
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, id, skipLoading) {
return {
type: TIMELINE_REFRESH_REQUEST,
timeline,
id,
skipLoading
};
};
export function refreshTimeline(timeline, id = null) {
return function (dispatch, getState) {
if (getState().getIn(['timelines', timeline, 'isLoading'])) {
return;
}
const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
const newestId = ids.size > 0 ? ids.first() : null;
let params = getState().getIn(['timelines', timeline, 'params'], {});
const path = getState().getIn(['timelines', timeline, 'path'])(id);
let skipLoading = false;
if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) {
if (id === null && getState().getIn(['timelines', timeline, 'online'])) {
// Skip refreshing when timeline is live anyway
return;
}
params = { ...params, since_id: newestId };
skipLoading = true;
}
dispatch(refreshTimelineRequest(timeline, id, skipLoading));
api(getState).get(path, { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading, next ? next.uri : null));
}).catch(error => {
dispatch(refreshTimelineFail(timeline, error, skipLoading));
});
};
};
export function refreshTimelineFail(timeline, error, skipLoading) {
return {
type: TIMELINE_REFRESH_FAIL,
timeline,
error,
skipLoading
};
};
export function expandTimeline(timeline) {
return (dispatch, getState) => {
if (getState().getIn(['timelines', timeline, 'isLoading'])) {
return;
}
if (getState().getIn(['timelines', timeline, 'items']).size === 0) {
return;
}
const path = getState().getIn(['timelines', timeline, 'path'])(getState().getIn(['timelines', timeline, 'id']));
const params = getState().getIn(['timelines', timeline, 'params'], {});
const lastId = getState().getIn(['timelines', timeline, 'items']).last();
dispatch(expandTimelineRequest(timeline));
api(getState).get(path, {
params: {
...params,
max_id: lastId,
limit: 10
}
}).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(expandTimelineSuccess(timeline, response.data, next ? next.uri : null));
}).catch(error => {
dispatch(expandTimelineFail(timeline, error));
});
};
};
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
};
};

View 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;
}
}]
});

View file

@ -0,0 +1,93 @@
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';
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}' }
});
class Account extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleFollow = this.handleFollow.bind(this);
this.handleBlock = this.handleBlock.bind(this);
this.handleMute = this.handleMute.bind(this);
}
handleFollow () {
this.props.onFollow(this.props.account);
}
handleBlock () {
this.props.onBlock(this.props.account);
}
handleMute () {
this.props.onMute(this.props.account);
}
render () {
const { account, me, intl } = this.props;
if (!account) {
return <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={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
} else if (blocking) {
buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
} else if (muting) {
buttons = <IconButton active={true} icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
} 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 src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={36} /></div>
<DisplayName account={account} />
</Permalink>
<div className='account__relationship'>
{buttons}
</div>
</div>
</div>
);
}
}
Account.propTypes = {
account: ImmutablePropTypes.map.isRequired,
me: PropTypes.number.isRequired,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired
}
export default injectIntl(Account);

View file

@ -0,0 +1,33 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
class AttachmentList extends React.PureComponent {
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>
);
}
}
AttachmentList.propTypes = {
media: ImmutablePropTypes.list.isRequired
};
export default AttachmentList;

View file

@ -0,0 +1,213 @@
import React from 'react';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { isRtl } from '../rtl';
import ImmutablePureComponent from 'react-immutable-pure-component';
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 < 2 || word[0] !== '@') {
return [null, null];
}
word = word.trim().toLowerCase().slice(1);
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
class AutosuggestTextarea extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.state = {
suggestionsHidden: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0
};
this.onChange = this.onChange.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onSuggestionClick = this.onSuggestionClick.bind(this);
this.setTextarea = this.setTextarea.bind(this);
this.onPaste = this.onPaste.bind(this);
}
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();
}
// auto-resize textarea
e.target.style.height = `${e.target.scrollHeight}px`;
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);
}
onBlur () {
// If we hide the suggestions immediately, then this will prevent the
// onClick for the suggestions themselves from firing.
// Setting a short window for that to take place before hiding the
// suggestions ensures that can't happen.
setTimeout(() => {
this.setState({ suggestionsHidden: true });
}, 100);
}
onSuggestionClick (suggestion, e) {
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();
}
}
reset () {
this.textarea.style.height = 'auto';
}
render () {
const { value, suggestions, disabled, placeholder, onKeyUp } = this.props;
const { suggestionsHidden, selectedSuggestion } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
return (
<div className='autosuggest-textarea'>
<textarea
ref={this.setTextarea}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={true}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style}
/>
<div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
{suggestions.map((suggestion, i) => (
<div
role='button'
tabIndex='0'
key={suggestion}
className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
onClick={this.onSuggestionClick.bind(this, suggestion)}>
<AutosuggestAccountContainer id={suggestion} />
</div>
))}
</div>
</div>
);
}
};
AutosuggestTextarea.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,
};
export default AutosuggestTextarea;

View file

@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
class Avatar extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.state = {
hovering: false
};
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
}
handleMouseEnter () {
if (this.props.animate) return;
this.setState({ hovering: true });
}
handleMouseLeave () {
if (this.props.animate) return;
this.setState({ hovering: false });
}
render () {
const { src, size, staticSrc, animate } = this.props;
const { hovering } = this.state;
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='account__avatar'
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={style}
/>
);
}
}
Avatar.propTypes = {
src: PropTypes.string.isRequired,
staticSrc: PropTypes.string,
size: PropTypes.number.isRequired,
style: PropTypes.object,
animate: PropTypes.bool
};
Avatar.defaultProps = {
animate: false
};
export default Avatar;

View file

@ -0,0 +1,50 @@
import React from 'react';
import PropTypes from 'prop-types';
class Button extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
handleClick (e) {
if (!this.props.disabled) {
this.props.onClick();
}
}
render () {
const style = {
display: this.props.block ? 'block' : 'inline-block',
width: this.props.block ? '100%' : 'auto',
padding: `0 ${this.props.size / 2.25}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`
};
return (
<button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ ...style, ...this.props.style }}>
{this.props.text || this.props.children}
</button>
);
}
}
Button.propTypes = {
text: PropTypes.node,
onClick: PropTypes.func,
disabled: PropTypes.bool,
block: PropTypes.bool,
secondary: PropTypes.bool,
size: PropTypes.number,
style: PropTypes.object,
children: PropTypes.node
};
Button.defaultProps = {
size: 36
};
export default Button;

View file

@ -0,0 +1,21 @@
import React from 'react';
import { Motion, spring } from 'react-motion';
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;

View file

@ -0,0 +1,32 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
class ColumnBackButton extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
handleClick () {
if (window.history && window.history.length === 1) this.context.router.push("/");
else this.context.router.goBack();
}
render () {
return (
<div role='button' tabIndex='0' 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' />
</div>
);
}
};
ColumnBackButton.contextTypes = {
router: PropTypes.object
};
export default ColumnBackButton;

View file

@ -0,0 +1,32 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
class ColumnBackButtonSlim extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
handleClick () {
this.context.router.push('/');
}
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>
);
}
}
ColumnBackButtonSlim.contextTypes = {
router: PropTypes.object
};
export default ColumnBackButtonSlim;

View file

@ -0,0 +1,57 @@
import React from 'react';
import { Motion, spring } from 'react-motion';
import PropTypes from 'prop-types';
class ColumnCollapsable extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.state = {
collapsed: true
};
this.handleToggleCollapsed = this.handleToggleCollapsed.bind(this);
}
handleToggleCollapsed () {
const currentState = this.state.collapsed;
this.setState({ collapsed: !currentState });
if (!currentState && this.props.onCollapse) {
this.props.onCollapse();
}
}
render () {
const { icon, title, fullHeight, children } = this.props;
const { collapsed } = this.state;
const collapsedClassName = collapsed ? 'collapsable-collapsed' : 'collapsable';
return (
<div className='column-collapsable'>
<div role='button' tabIndex='0' title={`${title}`} className={`column-icon ${collapsedClassName}`} onClick={this.handleToggleCollapsed}>
<i className={`fa fa-${icon}`} />
</div>
<Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
{({ opacity, height }) =>
<div style={{ overflow: height === fullHeight ? 'auto' : 'hidden', height: `${height}px`, opacity: opacity / 100, maxHeight: '70vh' }}>
{children}
</div>
}
</Motion>
</div>
);
}
}
ColumnCollapsable.propTypes = {
icon: PropTypes.string.isRequired,
title: PropTypes.string,
fullHeight: PropTypes.number.isRequired,
children: PropTypes.node,
onCollapse: PropTypes.func
};
export default ColumnCollapsable;

View file

@ -0,0 +1,25 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import escapeTextContentForBrowser from 'escape-html';
import emojify from '../emoji';
class DisplayName extends React.PureComponent {
render () {
const displayName = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name');
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
return (
<span className='display-name'>
<strong className='display-name__html' dangerouslySetInnerHTML={displayNameHTML} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
</span>
);
}
};
DisplayName.propTypes = {
account: ImmutablePropTypes.map.isRequired
}
export default DisplayName;

View file

@ -0,0 +1,79 @@
import React from 'react';
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
import PropTypes from 'prop-types';
class DropdownMenu extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.state = {
direction: 'left'
};
this.setRef = this.setRef.bind(this);
this.renderItem = this.renderItem.bind(this);
}
setRef (c) {
this.dropdown = c;
}
handleClick (i, e) {
const { action } = this.props.items[i];
if (typeof action === 'function') {
e.preventDefault();
action();
this.dropdown.hide();
}
}
renderItem (item, i) {
if (item === null) {
return <li key={ 'sep' + i } className='dropdown__sep' />;
}
const { text, action, href = '#' } = item;
return (
<li className='dropdown__content-list-item' key={ text + i }>
<a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)} className='dropdown__content-list-link'>
{text}
</a>
</li>
);
}
render () {
const { icon, items, size, direction, ariaLabel } = this.props;
const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
return (
<Dropdown ref={this.setRef}>
<DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }} aria-label={ariaLabel}>
<i className={ `fa fa-fw fa-${icon} dropdown__icon` } aria-hidden={true} />
</DropdownTrigger>
<DropdownContent className={directionClass}>
<ul className='dropdown__content-list'>
{items.map(this.renderItem)}
</ul>
</DropdownContent>
</Dropdown>
);
}
}
DropdownMenu.propTypes = {
icon: PropTypes.string.isRequired,
items: PropTypes.array.isRequired,
size: PropTypes.number.isRequired,
direction: PropTypes.string,
ariaLabel: PropTypes.string
};
DropdownMenu.defaultProps = {
ariaLabel: "Menu"
};
export default DropdownMenu;

View file

@ -0,0 +1,54 @@
import React from 'react';
import PropTypes from 'prop-types';
class ExtendedVideoPlayer extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleLoadedData = this.handleLoadedData.bind(this);
this.setRef = this.setRef.bind(this);
}
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 () {
return (
<div className='extended-video-player'>
<video
ref={this.setRef}
src={this.props.src}
autoPlay
muted={this.props.muted}
controls={this.props.controls}
loop={!this.props.controls}
/>
</div>
);
}
}
ExtendedVideoPlayer.propTypes = {
src: PropTypes.string.isRequired,
time: PropTypes.number,
controls: PropTypes.bool.isRequired,
muted: PropTypes.bool.isRequired
};
export default ExtendedVideoPlayer;

View file

@ -0,0 +1,96 @@
import React from 'react';
import { Motion, spring } from 'react-motion';
import PropTypes from 'prop-types';
class IconButton extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
handleClick (e) {
e.preventDefault();
if (!this.props.disabled) {
this.props.onClick(e);
}
}
render () {
let style = {
fontSize: `${this.props.size}px`,
width: `${this.props.size * 1.28571429}px`,
height: `${this.props.size * 1.28571429}px`,
lineHeight: `${this.props.size}px`,
...this.props.style
};
if (this.props.active) {
style = { ...style, ...this.props.activeStyle };
}
const classes = ['icon-button'];
if (this.props.active) {
classes.push('active');
}
if (this.props.disabled) {
classes.push('disabled');
}
if (this.props.inverted) {
classes.push('inverted');
}
if (this.props.overlay) {
classes.push('overlayed');
}
if (this.props.className) {
classes.push(this.props.className)
}
return (
<Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
{({ rotate }) =>
<button
aria-label={this.props.title}
title={this.props.title}
className={classes.join(' ')}
onClick={this.handleClick}
style={style}>
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
</button>
}
</Motion>
);
}
}
IconButton.propTypes = {
className: PropTypes.string,
title: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
onClick: PropTypes.func,
size: PropTypes.number,
active: PropTypes.bool,
style: PropTypes.object,
activeStyle: PropTypes.object,
disabled: PropTypes.bool,
inverted: PropTypes.bool,
animate: PropTypes.bool,
overlay: PropTypes.bool
};
IconButton.defaultProps = {
size: 18,
active: false,
disabled: false,
animate: false,
overlay: false
};
export default IconButton;

View file

@ -0,0 +1,15 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
const LoadMore = ({ onClick }) => (
<a href="#" className='load-more' role='button' onClick={onClick}>
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
</a>
);
LoadMore.propTypes = {
onClick: PropTypes.func
};
export default LoadMore;

View file

@ -0,0 +1,10 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
const LoadingIndicator = () => (
<div className='loading-indicator'>
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
</div>
);
export default LoadingIndicator;

View file

@ -0,0 +1,196 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
});
class Item extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
handleClick (e) {
const { index, onClick } = this.props;
if (e.button === 0) {
e.preventDefault();
onClick(index);
}
e.stopPropagation();
}
render () {
const { attachment, index, size } = 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') {
thumbnail = (
<a
className='media-gallery__item-thumbnail'
href={attachment.get('remote_url') || attachment.get('url')}
onClick={this.handleClick}
target='_blank'
style={{ backgroundImage: `url(${attachment.get('preview_url')})` }}
/>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && this.props.autoPlayGif;
thumbnail = (
<div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}>
<video
className='media-gallery__item-gifv-thumbnail'
role='application'
src={attachment.get('url')}
onClick={this.handleClick}
autoPlay={autoPlay}
loop={true}
muted={true}
/>
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
}
return (
<div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail}
</div>
);
}
}
Item.propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired,
autoPlayGif: PropTypes.bool.isRequired
};
class MediaGallery extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.state = {
visible: !props.sensitive
};
this.handleOpen = this.handleOpen.bind(this);
this.handleClick = this.handleClick.bind(this);
}
handleOpen (e) {
this.setState({ visible: !this.state.visible });
}
handleClick (index) {
this.props.onOpenMedia(this.props.media, index);
}
render () {
const { media, intl, sensitive } = this.props;
let children;
if (!this.state.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 = (
<div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}>
<span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
const size = media.take(4).size;
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
}
return (
<div className='media-gallery' style={{ height: `${this.props.height}px` }}>
<div className='spoiler-button' style={{ display: !this.state.visible ? 'none' : 'block' }}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div>
{children}
</div>
);
}
}
MediaGallery.propTypes = {
sensitive: PropTypes.bool,
media: ImmutablePropTypes.list.isRequired,
height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
autoPlayGif: PropTypes.bool.isRequired
};
export default injectIntl(MediaGallery);

View 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;

View file

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
class Permalink extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
handleClick (e) {
if (e.button === 0) {
e.preventDefault();
this.context.router.push(this.props.to);
}
}
render () {
const { href, children, className, ...other } = this.props;
return (
<a href={href} onClick={this.handleClick} {...other} className={'permalink ' + className}>
{children}
</a>
);
}
}
Permalink.contextTypes = {
router: PropTypes.object
};
Permalink.propTypes = {
className: PropTypes.string,
href: PropTypes.string.isRequired,
to: PropTypes.string.isRequired,
children: PropTypes.node
};
export default Permalink;

View file

@ -0,0 +1,20 @@
import React from 'react';
import { injectIntl, FormattedRelative } from 'react-intl';
import PropTypes from 'prop-types';
const RelativeTimestamp = ({ intl, timestamp }) => {
const date = new Date(timestamp);
return (
<time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
<FormattedRelative value={date} />
</time>
);
};
RelativeTimestamp.propTypes = {
intl: PropTypes.object.isRequired,
timestamp: PropTypes.string.isRequired
};
export default injectIntl(RelativeTimestamp);

View file

@ -0,0 +1,123 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
import RelativeTimestamp from './relative_timestamp';
import DisplayName from './display_name';
import MediaGallery from './media_gallery';
import VideoPlayer from './video_player';
import AttachmentList from './attachment_list';
import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import { FormattedMessage } from 'react-intl';
import emojify from '../emoji';
import escapeTextContentForBrowser from 'escape-html';
import ImmutablePureComponent from 'react-immutable-pure-component';
class Status extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
this.handleAccountClick = this.handleAccountClick.bind(this);
}
handleClick () {
const { status } = this.props;
this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
}
handleAccountClick (id, e) {
if (e.button === 0) {
e.preventDefault();
this.context.router.push(`/accounts/${id}`);
}
}
render () {
let media = '';
const { status, ...other } = this.props;
if (status === null) {
return <div />;
}
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
let displayName = status.getIn(['account', 'display_name']);
if (displayName.length === 0) {
displayName = status.getIn(['account', 'username']);
}
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
return (
<div className='status__wrapper'>
<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.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
</div>
<Status {...other} wrapped={true} status={status.get('reblog')} />
</div>
);
}
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') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />;
} else {
media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
}
}
return (
<div className={this.props.muted ? 'status muted' : 'status'}>
<div className='status__info'>
<div className='status__info-time'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div>
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'>
<div className='status__avatar'>
<Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
</div>
<DisplayName account={status.get('account')} />
</a>
</div>
<StatusContent status={status} onClick={this.handleClick} />
{media}
<StatusActionBar {...this.props} />
</div>
);
}
}
Status.contextTypes = {
router: PropTypes.object
};
Status.propTypes = {
status: ImmutablePropTypes.map,
wrapped: PropTypes.bool,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
onBlock: PropTypes.func,
me: PropTypes.number,
boostModal: PropTypes.bool,
autoPlayGif: PropTypes.bool,
muted: PropTypes.bool
};
export default Status;

View file

@ -0,0 +1,138 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from './icon_button';
import DropdownMenu from './dropdown_menu';
import { defineMessages, injectIntl } from 'react-intl';
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' },
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}' }
});
class StatusActionBar extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleReplyClick = this.handleReplyClick.bind(this);
this.handleFavouriteClick = this.handleFavouriteClick.bind(this);
this.handleReblogClick = this.handleReblogClick.bind(this);
this.handleDeleteClick = this.handleDeleteClick.bind(this);
this.handleMentionClick = this.handleMentionClick.bind(this);
this.handleMuteClick = this.handleMuteClick.bind(this);
this.handleBlockClick = this.handleBlockClick.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleReport = this.handleReport.bind(this);
}
handleReplyClick () {
this.props.onReply(this.props.status, this.context.router);
}
handleFavouriteClick () {
this.props.onFavourite(this.props.status);
}
handleReblogClick (e) {
this.props.onReblog(this.props.status, e);
}
handleDeleteClick () {
this.props.onDelete(this.props.status);
}
handleMentionClick () {
this.props.onMention(this.props.status.get('account'), this.context.router);
}
handleMuteClick () {
this.props.onMute(this.props.status.get('account'));
}
handleBlockClick () {
this.props.onBlock(this.props.status.get('account'));
}
handleOpen () {
this.context.router.push(`/statuses/${this.props.status.get('id')}`);
}
handleReport () {
this.props.onReport(this.props.status);
this.context.router.push('/report');
}
render () {
const { status, me, intl } = this.props;
const reblog_disabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
let menu = [];
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
menu.push(null);
if (status.getIn(['account', 'id']) === me) {
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 });
}
let reblogIcon = 'retweet';
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
let reply_icon;
let reply_title;
if (status.get('in_reply_to_id', null) === null) {
reply_icon = "reply";
reply_title = intl.formatMessage(messages.reply);
} else {
reply_icon = "reply-all";
reply_title = intl.formatMessage(messages.replyAll);
}
return (
<div className='status__action-bar'>
<div className='status__action-bar-button-wrapper'><IconButton title={reply_title} icon={reply_icon} onClick={this.handleReplyClick} /></div>
<div className='status__action-bar-button-wrapper'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
<div className='status__action-bar-button-wrapper'><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} className='star-icon' /></div>
<div className='status__action-bar-dropdown'>
<DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" ariaLabel="More"/>
</div>
</div>
);
}
}
StatusActionBar.contextTypes = {
router: PropTypes.object
};
StatusActionBar.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,
me: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired
};
export default injectIntl(StatusActionBar);

View file

@ -0,0 +1,165 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import escapeTextContentForBrowser from 'escape-html';
import PropTypes from 'prop-types';
import emojify from '../emoji';
import { isRtl } from '../rtl';
import { FormattedMessage } from 'react-intl';
import Permalink from './permalink';
class StatusContent extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.state = {
hidden: true
};
this.onMentionClick = this.onMentionClick.bind(this);
this.onHashtagClick = this.onHashtagClick.bind(this);
this.handleMouseDown = this.handleMouseDown.bind(this)
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleSpoilerClick = this.handleSpoilerClick.bind(this);
this.setRef = this.setRef.bind(this);
};
componentDidMount () {
const node = this.node;
const links = node.querySelectorAll('a');
for (var i = 0; i < links.length; ++i) {
let link = links[i];
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
let media = this.props.status.get('media_attachments').find(item => link.href === item.get('text_url') || (item.get('remote_url').length > 0 && link.href === item.get('remote_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 if (media) {
link.innerHTML = '<i class="fa fa-fw fa-photo"></i>';
} else {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
link.setAttribute('title', link.href);
}
}
}
onMentionClick (mention, e) {
if (e.button === 0) {
e.preventDefault();
this.context.router.push(`/accounts/${mention.get('id')}`);
}
}
onHashtagClick (hashtag, e) {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (e.button === 0) {
e.preventDefault();
this.context.router.push(`/timelines/tag/${hashtag}`);
}
}
handleMouseDown (e) {
this.startXY = [e.clientX, e.clientY];
}
handleMouseUp (e) {
const [ startX, startY ] = this.startXY;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0) {
this.props.onClick();
}
this.startXY = null;
}
handleSpoilerClick (e) {
e.preventDefault();
this.setState({ hidden: !this.state.hidden });
}
setRef (c) {
this.node = c;
}
render () {
const { status } = this.props;
const { hidden } = this.state;
const content = { __html: emojify(status.get('content')) };
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
const directionStyle = { direction: 'ltr' };
if (isRtl(status.get('content'))) {
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='status__content' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden && status.get('mentions').size === 0 ? '0px' : '' }} >
<span dangerouslySetInnerHTML={spoilerContent} /> <a tabIndex='0' className='status__content__spoiler-link' role='button' onClick={this.handleSpoilerClick}>{toggleText}</a>
</p>
{mentionsPlaceholder}
<div ref={this.setRef} style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} />
</div>
);
} else if (this.props.onClick) {
return (
<div
ref={this.setRef}
className='status__content'
style={{ ...directionStyle }}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
);
} else {
return (
<div
ref={this.setRef}
className='status__content status__content--no-action'
style={{ ...directionStyle }}
dangerouslySetInnerHTML={content}
/>
);
}
}
}
StatusContent.contextTypes = {
router: PropTypes.object
};
StatusContent.propTypes = {
status: ImmutablePropTypes.map.isRequired,
onClick: PropTypes.func
};
export default StatusContent;

View file

@ -0,0 +1,130 @@
import React from 'react';
import Status from './status';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { ScrollContainer } from 'react-router-scroll';
import PropTypes from 'prop-types';
import StatusContainer from '../containers/status_container';
import LoadMore from './load_more';
import ImmutablePureComponent from 'react-immutable-pure-component';
class StatusList extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleScroll = this.handleScroll.bind(this);
this.setRef = this.setRef.bind(this);
this.handleLoadMore = this.handleLoadMore.bind(this);
}
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
this._oldScrollPosition = scrollHeight - scrollTop;
if (250 > 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();
}
}
componentDidMount () {
this.attachScrollListener();
}
componentDidUpdate (prevProps) {
if (this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) {
this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
}
}
componentWillUnmount () {
this.detachScrollListener();
}
attachScrollListener () {
this.node.addEventListener('scroll', this.handleScroll);
}
detachScrollListener () {
this.node.removeEventListener('scroll', this.handleScroll);
}
setRef (c) {
this.node = c;
}
handleLoadMore (e) {
e.preventDefault();
this.props.onScrollToBottom();
}
render () {
const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
let loadMore = '';
let scrollableArea = '';
let unread = '';
if (!isLoading && statusIds.size > 0 && hasMore) {
loadMore = <LoadMore onClick={this.handleLoadMore} />;
}
if (isUnread) {
unread = <div className='status-list__unread-indicator' />;
}
if (isLoading || statusIds.size > 0 || !emptyMessage) {
scrollableArea = (
<div className='scrollable' ref={this.setRef}>
{unread}
<div className='status-list'>
{prepend}
{statusIds.map((statusId) => {
return <StatusContainer key={statusId} id={statusId} />;
})}
{loadMore}
</div>
</div>
);
} else {
scrollableArea = (
<div className='empty-column-indicator' ref={this.setRef}>
{emptyMessage}
</div>
);
}
return (
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
{scrollableArea}
</ScrollContainer>
);
}
}
StatusList.propTypes = {
scrollKey: PropTypes.string.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
onScrollToBottom: PropTypes.func,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
shouldUpdateScroll: PropTypes.func,
isLoading: PropTypes.bool,
isUnread: PropTypes.bool,
hasMore: PropTypes.bool,
prepend: PropTypes.node,
emptyMessage: PropTypes.node
};
StatusList.defaultProps = {
trackScroll: true
};
export default StatusList;

View file

@ -0,0 +1,210 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from './icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { isIOS } from '../is_mobile';
const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
expand_video: { id: 'video_player.video_error', defaultMessage: 'Video could not be played' }
});
class VideoPlayer extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.state = {
visible: !this.props.sensitive,
preview: true,
muted: true,
hasAudio: true,
videoError: false
};
this.handleClick = this.handleClick.bind(this);
this.handleVideoClick = this.handleVideoClick.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleVisibility = this.handleVisibility.bind(this);
this.handleExpand = this.handleExpand.bind(this);
this.setRef = this.setRef.bind(this);
this.handleLoadedData = this.handleLoadedData.bind(this);
this.handleVideoError = this.handleVideoError.bind(this);
}
handleClick () {
this.setState({ muted: !this.state.muted });
}
handleVideoClick (e) {
e.stopPropagation();
const node = this.video;
if (node.paused) {
node.play();
} else {
node.pause();
}
}
handleOpen () {
this.setState({ preview: !this.state.preview });
}
handleVisibility () {
this.setState({
visible: !this.state.visible,
preview: true
});
}
handleExpand () {
this.video.pause();
this.props.onOpenVideo(this.props.media, this.video.currentTime);
}
setRef (c) {
this.video = c;
}
handleLoadedData () {
if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError () {
this.setState({ videoError: true });
}
componentDidMount () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentDidUpdate () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentWillUnmount () {
if (!this.video) {
return;
}
this.video.removeEventListener('loadeddata', this.handleLoadedData);
this.video.removeEventListener('error', this.handleVideoError);
}
render () {
const { media, intl, width, height, sensitive, autoplay } = this.props;
let spoilerButton = (
<div className='status__video-player-spoiler' style={{ display: !this.state.visible ? 'none' : 'block' }} >
<IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
</div>
);
let expandButton = (
<div className='status__video-player-expand'>
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
</div>
);
let muteButton = '';
if (this.state.hasAudio) {
muteButton = (
<div className='status__video-player-mute'>
<IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
</div>
);
}
if (!this.state.visible) {
if (sensitive) {
return (
<div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
return (
<div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
}
}
if (this.state.preview && !autoplay) {
return (
<div role='button' tabIndex='0' className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center` }} onClick={this.handleOpen}>
{spoilerButton}
<div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
</div>
);
}
if (this.state.videoError) {
return (
<div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' >
<span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
</div>
);
}
return (
<div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}>
{spoilerButton}
{muteButton}
{expandButton}
<video
className='status__video-player-video'
role='button'
tabIndex='0'
ref={this.setRef}
src={media.get('url')}
autoPlay={!isIOS()}
loop={true}
muted={this.state.muted}
onClick={this.handleVideoClick}
/>
</div>
);
}
}
VideoPlayer.propTypes = {
media: ImmutablePropTypes.map.isRequired,
width: PropTypes.number,
height: PropTypes.number,
sensitive: PropTypes.bool,
intl: PropTypes.object.isRequired,
autoplay: PropTypes.bool,
onOpenVideo: PropTypes.func.isRequired
};
VideoPlayer.defaultProps = {
width: 239,
height: 110
};
export default injectIntl(VideoPlayer);

View file

@ -0,0 +1,50 @@
import { connect } from 'react-redux';
import { makeGetAccount } from '../selectors';
import Account from '../components/account';
import {
followAccount,
unfollowAccount,
blockAccount,
unblockAccount,
muteAccount,
unmuteAccount,
} from '../actions/accounts';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id),
me: state.getIn(['meta', 'me'])
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following'])) {
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(muteAccount(account.get('id')));
}
}
});
export default connect(makeMapStateToProps, mapDispatchToProps)(Account);

View file

@ -0,0 +1,314 @@
import React from 'react';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import configureStore from '../store/configureStore';
import {
refreshTimelineSuccess,
updateTimeline,
deleteFromTimelines,
refreshTimeline,
connectTimeline,
disconnectTimeline
} from '../actions/timelines';
import { showOnboardingOnce } from '../actions/onboarding';
import { updateNotifications, refreshNotifications } from '../actions/notifications';
import createBrowserHistory from 'history/lib/createBrowserHistory';
import {
applyRouterMiddleware,
useRouterHistory,
Router,
Route,
IndexRedirect,
IndexRoute
} from 'react-router';
import { useScroll } from 'react-router-scroll';
import UI from '../features/ui';
import Status from '../features/status';
import GettingStarted from '../features/getting_started';
import PublicTimeline from '../features/public_timeline';
import CommunityTimeline from '../features/community_timeline';
import AccountTimeline from '../features/account_timeline';
import HomeTimeline from '../features/home_timeline';
import Compose from '../features/compose';
import Followers from '../features/followers';
import Following from '../features/following';
import Reblogs from '../features/reblogs';
import Favourites from '../features/favourites';
import HashtagTimeline from '../features/hashtag_timeline';
import Notifications from '../features/notifications';
import FollowRequests from '../features/follow_requests';
import GenericNotFound from '../features/generic_not_found';
import FavouritedStatuses from '../features/favourited_statuses';
import Blocks from '../features/blocks';
import Mutes from '../features/mutes';
import Report from '../features/report';
import { IntlProvider, addLocaleData } from 'react-intl';
import ar from 'react-intl/locale-data/ar';
import en from 'react-intl/locale-data/en';
import de from 'react-intl/locale-data/de';
import eo from 'react-intl/locale-data/eo';
import es from 'react-intl/locale-data/es';
import fa from 'react-intl/locale-data/fa';
import fi from 'react-intl/locale-data/fi';
import fr from 'react-intl/locale-data/fr';
import he from 'react-intl/locale-data/he';
import hu from 'react-intl/locale-data/hu';
import it from 'react-intl/locale-data/it';
import ja from 'react-intl/locale-data/ja';
import pt from 'react-intl/locale-data/pt';
import nl from 'react-intl/locale-data/nl';
import no from 'react-intl/locale-data/no';
import ru from 'react-intl/locale-data/ru';
import uk from 'react-intl/locale-data/uk';
import zh from 'react-intl/locale-data/zh';
import bg from 'react-intl/locale-data/bg';
import id from 'react-intl/locale-data/id';
import getMessagesForLocale from '../locales';
import { hydrateStore } from '../actions/store';
import createStream from '../stream';
const store = configureStore();
const initialState = JSON.parse(document.getElementById("initial-state").textContent);
store.dispatch(hydrateStore(initialState));
const browserHistory = useRouterHistory(createBrowserHistory)({
basename: '/web'
});
addLocaleData([
...en,
...ar,
...de,
...eo,
...es,
...fa,
...fi,
...fr,
...he,
...hu,
...it,
...ja,
...pt,
...nl,
...no,
...ru,
...uk,
...zh,
...bg,
...id,
]);
const getTopWhenReplacing = (previous, { location }) => location && location.action === 'REPLACE' && [0, 0];
const hiddenColumnContainerStyle = {
position: 'absolute',
left: '0',
top: '0',
visibility: 'hidden'
};
class Container extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
renderedPersistents: [],
unrenderedPersistents: [],
};
}
componentWillMount () {
this.unlistenHistory = null;
this.setState(() => {
return {
mountImpersistent: false,
renderedPersistents: [],
unrenderedPersistents: [
{pathname: '/timelines/home', component: HomeTimeline},
{pathname: '/timelines/public', component: PublicTimeline},
{pathname: '/timelines/public/local', component: CommunityTimeline},
{pathname: '/notifications', component: Notifications},
{pathname: '/favourites', component: FavouritedStatuses}
],
};
}, () => {
if (this.unlistenHistory) {
return;
}
this.unlistenHistory = browserHistory.listen(location => {
const pathname = location.pathname.replace(/\/$/, '').toLowerCase();
this.setState(oldState => {
let persistentMatched = false;
const newState = {
renderedPersistents: oldState.renderedPersistents.map(persistent => {
const givenMatched = persistent.pathname === pathname;
if (givenMatched) {
persistentMatched = true;
}
return {
hidden: !givenMatched,
pathname: persistent.pathname,
component: persistent.component
};
}),
};
if (!persistentMatched) {
newState.unrenderedPersistents = [];
oldState.unrenderedPersistents.forEach(persistent => {
if (persistent.pathname === pathname) {
persistentMatched = true;
newState.renderedPersistents.push({
hidden: false,
pathname: persistent.pathname,
component: persistent.component
});
} else {
newState.unrenderedPersistents.push(persistent);
}
});
}
newState.mountImpersistent = !persistentMatched;
return newState;
});
});
});
}
componentWillUnmount () {
if (this.unlistenHistory) {
this.unlistenHistory();
}
this.unlistenHistory = "done";
}
render () {
// Hide some components rather than unmounting them to allow to show again
// quickly and keep the view state such as the scrolled offset.
const persistentsView = this.state.renderedPersistents.map((persistent) =>
<div aria-hidden={persistent.hidden} key={persistent.pathname} className='mastodon-column-container' style={persistent.hidden ? hiddenColumnContainerStyle : null}>
<persistent.component shouldUpdateScroll={persistent.hidden ? Function.prototype : getTopWhenReplacing} />
</div>
);
return (
<UI>
{this.state.mountImpersistent && this.props.children}
{persistentsView}
</UI>
);
}
}
Container.propTypes = {
children: PropTypes.node,
};
class Mastodon extends React.Component {
componentDidMount() {
const { locale } = this.props;
const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']);
const accessToken = store.getState().getIn(['meta', 'access_token']);
this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', {
connected () {
store.dispatch(connectTimeline('home'));
},
disconnected () {
store.dispatch(disconnectTimeline('home'));
},
received (data) {
switch(data.event) {
case 'update':
store.dispatch(updateTimeline('home', JSON.parse(data.payload)));
break;
case 'delete':
store.dispatch(deleteFromTimelines(data.payload));
break;
case 'notification':
store.dispatch(updateNotifications(JSON.parse(data.payload), getMessagesForLocale(locale), locale));
break;
}
},
reconnected () {
store.dispatch(connectTimeline('home'));
store.dispatch(refreshTimeline('home'));
store.dispatch(refreshNotifications());
}
});
// Desktop notifications
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission();
}
store.dispatch(showOnboardingOnce());
}
componentWillUnmount () {
if (typeof this.subscription !== 'undefined') {
this.subscription.close();
this.subscription = null;
}
}
render () {
const { locale } = this.props;
return (
<IntlProvider locale={locale} messages={getMessagesForLocale(locale)}>
<Provider store={store}>
<Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
<Route path='/' component={Container}>
<IndexRedirect to='/getting-started' />
<Route path='getting-started' component={GettingStarted} />
<Route path='timelines/tag/:id' component={HashtagTimeline} />
<Route path='statuses/new' component={Compose} />
<Route path='statuses/:statusId' component={Status} />
<Route path='statuses/:statusId/reblogs' component={Reblogs} />
<Route path='statuses/:statusId/favourites' component={Favourites} />
<Route path='accounts/:accountId' component={AccountTimeline} />
<Route path='accounts/:accountId/followers' component={Followers} />
<Route path='accounts/:accountId/following' component={Following} />
<Route path='follow_requests' component={FollowRequests} />
<Route path='blocks' component={Blocks} />
<Route path='mutes' component={Mutes} />
<Route path='report' component={Report} />
<Route path='*' component={GenericNotFound} />
</Route>
</Router>
</Provider>
</IntlProvider>
);
}
}
Mastodon.propTypes = {
locale: PropTypes.string.isRequired
};
export default Mastodon;

View file

@ -0,0 +1,118 @@
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
} from '../actions/interactions';
import {
blockAccount,
muteAccount
} from '../actions/accounts';
import { deleteStatus } from '../actions/statuses';
import { initReport } from '../actions/reports';
import { openModal } from '../actions/modal';
import { createSelector } from 'reselect'
import { isMobile } from '../is_mobile'
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
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' },
muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' },
});
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => ({
status: getStatus(state, props.id),
me: state.getIn(['meta', 'me']),
boostModal: state.getIn(['meta', 'boost_modal']),
autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
});
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 || !this.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));
}
},
onDelete (status) {
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(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.muteConfirm),
onConfirm: () => dispatch(muteAccount(account.get('id')))
}));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

View file

@ -0,0 +1,35 @@
import emojione from 'emojione';
const toImage = str => shortnameToImage(unicodeToImage(str));
const unicodeToImage = str => {
const mappedUnicode = emojione.mapUnicodeToShort();
return str.replace(emojione.regUnicode, unicodeChar => {
if (typeof unicodeChar === 'undefined' || unicodeChar === '' || !(unicodeChar in emojione.jsEscapeMap)) {
return unicodeChar;
}
const unicode = emojione.jsEscapeMap[unicodeChar];
const short = mappedUnicode[unicode];
const filename = emojione.emojioneList[short].fname;
const alt = emojione.convert(unicode.toUpperCase());
return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`;
});
};
const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => {
if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) {
return shortname;
}
const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1];
const alt = emojione.convert(unicode.toUpperCase());
return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${unicode}.svg" />`;
});
export default function emojify(text) {
return toImage(text);
};

View file

@ -0,0 +1,93 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import DropdownMenu from '../../../components/dropdown_menu';
import { Link } from 'react-router';
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
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}' },
disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' }
});
class ActionBar extends React.PureComponent {
render () {
const { account, me, intl } = this.props;
let menu = [];
let extraInfo = '';
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
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')) {
extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>;
}
return (
<div className='account__action-bar'>
<div className='account__action-bar-dropdown'>
<DropdownMenu 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')} /> {extraInfo}</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')} /> {extraInfo}</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')} /> {extraInfo}</strong>
</Link>
</div>
</div>
);
}
}
ActionBar.propTypes = {
account: ImmutablePropTypes.map.isRequired,
me: PropTypes.number.isRequired,
onFollow: PropTypes.func,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired
};
export default injectIntl(ActionBar);

View file

@ -0,0 +1,150 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import emojify from '../../../emoji';
import escapeTextContentForBrowser from 'escape-html';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button';
import { Motion, spring } from 'react-motion';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
});
const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
});
return mapStateToProps;
};
class Avatar extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.state = {
isHovered: false
};
this.handleMouseOver = this.handleMouseOver.bind(this);
this.handleMouseOut = this.handleMouseOut.bind(this);
}
handleMouseOver () {
if (this.state.isHovered) return;
this.setState({ isHovered: true });
}
handleMouseOut () {
if (!this.state.isHovered) return;
this.setState({ isHovered: false });
}
render () {
const { account, autoPlayGif } = this.props;
const { isHovered } = this.state;
return (
<Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
{({ radius }) =>
<a
href={account.get('url')}
className='account__header__avatar'
target='_blank'
rel='noopener'
style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
onFocus={this.handleMouseOver}
onBlur={this.handleMouseOut}
/>
}
</Motion>
);
}
}
Avatar.propTypes = {
account: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool.isRequired
};
class Header extends ImmutablePureComponent {
render () {
const { account, me, intl } = this.props;
if (!account) {
return null;
}
let displayName = account.get('display_name');
let info = '';
let actionBtn = '';
let lockedIcon = '';
if (displayName.length === 0) {
displayName = account.get('username');
}
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
info = <span className='account--follows-info' style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>
}
if (me !== account.get('id')) {
if (account.getIn(['relationship', 'requested'])) {
actionBtn = (
<div style={{ position: 'absolute', top: '10px', left: '20px' }}>
<IconButton size={26} disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
</div>
);
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = (
<div style={{ position: 'absolute', top: '10px', left: '20px' }}>
<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: emojify(account.get('note')) };
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
return (
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div style={{ padding: '20px 10px' }}>
<Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
<span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
<span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
<div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
{info}
{actionBtn}
</div>
</div>
);
}
}
Header.propTypes = {
account: ImmutablePropTypes.map,
me: PropTypes.number.isRequired,
onFollow: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
autoPlayGif: PropTypes.bool.isRequired
};
export default connect(makeMapStateToProps)(injectIntl(Header));

View file

@ -0,0 +1,83 @@
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';
class Header extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleFollow = this.handleFollow.bind(this);
this.handleBlock = this.handleBlock.bind(this);
this.handleMention = this.handleMention.bind(this);
this.handleReport = this.handleReport.bind(this);
this.handleMute = this.handleMute.bind(this);
}
handleFollow () {
this.props.onFollow(this.props.account);
}
handleBlock () {
this.props.onBlock(this.props.account);
}
handleMention () {
this.props.onMention(this.props.account, this.context.router);
}
handleReport () {
this.props.onReport(this.props.account);
this.context.router.push('/report');
}
handleMute() {
this.props.onMute(this.props.account);
}
render () {
const { account, me } = this.props;
if (account === null) {
return <MissingIndicator />;
}
return (
<div className='account-timeline__header'>
<InnerHeader
account={account}
me={me}
onFollow={this.handleFollow}
/>
<ActionBar
account={account}
me={me}
onBlock={this.handleBlock}
onMention={this.handleMention}
onReport={this.handleReport}
onMute={this.handleMute}
/>
</div>
);
}
}
Header.propTypes = {
account: ImmutablePropTypes.map,
me: PropTypes.number.isRequired,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired
};
Header.contextTypes = {
router: PropTypes.object
};
export default Header;

View file

@ -0,0 +1,76 @@
import React from 'react';
import { connect } from 'react-redux';
import { makeGetAccount } from '../../../selectors';
import Header from '../components/header';
import {
followAccount,
unfollowAccount,
blockAccount,
unblockAccount,
muteAccount,
unmuteAccount
} from '../../../actions/accounts';
import { mentionCompose } from '../../../actions/compose';
import { initReport } from '../../../actions/reports';
import { openModal } from '../../../actions/modal';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, Number(accountId)),
me: state.getIn(['meta', 'me'])
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following'])) {
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(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.muteConfirm),
onConfirm: () => dispatch(muteAccount(account.get('id')))
}));
}
}
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));

View file

@ -0,0 +1,89 @@
import React from 'react';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import {
fetchAccount,
fetchAccountTimeline,
expandAccountTimeline
} from '../../actions/accounts';
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 Immutable from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
const mapStateToProps = (state, props) => ({
statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items'], Immutable.List()),
isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']),
hasMore: !!state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'next']),
me: state.getIn(['meta', 'me'])
});
class AccountTimeline extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleScrollToBottom = this.handleScrollToBottom.bind(this);
}
componentWillMount () {
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId)));
}
componentWillReceiveProps(nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
}
}
handleScrollToBottom () {
if (!this.props.isLoading && this.props.hasMore) {
this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
}
}
render () {
const { statusIds, isLoading, hasMore, me } = 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}
me={me}
onScrollToBottom={this.handleScrollToBottom}
/>
</Column>
);
}
}
AccountTimeline.propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
me: PropTypes.number.isRequired
};
export default connect(mapStateToProps)(AccountTimeline);

View file

@ -0,0 +1,74 @@
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';
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'])
});
class Blocks extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleScroll = this.handleScroll.bind(this);
}
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>
);
}
}
Blocks.propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired
};
export default connect(mapStateToProps)(injectIntl(Blocks));

View file

@ -0,0 +1,96 @@
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 '../ui/components/column';
import {
refreshTimeline,
updateTimeline,
deleteFromTimelines,
connectTimeline,
disconnectTimeline
} from '../../actions/timelines';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import createStream from '../../stream';
const messages = defineMessages({
title: { id: 'column.community', defaultMessage: 'Local timeline' }
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token'])
});
let subscription;
class CommunityTimeline extends React.PureComponent {
componentDidMount () {
const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
dispatch(refreshTimeline('community'));
if (typeof subscription !== 'undefined') {
return;
}
subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', {
connected () {
dispatch(connectTimeline('community'));
},
reconnected () {
dispatch(connectTimeline('community'));
},
disconnected () {
dispatch(disconnectTimeline('community'));
},
received (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline('community', JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
}
}
});
}
componentWillUnmount () {
// if (typeof subscription !== 'undefined') {
// subscription.close();
// subscription = null;
// }
}
render () {
const { intl, hasUnread } = this.props;
return (
<Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}>
<ColumnBackButtonSlim />
<StatusListContainer {...this.props} scrollKey='community_timeline' type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} />
</Column>
);
}
}
CommunityTimeline.propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool
};
export default connect(mapStateToProps)(injectIntl(CommunityTimeline));

View file

@ -0,0 +1,26 @@
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';
class AutosuggestAccount extends ImmutablePureComponent {
render () {
const { account } = this.props;
return (
<div className='autosuggest-account'>
<div className='autosuggest-account-icon'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={18} /></div>
<DisplayName account={account} />
</div>
);
}
}
AutosuggestAccount.propTypes = {
account: ImmutablePropTypes.map.isRequired
};
export default AutosuggestAccount;

View file

@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import { length } from 'stringz';
class CharacterCounter extends React.PureComponent {
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);
}
}
CharacterCounter.propTypes = {
text: PropTypes.string.isRequired,
max: PropTypes.number.isRequired
}
export default CharacterCounter;

View file

@ -0,0 +1,211 @@
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 { debounce } from 'react-decoration';
import UploadButtonContainer from '../containers/upload_button_container';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Toggle from 'react-toggle';
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 './emoji_picker_dropdown';
import UploadFormContainer from '../containers/upload_form_container';
import TextIconButton from './text_icon_button';
import WarningContainer from '../containers/warning_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' },
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }
});
class ComposeForm extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleChange = this.handleChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.onSuggestionsClearRequested = this.onSuggestionsClearRequested.bind(this);
this.onSuggestionsFetchRequested = this.onSuggestionsFetchRequested.bind(this);
this.onSuggestionSelected = this.onSuggestionSelected.bind(this);
this.handleChangeSpoilerText = this.handleChangeSpoilerText.bind(this);
this.setAutosuggestTextarea = this.setAutosuggestTextarea.bind(this);
this.handleEmojiPick = this.handleEmojiPick.bind(this);
}
handleChange (e) {
this.props.onChange(e.target.value);
}
handleKeyDown (e) {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
}
handleSubmit () {
this.autosuggestTextarea.reset();
this.props.onSubmit();
}
onSuggestionsClearRequested () {
this.props.onClearSuggestions();
}
@debounce(500)
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();
}
}
setAutosuggestTextarea (c) {
this.autosuggestTextarea = c;
}
handleEmojiPick (data) {
const position = this.autosuggestTextarea.textarea.selectionStart;
this._restoreCaret = position + data.shortname.length + 1;
this.props.onPickEmoji(position, data);
}
render () {
const { intl, onPaste } = this.props;
const disabled = this.props.is_submitting;
const text = [this.props.spoiler_text, this.props.text].join('');
let publishText = '';
let reply_to_other = false;
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 = intl.formatMessage(messages.publish) + (this.props.privacy !== 'unlisted' ? '!' : '');
}
return (
<div className='compose-form'>
<Collapsable isVisible={this.props.spoiler} fullHeight={50}>
<div className="spoiler-input">
<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'/>
</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}
/>
<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 || text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length > 500 || (text.length !==0 && text.trim().length === 0)} block /></div>
</div>
</div>
</div>
);
}
}
ComposeForm.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,
me: PropTypes.number,
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
};
export default injectIntl(ComposeForm);

View file

@ -0,0 +1,115 @@
import React from 'react';
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
import EmojiPicker from 'emojione-picker';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
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 settings = {
imageType: 'png',
sprites: false,
imagePathPNG: '/emoji/'
};
const dropdownStyle = {
position: 'absolute',
right: '5px',
top: '5px'
};
const dropdownTriggerStyle = {
display: 'block',
fontSize: '24px',
lineHeight: '24px',
marginLeft: '2px',
width: '24px'
}
class EmojiPickerDropdown extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.setRef = this.setRef.bind(this);
this.handleChange = this.handleChange.bind(this);
}
setRef (c) {
this.dropdown = c;
}
handleChange (data) {
this.dropdown.hide();
this.props.onPickEmoji(data);
}
render () {
const { intl } = this.props;
const categories = {
people: {
title: intl.formatMessage(messages.people),
emoji: 'smile',
},
nature: {
title: intl.formatMessage(messages.nature),
emoji: 'hamster',
},
food: {
title: intl.formatMessage(messages.food),
emoji: 'pizza',
},
activity: {
title: intl.formatMessage(messages.activity),
emoji: 'soccer',
},
travel: {
title: intl.formatMessage(messages.travel),
emoji: 'earth_americas',
},
objects: {
title: intl.formatMessage(messages.objects),
emoji: 'bulb',
},
symbols: {
title: intl.formatMessage(messages.symbols),
emoji: 'clock9',
},
flags: {
title: intl.formatMessage(messages.flags),
emoji: 'flag_gb',
}
}
return (
<Dropdown ref={this.setRef} style={dropdownStyle}>
<DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={dropdownTriggerStyle}>
<img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" />
</DropdownTrigger>
<DropdownContent className='dropdown__left'>
<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} categories={categories} search={true} />
</DropdownContent>
</Dropdown>
);
}
}
EmojiPickerDropdown.propTypes = {
intl: PropTypes.object.isRequired,
onPickEmoji: PropTypes.func.isRequired
};
export default injectIntl(EmojiPickerDropdown);

View file

@ -0,0 +1,37 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import IconButton from '../../../components/icon_button';
import DisplayName from '../../../components/display_name';
import Permalink from '../../../components/permalink';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router';
import ImmutablePureComponent from 'react-immutable-pure-component';
class NavigationBar extends ImmutablePureComponent {
render () {
return (
<div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<Avatar src={this.props.account.get('avatar')} animate 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>
</div>
);
}
}
NavigationBar.propTypes = {
account: ImmutablePropTypes.map.isRequired
};
export default NavigationBar;

View file

@ -0,0 +1,105 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import IconButton from '../../../components/icon_button';
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 iconStyle = {
height: null,
lineHeight: '27px'
}
class PrivacyDropdown extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.state = {
open: false
};
this.handleToggle = this.handleToggle.bind(this);
this.handleClick = this.handleClick.bind(this);
this.onGlobalClick = this.onGlobalClick.bind(this);
this.setRef = this.setRef.bind(this);
}
handleToggle () {
this.setState({ open: !this.state.open });
}
handleClick (value, e) {
e.preventDefault();
this.setState({ open: false });
this.props.onChange(value);
}
onGlobalClick (e) {
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
this.setState({ open: false });
}
}
componentDidMount () {
window.addEventListener('click', this.onGlobalClick);
window.addEventListener('touchstart', this.onGlobalClick);
}
componentWillUnmount () {
window.removeEventListener('click', this.onGlobalClick);
window.removeEventListener('touchstart', this.onGlobalClick);
}
setRef (c) {
this.node = c;
}
render () {
const { value, onChange, intl } = this.props;
const { open } = this.state;
const options = [
{ icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) },
{ icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) },
{ icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) },
{ icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) }
];
const valueOption = options.find(item => item.value === value);
return (
<div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
<div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle}/></div>
<div className='privacy-dropdown__dropdown'>
{options.map(item =>
<div role='button' tabIndex='0' key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
<div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
<div className='privacy-dropdown__option__content'>
<strong>{item.shortText}</strong>
{item.longText}
</div>
</div>
)}
</div>
</div>
);
}
}
PrivacyDropdown.propTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired
};
export default injectIntl(PrivacyDropdown);

View file

@ -0,0 +1,71 @@
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 emojify from '../../../emoji';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }
});
class ReplyIndicator extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
this.handleAccountClick = this.handleAccountClick.bind(this);
}
handleClick () {
this.props.onCancel();
}
handleAccountClick (e) {
if (e.button === 0) {
e.preventDefault();
this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
}
render () {
const { status, intl } = this.props;
if (!status) {
return null;
}
const content = { __html: emojify(status.get('content')) };
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 size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div>
<DisplayName account={status.get('account')} />
</a>
</div>
<div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
</div>
);
}
}
ReplyIndicator.contextTypes = {
router: PropTypes.object
};
ReplyIndicator.propTypes = {
status: ImmutablePropTypes.map,
onCancel: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired
};
export default injectIntl(ReplyIndicator);

View file

@ -0,0 +1,82 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }
});
class Search extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleChange = this.handleChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleFocus = this.handleFocus.bind(this);
this.handleClear = this.handleClear.bind(this);
}
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();
}
}
noop () {
}
handleFocus () {
this.props.onShow();
}
render () {
const { intl, value, submitted } = this.props;
const hasValue = value.length > 0 || submitted;
return (
<div className='search'>
<input
className='search__input'
type='text'
placeholder={intl.formatMessage(messages.placeholder)}
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyDown}
onFocus={this.handleFocus}
/>
<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>
</div>
);
}
}
Search.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
};
export default injectIntl(Search);

View file

@ -0,0 +1,67 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container';
import { Link } from 'react-router';
import ImmutablePureComponent from 'react-immutable-pure-component';
class SearchResults extends ImmutablePureComponent {
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 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>
);
}
}
SearchResults.propTypes = {
results: ImmutablePropTypes.map.isRequired
};
export default SearchResults;

View file

@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
class TextIconButton extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
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>
);
}
}
TextIconButton.propTypes = {
label: PropTypes.string.isRequired,
title: PropTypes.string,
active: PropTypes.bool,
onClick: PropTypes.func.isRequired,
ariaControls: PropTypes.string
};
export default TextIconButton;

View file

@ -0,0 +1,61 @@
import React from 'react';
import IconButton from '../../../components/icon_button';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add media' }
});
const iconStyle = {
height: null,
lineHeight: '27px'
}
class UploadButton extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleChange = this.handleChange.bind(this);
this.handleClick = this.handleClick.bind(this);
this.setRef = this.setRef.bind(this);
}
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 } = 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}/>
<input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
</div>
);
}
}
UploadButton.propTypes = {
disabled: PropTypes.bool,
onSelectFile: PropTypes.func.isRequired,
style: PropTypes.object,
resetFileKey: PropTypes.number,
intl: PropTypes.object.isRequired
};
export default injectIntl(UploadButton);

View file

@ -0,0 +1,46 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import UploadProgressContainer from '../containers/upload_progress_container';
import { Motion, spring } from 'react-motion';
const messages = defineMessages({
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }
});
class UploadForm extends React.PureComponent {
render () {
const { intl, media } = this.props;
const uploads = media.map(attachment =>
<div className='compose-form__upload' key={attachment.get('id')}>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) =>
<div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
</div>
}
</Motion>
</div>
);
return (
<div className='compose-form__upload-wrapper'>
<UploadProgressContainer />
<div className='compose-form__uploads-wrapper'>{uploads}</div>
</div>
);
}
}
UploadForm.propTypes = {
media: ImmutablePropTypes.list.isRequired,
onRemoveFile: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired
};
export default injectIntl(UploadForm);

View file

@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Motion, spring } from 'react-motion';
import { FormattedMessage } from 'react-intl';
class UploadProgress extends React.PureComponent {
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>
);
}
}
UploadProgress.propTypes = {
active: PropTypes.bool,
progress: PropTypes.number
};
export default UploadProgress;

View file

@ -0,0 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
class Warning extends React.PureComponent {
constructor (props) {
super(props);
}
render () {
const { message } = this.props;
return (
<div className='compose-form__warning'>
{message}
</div>
);
}
}
Warning.propTypes = {
message: PropTypes.node.isRequired
};
export default Warning;

View file

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import AutosuggestAccount from '../components/autosuggest_account';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { id }) => ({
account: getAccount(state, id)
});
return mapStateToProps;
};
export default connect(makeMapStateToProps)(AutosuggestAccount);

View file

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import AutosuggestStatus from '../components/autosuggest_status';
import { makeGetStatus } from '../../../selectors';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, { id }) => ({
status: getStatus(state, id)
});
return mapStateToProps;
};
export default connect(makeMapStateToProps)(AutosuggestStatus);

View file

@ -0,0 +1,64 @@
import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form';
import { uploadCompose } from '../../../actions/compose';
import {
changeCompose,
submitCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose
} from '../../../actions/compose';
const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']),
suggestion_token: state.getIn(['compose', 'suggestion_token']),
suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['compose', 'spoiler']),
spoiler_text: state.getIn(['compose', 'spoiler_text']),
privacy: state.getIn(['compose', 'privacy']),
focusDate: state.getIn(['compose', 'focusDate']),
preselectDate: state.getIn(['compose', 'preselectDate']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
me: state.getIn(['compose', 'me'])
});
const mapDispatchToProps = (dispatch) => ({
onChange (text) {
dispatch(changeCompose(text));
},
onSubmit () {
dispatch(submitCompose());
},
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions (token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected (position, token, accountId) {
dispatch(selectComposeSuggestion(position, token, accountId));
},
onChangeSpoilerText (checked) {
dispatch(changeComposeSpoilerText(checked));
},
onPaste (files) {
dispatch(uploadCompose(files));
},
onPickEmoji (position, data) {
dispatch(insertEmojiCompose(position, data));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);

View file

@ -0,0 +1,10 @@
import { connect } from 'react-redux';
import NavigationBar from '../components/navigation_bar';
const mapStateToProps = (state, props) => {
return {
account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
};
};
export default connect(mapStateToProps)(NavigationBar);

View file

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import PrivacyDropdown from '../components/privacy_dropdown';
import { changeComposeVisibility } from '../../../actions/compose';
const mapStateToProps = state => ({
value: state.getIn(['compose', 'privacy'])
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeComposeVisibility(value));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);

View file

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

View file

@ -0,0 +1,35 @@
import { connect } from 'react-redux';
import {
changeSearch,
clearSearch,
submitSearch,
showSearch
} from '../../../actions/search';
import Search from '../components/search';
const mapStateToProps = state => ({
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted'])
});
const mapDispatchToProps = dispatch => ({
onChange (value) {
dispatch(changeSearch(value));
},
onClear () {
dispatch(clearSearch());
},
onSubmit () {
dispatch(submitSearch());
},
onShow () {
dispatch(showSearch());
}
});
export default connect(mapStateToProps, mapDispatchToProps)(Search);

View file

@ -0,0 +1,8 @@
import { connect } from 'react-redux';
import SearchResults from '../components/search_results';
const mapStateToProps = state => ({
results: state.getIn(['search', 'results'])
});
export default connect(mapStateToProps)(SearchResults);

View file

@ -0,0 +1,51 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import TextIconButton from '../components/text_icon_button';
import { changeComposeSensitivity } from '../../../actions/compose';
import { Motion, spring } from 'react-motion';
import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' }
});
const mapStateToProps = state => ({
visible: state.getIn(['compose', 'media_attachments']).size > 0,
active: state.getIn(['compose', 'sensitive'])
});
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch(changeComposeSensitivity());
}
});
class SensitiveButton extends React.PureComponent {
render () {
const { visible, active, onClick, intl } = this.props;
return (
<Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
{({ scale }) =>
<div style={{ display: visible ? 'block' : 'none', transform: `translateZ(0) scale(${scale})` }}>
<TextIconButton onClick={onClick} label='NSFW' title={intl.formatMessage(messages.title)} active={active} />
</div>
}
</Motion>
);
}
}
SensitiveButton.propTypes = {
visible: PropTypes.bool,
active: PropTypes.bool,
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired
};
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));

View file

@ -0,0 +1,25 @@
import { connect } from 'react-redux';
import TextIconButton from '../components/text_icon_button';
import { changeComposeSpoilerness } from '../../../actions/compose';
import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' }
});
const mapStateToProps = (state, { intl }) => ({
label: 'CW',
title: intl.formatMessage(messages.title),
active: state.getIn(['compose', 'spoiler']),
ariaControls: 'cw-spoiler-input'
});
const mapDispatchToProps = dispatch => ({
onClick () {
dispatch(changeComposeSpoilerness());
}
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));

View file

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

View file

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import UploadForm from '../components/upload_form';
import { undoUploadCompose } from '../../../actions/compose';
const mapStateToProps = (state, props) => ({
media: state.getIn(['compose', 'media_attachments']),
});
const mapDispatchToProps = dispatch => ({
onRemoveFile (media_id) {
dispatch(undoUploadCompose(media_id));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import UploadProgress from '../components/upload_progress';
const mapStateToProps = (state, props) => ({
active: state.getIn(['compose', 'is_uploading']),
progress: state.getIn(['compose', 'progress'])
});
export default connect(mapStateToProps)(UploadProgress);

View file

@ -0,0 +1,49 @@
import React from 'react';
import { connect } from 'react-redux';
import Warning from '../components/warning';
import { createSelector } from 'reselect';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => {
return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : [];
});
const mapStateToProps = state => {
const mentionedUsernames = getMentionedUsernames(state);
const mentionedUsernamesWithDomains = getMentionedDomains(state);
return {
needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null,
mentionedDomains: mentionedUsernamesWithDomains,
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked'])
};
};
const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => {
if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
} else if (needsLeakWarning) {
return (
<Warning
message={<FormattedMessage
id='compose_form.privacy_disclaimer'
defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.'
values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }}
/>}
/>
);
}
return null;
};
WarningWrapper.propTypes = {
needsLeakWarning: PropTypes.bool,
needsLockWarning: PropTypes.bool,
mentionedDomains: PropTypes.array.isRequired,
};
export default connect(mapStateToProps)(WarningWrapper);

View file

@ -0,0 +1,86 @@
import React from 'react';
import ComposeFormContainer from './containers/compose_form_container';
import UploadFormContainer from './containers/upload_form_container';
import NavigationContainer from './containers/navigation_container';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { mountCompose, unmountCompose } from '../../actions/compose';
import { Link } from 'react-router';
import { injectIntl, defineMessages } from 'react-intl';
import SearchContainer from './containers/search_container';
import { Motion, spring } from 'react-motion';
import SearchResultsContainer from './containers/search_results_container';
const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
});
const mapStateToProps = state => ({
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden'])
});
class Compose extends React.PureComponent {
componentDidMount () {
this.props.dispatch(mountCompose());
}
componentWillUnmount () {
this.props.dispatch(unmountCompose());
}
render () {
const { withHeader, showSearch, intl } = this.props;
let header = '';
if (withHeader) {
header = (
<div className='drawer__header'>
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)}><i role="img" aria-label={intl.formatMessage(messages.start)} className='fa fa-fw fa-asterisk' /></Link>
<Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)}><i role="img" aria-label={intl.formatMessage(messages.community)} className='fa fa-fw fa-users' /></Link>
<Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)}><i role="img" aria-label={intl.formatMessage(messages.public)} className='fa fa-fw fa-globe' /></Link>
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)}><i role="img" aria-label={intl.formatMessage(messages.preferences)} className='fa fa-fw fa-cog' /></a>
<a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)}><i role="img" aria-label={intl.formatMessage(messages.logout)} className='fa fa-fw fa-sign-out' /></a>
</div>
);
}
return (
<div className='drawer'>
{header}
<SearchContainer />
<div className='drawer__pager'>
<div className='drawer__inner'>
<NavigationContainer />
<ComposeFormContainer />
</div>
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) =>
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
<SearchResultsContainer />
</div>
}
</Motion>
</div>
</div>
);
}
}
Compose.propTypes = {
dispatch: PropTypes.func.isRequired,
withHeader: PropTypes.bool,
showSearch: PropTypes.bool,
intl: PropTypes.object.isRequired
};
export default connect(mapStateToProps)(injectIntl(Compose));

View file

@ -0,0 +1,67 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
import Column from '../ui/components/column';
import StatusList from '../../components/status_list';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
heading: { id: 'column.favourites', defaultMessage: 'Favourites' }
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
me: state.getIn(['meta', 'me'])
});
class Favourites extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleScrollToBottom = this.handleScrollToBottom.bind(this);
}
componentWillMount () {
this.props.dispatch(fetchFavouritedStatuses());
}
handleScrollToBottom () {
this.props.dispatch(expandFavouritedStatuses());
}
render () {
const { statusIds, loaded, intl, me } = this.props;
if (!loaded) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column icon='star' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<StatusList {...this.props} scrollKey='favourited_statuses' onScrollToBottom={this.handleScrollToBottom} />
</Column>
);
}
}
Favourites.propTypes = {
dispatch: PropTypes.func.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
loaded: PropTypes.bool,
intl: PropTypes.object.isRequired,
me: PropTypes.number.isRequired
};
export default connect(mapStateToProps)(injectIntl(Favourites));

View file

@ -0,0 +1,61 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { fetchFavourites } from '../../actions/interactions';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';
import ColumnBackButton from '../../components/column_back_button';
import ImmutablePureComponent from 'react-immutable-pure-component';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)])
});
class Favourites extends ImmutablePureComponent {
componentWillMount () {
this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
}
componentWillReceiveProps(nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
}
}
render () {
const { accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column>
<ColumnBackButton />
<ScrollContainer scrollKey='favourites'>
<div className='scrollable'>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div>
</ScrollContainer>
</Column>
);
}
}
Favourites.propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
};
export default connect(mapStateToProps)(Favourites);

View file

@ -0,0 +1,51 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Permalink from '../../../components/permalink';
import Avatar from '../../../components/avatar';
import DisplayName from '../../../components/display_name';
import emojify from '../../../emoji';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }
});
class AccountAuthorize extends ImmutablePureComponent {
render () {
const { intl, account, onAuthorize, onReject } = this.props;
const content = { __html: emojify(account.get('note')) };
return (
<div className='account-authorize__wrapper'>
<div className='account-authorize'>
<Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'>
<div className='account-authorize__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={48} /></div>
<DisplayName account={account} />
</Permalink>
<div className='account__header__content' dangerouslySetInnerHTML={content} />
</div>
<div className='account--panel'>
<div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div>
<div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div>
</div>
</div>
);
}
}
AccountAuthorize.propTypes = {
account: ImmutablePropTypes.map.isRequired,
onAuthorize: PropTypes.func.isRequired,
onReject: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired
};
export default injectIntl(AccountAuthorize);

View file

@ -0,0 +1,26 @@
import { connect } from 'react-redux';
import { makeGetAccount } from '../../../selectors';
import AccountAuthorize from '../components/account_authorize';
import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, props) => ({
account: getAccount(state, props.id)
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { id }) => ({
onAuthorize (account) {
dispatch(authorizeFollowRequest(id));
},
onReject (account) {
dispatch(rejectFollowRequest(id));
}
});
export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);

View file

@ -0,0 +1,74 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { ScrollContainer } from 'react-router-scroll';
import Column from '../ui/components/column';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import AccountAuthorizeContainer from './containers/account_authorize_container';
import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }
});
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'follow_requests', 'items'])
});
class FollowRequests extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleScroll = this.handleScroll.bind(this);
}
componentWillMount () {
this.props.dispatch(fetchFollowRequests());
}
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandFollowRequests());
}
}
render () {
const { intl, accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column icon='users' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollContainer scrollKey='follow_requests'>
<div className='scrollable' onScroll={this.handleScroll}>
{accountIds.map(id =>
<AccountAuthorizeContainer key={id} id={id} />
)}
</div>
</ScrollContainer>
</Column>
);
}
}
FollowRequests.propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired
};
export default connect(mapStateToProps)(injectIntl(FollowRequests));

View file

@ -0,0 +1,92 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import {
fetchAccount,
fetchFollowers,
expandFollowers
} from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';
import HeaderContainer from '../account_timeline/containers/header_container';
import LoadMore from '../../components/load_more';
import ColumnBackButton from '../../components/column_back_button';
import ImmutablePureComponent from 'react-immutable-pure-component';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items'])
});
class Followers extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleScroll = this.handleScroll.bind(this);
this.handleLoadMore = this.handleLoadMore.bind(this);
}
componentWillMount () {
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
}
componentWillReceiveProps(nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
}
}
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
}
}
handleLoadMore (e) {
e.preventDefault();
this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
}
render () {
const { accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column>
<ColumnBackButton />
<ScrollContainer scrollKey='followers'>
<div className='scrollable' onScroll={this.handleScroll}>
<div className='followers'>
<HeaderContainer accountId={this.props.params.accountId} />
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
<LoadMore onClick={this.handleLoadMore} />
</div>
</div>
</ScrollContainer>
</Column>
);
}
}
Followers.propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
};
export default connect(mapStateToProps)(Followers);

View file

@ -0,0 +1,92 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import {
fetchAccount,
fetchFollowing,
expandFollowing
} from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';
import HeaderContainer from '../account_timeline/containers/header_container';
import LoadMore from '../../components/load_more';
import ColumnBackButton from '../../components/column_back_button';
import ImmutablePureComponent from 'react-immutable-pure-component';
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items'])
});
class Following extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleScroll = this.handleScroll.bind(this);
this.handleLoadMore = this.handleLoadMore.bind(this);
}
componentWillMount () {
this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
}
componentWillReceiveProps(nextProps) {
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
}
}
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
}
}
handleLoadMore (e) {
e.preventDefault();
this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
}
render () {
const { accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column>
<ColumnBackButton />
<ScrollContainer scrollKey='following'>
<div className='scrollable' onScroll={this.handleScroll}>
<div className='following'>
<HeaderContainer accountId={this.props.params.accountId} />
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
<LoadMore onClick={this.handleLoadMore} />
</div>
</div>
</ScrollContainer>
</Column>
);
}
}
Following.propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list
};
export default connect(mapStateToProps)(Following);

View file

@ -0,0 +1,11 @@
import React from 'react';
import Column from '../ui/components/column';
import MissingIndicator from '../../components/missing_indicator';
const GenericNotFound = () => (
<Column>
<MissingIndicator />
</Column>
);
export default GenericNotFound;

View file

@ -0,0 +1,73 @@
import React from 'react';
import Column from '../ui/components/column';
import ColumnLink from '../ui/components/column_link';
import ColumnSubheading from '../ui/components/column_subheading';
import { Link } from 'react-router';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation'},
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings'},
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }
});
const mapStateToProps = state => ({
me: state.getIn(['accounts', state.getIn(['meta', 'me'])])
});
class GettingStarted extends ImmutablePureComponent {
render () {
const { intl, me } = this.props;
let followRequests = '';
if (me.get('locked')) {
followRequests = <ColumnLink icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />;
}
return (
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile={true}>
<div className='getting-started__wrapper'>
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)}/>
<ColumnLink icon='users' hideOnMobile={true} text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />
<ColumnLink icon='globe' hideOnMobile={true} text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
<ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
{followRequests}
<ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
<ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)}/>
<ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div>
<div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
<div className='static-content getting-started'>
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
</div>
</div>
</Column>
);
}
}
GettingStarted.propTypes = {
intl: PropTypes.object.isRequired,
me: ImmutablePropTypes.map.isRequired
};
export default connect(mapStateToProps)(injectIntl(GettingStarted));

View file

@ -0,0 +1,90 @@
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 '../ui/components/column';
import {
refreshTimeline,
updateTimeline,
deleteFromTimelines
} from '../../actions/timelines';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import { FormattedMessage } from 'react-intl';
import createStream from '../../stream';
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
accessToken: state.getIn(['meta', 'access_token'])
});
class HashtagTimeline extends React.PureComponent {
_subscribe (dispatch, id) {
const { streamingAPIBaseURL, accessToken } = this.props;
this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, {
received (data) {
switch(data.event) {
case 'update':
dispatch(updateTimeline('tag', JSON.parse(data.payload)));
break;
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
}
}
});
}
_unsubscribe () {
if (typeof this.subscription !== 'undefined') {
this.subscription.close();
this.subscription = null;
}
}
componentDidMount () {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(refreshTimeline('tag', id));
this._subscribe(dispatch, id);
}
componentWillReceiveProps (nextProps) {
if (nextProps.params.id !== this.props.params.id) {
this.props.dispatch(refreshTimeline('tag', nextProps.params.id));
this._unsubscribe();
this._subscribe(this.props.dispatch, nextProps.params.id);
}
}
componentWillUnmount () {
this._unsubscribe();
}
render () {
const { id, hasUnread } = this.props.params;
return (
<Column icon='hashtag' active={hasUnread} heading={id}>
<ColumnBackButtonSlim />
<StatusListContainer scrollKey='hashtag_timeline' type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} />
</Column>
);
}
}
HashtagTimeline.propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool
};
export default connect(mapStateToProps)(HashtagTimeline);

View file

@ -0,0 +1,51 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnCollapsable from '../../../components/column_collapsable';
import SettingToggle from '../../notifications/components/setting_toggle';
import SettingText from './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' }
});
class ColumnSettings extends React.PureComponent {
render () {
const { settings, onChange, onSave, intl } = this.props;
return (
<ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={209} onCollapse={onSave}>
<div className='column-settings__outer'>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
</div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</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>
</ColumnCollapsable>
);
}
}
ColumnSettings.propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired
}
export default injectIntl(ColumnSettings);

View file

@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
class SettingText extends React.PureComponent {
constructor (props, context) {
super(props, context);
this.handleChange = this.handleChange.bind(this);
}
handleChange (e) {
this.props.onChange(this.props.settingKey, e.target.value)
}
render () {
const { settings, settingKey, label } = this.props;
return (
<input
className='setting-text'
value={settings.getIn(settingKey)}
onChange={this.handleChange}
placeholder={label}
/>
);
}
}
SettingText.propTypes = {
settings: ImmutablePropTypes.map.isRequired,
settingKey: PropTypes.array.isRequired,
label: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired
};
export default SettingText;

View file

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

View file

@ -0,0 +1,38 @@
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 '../ui/components/column';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { Link } from 'react-router';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' }
});
const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0
});
class HomeTimeline extends React.PureComponent {
render () {
const { intl, hasUnread } = this.props;
return (
<Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}>
<ColumnSettingsContainer />
<StatusListContainer {...this.props} scrollKey='home_timeline' type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} />
</Column>
);
}
}
HomeTimeline.propTypes = {
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool
};
export default connect(mapStateToProps)(injectIntl(HomeTimeline));

View file

@ -0,0 +1,75 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator';
import { ScrollContainer } from 'react-router-scroll';
import Column from '../ui/components/column';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import AccountContainer from '../../containers/account_container';
import { fetchMutes, expandMutes } from '../../actions/mutes';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
heading: { id: 'column.mutes', defaultMessage: 'Muted users' }
});
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'mutes', 'items'])
});
class Mutes extends ImmutablePureComponent {
constructor (props, context) {
super(props, context);
this.handleScroll = this.handleScroll.bind(this);
}
componentWillMount () {
this.props.dispatch(fetchMutes());
}
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandMutes());
}
}
render () {
const { intl, accountIds } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column icon='volume-off' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollContainer scrollKey='mutes'>
<div className='scrollable mutes' onScroll={this.handleScroll}>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />
)}
</div>
</ScrollContainer>
</Column>
);
}
}
Mutes.propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired
};
export default connect(mapStateToProps)(injectIntl(Mutes));

View file

@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
clear: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }
});
class ClearColumnButton extends React.Component {
render () {
const { intl } = this.props;
return (
<div role='button' title={intl.formatMessage(messages.clear)} className='column-icon column-icon-clear' tabIndex='0' onClick={this.props.onClick}>
<i className='fa fa-eraser' />
</div>
);
}
}
ClearColumnButton.propTypes = {
onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired
};
export default injectIntl(ClearColumnButton);

View file

@ -0,0 +1,71 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnCollapsable from '../../../components/column_collapsable';
import SettingToggle from './setting_toggle';
const messages = defineMessages({
settings: { id: 'notifications.settings', defaultMessage: 'Column settings' }
});
class ColumnSettings extends React.PureComponent {
render () {
const { settings, intl, onChange, onSave } = this.props;
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
return (
<ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={616} onCollapse={onSave}>
<div className='column-settings__outer'>
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
</div>
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
</div>
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
</div>
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
<SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div>
</div>
</ColumnCollapsable>
);
}
}
ColumnSettings.propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired
}).isRequired
};
export default injectIntl(ColumnSettings);

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