@ -0,0 +1,659 @@ | |||
import api, { getLinks } from '../api'; | |||
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; | |||
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; | |||
export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; | |||
export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; | |||
export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; | |||
export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; | |||
export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; | |||
export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; | |||
export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; | |||
export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; | |||
export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; | |||
export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; | |||
export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; | |||
export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; | |||
export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; | |||
export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; | |||
export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; | |||
export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; | |||
export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; | |||
export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; | |||
export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; | |||
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; | |||
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; | |||
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; | |||
export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST'; | |||
export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS'; | |||
export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL'; | |||
export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; | |||
export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; | |||
export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; | |||
export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; | |||
export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; | |||
export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; | |||
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; | |||
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; | |||
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; | |||
export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; | |||
export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; | |||
export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; | |||
export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; | |||
export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; | |||
export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; | |||
export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; | |||
export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; | |||
export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; | |||
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; | |||
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; | |||
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; | |||
export function fetchAccount(id) { | |||
return (dispatch, getState) => { | |||
dispatch(fetchRelationships([id])); | |||
if (getState().getIn(['accounts', id], null) !== null) { | |||
return; | |||
} | |||
dispatch(fetchAccountRequest(id)); | |||
api(getState).get(`/api/v1/accounts/${id}`).then(response => { | |||
dispatch(fetchAccountSuccess(response.data)); | |||
}).catch(error => { | |||
dispatch(fetchAccountFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchAccountRequest(id) { | |||
return { | |||
type: ACCOUNT_FETCH_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function fetchAccountSuccess(account) { | |||
return { | |||
type: ACCOUNT_FETCH_SUCCESS, | |||
account, | |||
}; | |||
}; | |||
export function fetchAccountFail(id, error) { | |||
return { | |||
type: ACCOUNT_FETCH_FAIL, | |||
id, | |||
error, | |||
skipAlert: true, | |||
}; | |||
}; | |||
export function followAccount(id) { | |||
return (dispatch, getState) => { | |||
dispatch(followAccountRequest(id)); | |||
api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => { | |||
dispatch(followAccountSuccess(response.data)); | |||
}).catch(error => { | |||
dispatch(followAccountFail(error)); | |||
}); | |||
}; | |||
}; | |||
export function unfollowAccount(id) { | |||
return (dispatch, getState) => { | |||
dispatch(unfollowAccountRequest(id)); | |||
api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { | |||
dispatch(unfollowAccountSuccess(response.data, getState().get('statuses'))); | |||
}).catch(error => { | |||
dispatch(unfollowAccountFail(error)); | |||
}); | |||
}; | |||
}; | |||
export function followAccountRequest(id) { | |||
return { | |||
type: ACCOUNT_FOLLOW_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function followAccountSuccess(relationship) { | |||
return { | |||
type: ACCOUNT_FOLLOW_SUCCESS, | |||
relationship, | |||
}; | |||
}; | |||
export function followAccountFail(error) { | |||
return { | |||
type: ACCOUNT_FOLLOW_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function unfollowAccountRequest(id) { | |||
return { | |||
type: ACCOUNT_UNFOLLOW_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function unfollowAccountSuccess(relationship, statuses) { | |||
return { | |||
type: ACCOUNT_UNFOLLOW_SUCCESS, | |||
relationship, | |||
statuses, | |||
}; | |||
}; | |||
export function unfollowAccountFail(error) { | |||
return { | |||
type: ACCOUNT_UNFOLLOW_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function blockAccount(id) { | |||
return (dispatch, getState) => { | |||
dispatch(blockAccountRequest(id)); | |||
api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { | |||
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers | |||
dispatch(blockAccountSuccess(response.data, getState().get('statuses'))); | |||
}).catch(error => { | |||
dispatch(blockAccountFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function unblockAccount(id) { | |||
return (dispatch, getState) => { | |||
dispatch(unblockAccountRequest(id)); | |||
api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => { | |||
dispatch(unblockAccountSuccess(response.data)); | |||
}).catch(error => { | |||
dispatch(unblockAccountFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function blockAccountRequest(id) { | |||
return { | |||
type: ACCOUNT_BLOCK_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function blockAccountSuccess(relationship, statuses) { | |||
return { | |||
type: ACCOUNT_BLOCK_SUCCESS, | |||
relationship, | |||
statuses, | |||
}; | |||
}; | |||
export function blockAccountFail(error) { | |||
return { | |||
type: ACCOUNT_BLOCK_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function unblockAccountRequest(id) { | |||
return { | |||
type: ACCOUNT_UNBLOCK_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function unblockAccountSuccess(relationship) { | |||
return { | |||
type: ACCOUNT_UNBLOCK_SUCCESS, | |||
relationship, | |||
}; | |||
}; | |||
export function unblockAccountFail(error) { | |||
return { | |||
type: ACCOUNT_UNBLOCK_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function muteAccount(id, notifications) { | |||
return (dispatch, getState) => { | |||
dispatch(muteAccountRequest(id)); | |||
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { | |||
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers | |||
dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); | |||
}).catch(error => { | |||
dispatch(muteAccountFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function unmuteAccount(id) { | |||
return (dispatch, getState) => { | |||
dispatch(unmuteAccountRequest(id)); | |||
api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { | |||
dispatch(unmuteAccountSuccess(response.data)); | |||
}).catch(error => { | |||
dispatch(unmuteAccountFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function muteAccountRequest(id) { | |||
return { | |||
type: ACCOUNT_MUTE_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function muteAccountSuccess(relationship, statuses) { | |||
return { | |||
type: ACCOUNT_MUTE_SUCCESS, | |||
relationship, | |||
statuses, | |||
}; | |||
}; | |||
export function muteAccountFail(error) { | |||
return { | |||
type: ACCOUNT_MUTE_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function unmuteAccountRequest(id) { | |||
return { | |||
type: ACCOUNT_UNMUTE_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function unmuteAccountSuccess(relationship) { | |||
return { | |||
type: ACCOUNT_UNMUTE_SUCCESS, | |||
relationship, | |||
}; | |||
}; | |||
export function unmuteAccountFail(error) { | |||
return { | |||
type: ACCOUNT_UNMUTE_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function fetchFollowers(id) { | |||
return (dispatch, getState) => { | |||
dispatch(fetchFollowersRequest(id)); | |||
api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); | |||
dispatch(fetchRelationships(response.data.map(item => item.id))); | |||
}).catch(error => { | |||
dispatch(fetchFollowersFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchFollowersRequest(id) { | |||
return { | |||
type: FOLLOWERS_FETCH_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function fetchFollowersSuccess(id, accounts, next) { | |||
return { | |||
type: FOLLOWERS_FETCH_SUCCESS, | |||
id, | |||
accounts, | |||
next, | |||
}; | |||
}; | |||
export function fetchFollowersFail(id, error) { | |||
return { | |||
type: FOLLOWERS_FETCH_FAIL, | |||
id, | |||
error, | |||
}; | |||
}; | |||
export function expandFollowers(id) { | |||
return (dispatch, getState) => { | |||
const url = getState().getIn(['user_lists', 'followers', id, 'next']); | |||
if (url === null) { | |||
return; | |||
} | |||
dispatch(expandFollowersRequest(id)); | |||
api(getState).get(url).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); | |||
dispatch(fetchRelationships(response.data.map(item => item.id))); | |||
}).catch(error => { | |||
dispatch(expandFollowersFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function expandFollowersRequest(id) { | |||
return { | |||
type: FOLLOWERS_EXPAND_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function expandFollowersSuccess(id, accounts, next) { | |||
return { | |||
type: FOLLOWERS_EXPAND_SUCCESS, | |||
id, | |||
accounts, | |||
next, | |||
}; | |||
}; | |||
export function expandFollowersFail(id, error) { | |||
return { | |||
type: FOLLOWERS_EXPAND_FAIL, | |||
id, | |||
error, | |||
}; | |||
}; | |||
export function fetchFollowing(id) { | |||
return (dispatch, getState) => { | |||
dispatch(fetchFollowingRequest(id)); | |||
api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); | |||
dispatch(fetchRelationships(response.data.map(item => item.id))); | |||
}).catch(error => { | |||
dispatch(fetchFollowingFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchFollowingRequest(id) { | |||
return { | |||
type: FOLLOWING_FETCH_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function fetchFollowingSuccess(id, accounts, next) { | |||
return { | |||
type: FOLLOWING_FETCH_SUCCESS, | |||
id, | |||
accounts, | |||
next, | |||
}; | |||
}; | |||
export function fetchFollowingFail(id, error) { | |||
return { | |||
type: FOLLOWING_FETCH_FAIL, | |||
id, | |||
error, | |||
}; | |||
}; | |||
export function expandFollowing(id) { | |||
return (dispatch, getState) => { | |||
const url = getState().getIn(['user_lists', 'following', id, 'next']); | |||
if (url === null) { | |||
return; | |||
} | |||
dispatch(expandFollowingRequest(id)); | |||
api(getState).get(url).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); | |||
dispatch(fetchRelationships(response.data.map(item => item.id))); | |||
}).catch(error => { | |||
dispatch(expandFollowingFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function expandFollowingRequest(id) { | |||
return { | |||
type: FOLLOWING_EXPAND_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function expandFollowingSuccess(id, accounts, next) { | |||
return { | |||
type: FOLLOWING_EXPAND_SUCCESS, | |||
id, | |||
accounts, | |||
next, | |||
}; | |||
}; | |||
export function expandFollowingFail(id, error) { | |||
return { | |||
type: FOLLOWING_EXPAND_FAIL, | |||
id, | |||
error, | |||
}; | |||
}; | |||
export function fetchRelationships(accountIds) { | |||
return (dispatch, getState) => { | |||
const loadedRelationships = getState().get('relationships'); | |||
const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); | |||
if (newAccountIds.length === 0) { | |||
return; | |||
} | |||
dispatch(fetchRelationshipsRequest(newAccountIds)); | |||
api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { | |||
dispatch(fetchRelationshipsSuccess(response.data)); | |||
}).catch(error => { | |||
dispatch(fetchRelationshipsFail(error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchRelationshipsRequest(ids) { | |||
return { | |||
type: RELATIONSHIPS_FETCH_REQUEST, | |||
ids, | |||
skipLoading: true, | |||
}; | |||
}; | |||
export function fetchRelationshipsSuccess(relationships) { | |||
return { | |||
type: RELATIONSHIPS_FETCH_SUCCESS, | |||
relationships, | |||
skipLoading: true, | |||
}; | |||
}; | |||
export function fetchRelationshipsFail(error) { | |||
return { | |||
type: RELATIONSHIPS_FETCH_FAIL, | |||
error, | |||
skipLoading: true, | |||
}; | |||
}; | |||
export function fetchFollowRequests() { | |||
return (dispatch, getState) => { | |||
dispatch(fetchFollowRequestsRequest()); | |||
api(getState).get('/api/v1/follow_requests').then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); | |||
}).catch(error => dispatch(fetchFollowRequestsFail(error))); | |||
}; | |||
}; | |||
export function fetchFollowRequestsRequest() { | |||
return { | |||
type: FOLLOW_REQUESTS_FETCH_REQUEST, | |||
}; | |||
}; | |||
export function fetchFollowRequestsSuccess(accounts, next) { | |||
return { | |||
type: FOLLOW_REQUESTS_FETCH_SUCCESS, | |||
accounts, | |||
next, | |||
}; | |||
}; | |||
export function fetchFollowRequestsFail(error) { | |||
return { | |||
type: FOLLOW_REQUESTS_FETCH_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function expandFollowRequests() { | |||
return (dispatch, getState) => { | |||
const url = getState().getIn(['user_lists', 'follow_requests', 'next']); | |||
if (url === null) { | |||
return; | |||
} | |||
dispatch(expandFollowRequestsRequest()); | |||
api(getState).get(url).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); | |||
}).catch(error => dispatch(expandFollowRequestsFail(error))); | |||
}; | |||
}; | |||
export function expandFollowRequestsRequest() { | |||
return { | |||
type: FOLLOW_REQUESTS_EXPAND_REQUEST, | |||
}; | |||
}; | |||
export function expandFollowRequestsSuccess(accounts, next) { | |||
return { | |||
type: FOLLOW_REQUESTS_EXPAND_SUCCESS, | |||
accounts, | |||
next, | |||
}; | |||
}; | |||
export function expandFollowRequestsFail(error) { | |||
return { | |||
type: FOLLOW_REQUESTS_EXPAND_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function authorizeFollowRequest(id) { | |||
return (dispatch, getState) => { | |||
dispatch(authorizeFollowRequestRequest(id)); | |||
api(getState) | |||
.post(`/api/v1/follow_requests/${id}/authorize`) | |||
.then(() => dispatch(authorizeFollowRequestSuccess(id))) | |||
.catch(error => dispatch(authorizeFollowRequestFail(id, error))); | |||
}; | |||
}; | |||
export function authorizeFollowRequestRequest(id) { | |||
return { | |||
type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function authorizeFollowRequestSuccess(id) { | |||
return { | |||
type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, | |||
id, | |||
}; | |||
}; | |||
export function authorizeFollowRequestFail(id, error) { | |||
return { | |||
type: FOLLOW_REQUEST_AUTHORIZE_FAIL, | |||
id, | |||
error, | |||
}; | |||
}; | |||
export function rejectFollowRequest(id) { | |||
return (dispatch, getState) => { | |||
dispatch(rejectFollowRequestRequest(id)); | |||
api(getState) | |||
.post(`/api/v1/follow_requests/${id}/reject`) | |||
.then(() => dispatch(rejectFollowRequestSuccess(id))) | |||
.catch(error => dispatch(rejectFollowRequestFail(id, error))); | |||
}; | |||
}; | |||
export function rejectFollowRequestRequest(id) { | |||
return { | |||
type: FOLLOW_REQUEST_REJECT_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function rejectFollowRequestSuccess(id) { | |||
return { | |||
type: FOLLOW_REQUEST_REJECT_SUCCESS, | |||
id, | |||
}; | |||
}; | |||
export function rejectFollowRequestFail(id, error) { | |||
return { | |||
type: FOLLOW_REQUEST_REJECT_FAIL, | |||
id, | |||
error, | |||
}; | |||
}; |
@ -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, | |||
}; | |||
}; |
@ -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, | |||
}; | |||
}; |
@ -0,0 +1,25 @@ | |||
export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; | |||
export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; | |||
export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; | |||
export function fetchBundleRequest(skipLoading) { | |||
return { | |||
type: BUNDLE_FETCH_REQUEST, | |||
skipLoading, | |||
}; | |||
} | |||
export function fetchBundleSuccess(skipLoading) { | |||
return { | |||
type: BUNDLE_FETCH_SUCCESS, | |||
skipLoading, | |||
}; | |||
} | |||
export function fetchBundleFail(error, skipLoading) { | |||
return { | |||
type: BUNDLE_FETCH_FAIL, | |||
error, | |||
skipLoading, | |||
}; | |||
} |
@ -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, | |||
}; | |||
}; |
@ -0,0 +1,40 @@ | |||
import { saveSettings } from './settings'; | |||
export const COLUMN_ADD = 'COLUMN_ADD'; | |||
export const COLUMN_REMOVE = 'COLUMN_REMOVE'; | |||
export const COLUMN_MOVE = 'COLUMN_MOVE'; | |||
export function addColumn(id, params) { | |||
return dispatch => { | |||
dispatch({ | |||
type: COLUMN_ADD, | |||
id, | |||
params, | |||
}); | |||
dispatch(saveSettings()); | |||
}; | |||
}; | |||
export function removeColumn(uuid) { | |||
return dispatch => { | |||
dispatch({ | |||
type: COLUMN_REMOVE, | |||
uuid, | |||
}); | |||
dispatch(saveSettings()); | |||
}; | |||
}; | |||
export function moveColumn(uuid, direction) { | |||
return dispatch => { | |||
dispatch({ | |||
type: COLUMN_MOVE, | |||
uuid, | |||
direction, | |||
}); | |||
dispatch(saveSettings()); | |||
}; | |||
}; |
@ -0,0 +1,376 @@ | |||
import api from '../api'; | |||
import { throttle } from 'lodash'; | |||
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; | |||
import { useEmoji } from './emojis'; | |||
import { | |||
updateTimeline, | |||
refreshHomeTimeline, | |||
refreshCommunityTimeline, | |||
refreshPublicTimeline, | |||
} from './timelines'; | |||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; | |||
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; | |||
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; | |||
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; | |||
export const COMPOSE_REPLY = 'COMPOSE_REPLY'; | |||
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; | |||
export const COMPOSE_MENTION = 'COMPOSE_MENTION'; | |||
export const COMPOSE_RESET = 'COMPOSE_RESET'; | |||
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; | |||
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; | |||
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; | |||
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; | |||
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; | |||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; | |||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; | |||
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; | |||
export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; | |||
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; | |||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; | |||
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; | |||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; | |||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; | |||
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; | |||
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; | |||
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; | |||
export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; | |||
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; | |||
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; | |||
export function changeCompose(text) { | |||
return { | |||
type: COMPOSE_CHANGE, | |||
text: text, | |||
}; | |||
}; | |||
export function replyCompose(status, router) { | |||
return (dispatch, getState) => { | |||
dispatch({ | |||
type: COMPOSE_REPLY, | |||
status: status, | |||
}); | |||
if (!getState().getIn(['compose', 'mounted'])) { | |||
router.push('/statuses/new'); | |||
} | |||
}; | |||
}; | |||
export function cancelReplyCompose() { | |||
return { | |||
type: COMPOSE_REPLY_CANCEL, | |||
}; | |||
}; | |||
export function resetCompose() { | |||
return { | |||
type: COMPOSE_RESET, | |||
}; | |||
}; | |||
export function mentionCompose(account, router) { | |||
return (dispatch, getState) => { | |||
dispatch({ | |||
type: COMPOSE_MENTION, | |||
account: account, | |||
}); | |||
if (!getState().getIn(['compose', 'mounted'])) { | |||
router.push('/statuses/new'); | |||
} | |||
}; | |||
}; | |||
export function submitCompose() { | |||
return function (dispatch, getState) { | |||
const status = getState().getIn(['compose', 'text'], ''); | |||
if (!status || !status.length) { | |||
return; | |||
} | |||
dispatch(submitComposeRequest()); | |||
api(getState).post('/api/v1/statuses', { | |||
status, | |||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), | |||
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), | |||
sensitive: getState().getIn(['compose', 'sensitive']), | |||
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), | |||
visibility: getState().getIn(['compose', 'privacy']), | |||
}, { | |||
headers: { | |||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), | |||
}, | |||
}).then(function (response) { | |||
dispatch(submitComposeSuccess({ ...response.data })); | |||
// To make the app more responsive, immediately get the status into the columns | |||
const insertOrRefresh = (timelineId, refreshAction) => { | |||
if (getState().getIn(['timelines', timelineId, 'online'])) { | |||
dispatch(updateTimeline(timelineId, { ...response.data })); | |||
} else if (getState().getIn(['timelines', timelineId, 'loaded'])) { | |||
dispatch(refreshAction()); | |||
} | |||
}; | |||
insertOrRefresh('home', refreshHomeTimeline); | |||
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { | |||
insertOrRefresh('community', refreshCommunityTimeline); | |||
insertOrRefresh('public', refreshPublicTimeline); | |||
} | |||
}).catch(function (error) { | |||
dispatch(submitComposeFail(error)); | |||
}); | |||
}; | |||
}; | |||
export function submitComposeRequest() { | |||
return { | |||
type: COMPOSE_SUBMIT_REQUEST, | |||
}; | |||
}; | |||
export function submitComposeSuccess(status) { | |||
return { | |||
type: COMPOSE_SUBMIT_SUCCESS, | |||
status: status, | |||
}; | |||
}; | |||
export function submitComposeFail(error) { | |||
return { | |||
type: COMPOSE_SUBMIT_FAIL, | |||
error: error, | |||
}; | |||
}; | |||
export function uploadCompose(files) { | |||
return function (dispatch, getState) { | |||
if (getState().getIn(['compose', 'media_attachments']).size > 3) { | |||
return; | |||
} | |||
dispatch(uploadComposeRequest()); | |||
let data = new FormData(); | |||
data.append('file', files[0]); | |||
api(getState).post('/api/v1/media', data, { | |||
onUploadProgress: function (e) { | |||
dispatch(uploadComposeProgress(e.loaded, e.total)); | |||
}, | |||
}).then(function (response) { | |||
dispatch(uploadComposeSuccess(response.data)); | |||
}).catch(function (error) { | |||
dispatch(uploadComposeFail(error)); | |||
}); | |||
}; | |||
}; | |||
export function changeUploadCompose(id, description) { | |||
return (dispatch, getState) => { | |||
dispatch(changeUploadComposeRequest()); | |||
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { | |||
dispatch(changeUploadComposeSuccess(response.data)); | |||
}).catch(error => { | |||
dispatch(changeUploadComposeFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function changeUploadComposeRequest() { | |||
return { | |||
type: COMPOSE_UPLOAD_CHANGE_REQUEST, | |||
skipLoading: true, | |||
}; | |||
}; | |||
export function changeUploadComposeSuccess(media) { | |||
return { | |||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS, | |||
media: media, | |||
skipLoading: true, | |||
}; | |||
}; | |||
export function changeUploadComposeFail(error) { | |||
return { | |||
type: COMPOSE_UPLOAD_CHANGE_FAIL, | |||
error: error, | |||
skipLoading: true, | |||
}; | |||
}; | |||
export function uploadComposeRequest() { | |||
return { | |||
type: COMPOSE_UPLOAD_REQUEST, | |||
skipLoading: true, | |||
}; | |||
}; | |||
export function uploadComposeProgress(loaded, total) { | |||
return { | |||
type: COMPOSE_UPLOAD_PROGRESS, | |||
loaded: loaded, | |||
total: total, | |||
}; | |||
}; | |||
export function uploadComposeSuccess(media) { | |||
return { | |||
type: COMPOSE_UPLOAD_SUCCESS, | |||
media: media, | |||
skipLoading: true, | |||
}; | |||
}; | |||
export function uploadComposeFail(error) { | |||
return { | |||
type: COMPOSE_UPLOAD_FAIL, | |||
error: error, | |||
skipLoading: true, | |||
}; | |||
}; | |||
export function undoUploadCompose(media_id) { | |||
return { | |||
type: COMPOSE_UPLOAD_UNDO, | |||
media_id: media_id, | |||
}; | |||
}; | |||
export function clearComposeSuggestions() { | |||
return { | |||
type: COMPOSE_SUGGESTIONS_CLEAR, | |||
}; | |||
}; | |||
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { | |||
api(getState).get('/api/v1/accounts/search', { | |||
params: { | |||
q: token.slice(1), | |||
resolve: false, | |||
limit: 4, | |||
}, | |||
}).then(response => { | |||
dispatch(readyComposeSuggestionsAccounts(token, response.data)); | |||
}); | |||
}, 200, { leading: true, trailing: true }); | |||
const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { | |||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); | |||
dispatch(readyComposeSuggestionsEmojis(token, results)); | |||
}; | |||
export function fetchComposeSuggestions(token) { | |||
return (dispatch, getState) => { | |||
if (token[0] === ':') { | |||
fetchComposeSuggestionsEmojis(dispatch, getState, token); | |||
} else { | |||
fetchComposeSuggestionsAccounts(dispatch, getState, token); | |||
} | |||
}; | |||
}; | |||
export function readyComposeSuggestionsEmojis(token, emojis) { | |||
return { | |||
type: COMPOSE_SUGGESTIONS_READY, | |||
token, | |||
emojis, | |||
}; | |||
}; | |||
export function readyComposeSuggestionsAccounts(token, accounts) { | |||
return { | |||
type: COMPOSE_SUGGESTIONS_READY, | |||
token, | |||
accounts, | |||
}; | |||
}; | |||
export function selectComposeSuggestion(position, token, suggestion) { | |||
return (dispatch, getState) => { | |||
let completion, startPosition; | |||
if (typeof suggestion === 'object' && suggestion.id) { | |||
completion = suggestion.native || suggestion.colons; | |||
startPosition = position - 1; | |||
dispatch(useEmoji(suggestion)); | |||
} else { | |||
completion = getState().getIn(['accounts', suggestion, 'acct']); | |||
startPosition = position; | |||
} | |||
dispatch({ | |||
type: COMPOSE_SUGGESTION_SELECT, | |||
position: startPosition, | |||
token, | |||
completion, | |||
}); | |||
}; | |||
}; | |||
export function mountCompose() { | |||
return { | |||
type: COMPOSE_MOUNT, | |||
}; | |||
}; | |||
export function unmountCompose() { | |||
return { | |||
type: COMPOSE_UNMOUNT, | |||
}; | |||
}; | |||
export function changeComposeSensitivity() { | |||
return { | |||
type: COMPOSE_SENSITIVITY_CHANGE, | |||
}; | |||
}; | |||
export function changeComposeSpoilerness() { | |||
return { | |||
type: COMPOSE_SPOILERNESS_CHANGE, | |||
}; | |||
}; | |||
export function changeComposeSpoilerText(text) { | |||
return { | |||
type: COMPOSE_SPOILER_TEXT_CHANGE, | |||
text, | |||
}; | |||
}; | |||
export function changeComposeVisibility(value) { | |||
return { | |||
type: COMPOSE_VISIBILITY_CHANGE, | |||
value, | |||
}; | |||
}; | |||
export function insertEmojiCompose(position, emoji) { | |||
return { | |||
type: COMPOSE_EMOJI_INSERT, | |||
position, | |||
emoji, | |||
}; | |||
}; | |||
export function changeComposing(value) { | |||
return { | |||
type: COMPOSE_COMPOSING_CHANGE, | |||
value, | |||
}; | |||
} |
@ -0,0 +1,117 @@ | |||
import api, { getLinks } from '../api'; | |||
export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; | |||
export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS'; | |||
export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; | |||
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; | |||
export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS'; | |||
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; | |||
export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; | |||
export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS'; | |||
export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL'; | |||
export function blockDomain(domain, accountId) { | |||
return (dispatch, getState) => { | |||
dispatch(blockDomainRequest(domain)); | |||
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { | |||
dispatch(blockDomainSuccess(domain, accountId)); | |||
}).catch(err => { | |||
dispatch(blockDomainFail(domain, err)); | |||
}); | |||
}; | |||
}; | |||
export function blockDomainRequest(domain) { | |||
return { | |||
type: DOMAIN_BLOCK_REQUEST, | |||
domain, | |||
}; | |||
}; | |||
export function blockDomainSuccess(domain, accountId) { | |||
return { | |||
type: DOMAIN_BLOCK_SUCCESS, | |||
domain, | |||
accountId, | |||
}; | |||
}; | |||
export function blockDomainFail(domain, error) { | |||
return { | |||
type: DOMAIN_BLOCK_FAIL, | |||
domain, | |||
error, | |||
}; | |||
}; | |||
export function unblockDomain(domain, accountId) { | |||
return (dispatch, getState) => { | |||
dispatch(unblockDomainRequest(domain)); | |||
api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { | |||
dispatch(unblockDomainSuccess(domain, accountId)); | |||
}).catch(err => { | |||
dispatch(unblockDomainFail(domain, err)); | |||
}); | |||
}; | |||
}; | |||
export function unblockDomainRequest(domain) { | |||
return { | |||
type: DOMAIN_UNBLOCK_REQUEST, | |||
domain, | |||
}; | |||
}; | |||
export function unblockDomainSuccess(domain, accountId) { | |||
return { | |||
type: DOMAIN_UNBLOCK_SUCCESS, | |||
domain, | |||
accountId, | |||
}; | |||
}; | |||
export function unblockDomainFail(domain, error) { | |||
return { | |||
type: DOMAIN_UNBLOCK_FAIL, | |||
domain, | |||
error, | |||
}; | |||
}; | |||
export function fetchDomainBlocks() { | |||
return (dispatch, getState) => { | |||
dispatch(fetchDomainBlocksRequest()); | |||
api(getState).get().then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null)); | |||
}).catch(err => { | |||
dispatch(fetchDomainBlocksFail(err)); | |||
}); | |||
}; | |||
}; | |||
export function fetchDomainBlocksRequest() { | |||
return { | |||
type: DOMAIN_BLOCKS_FETCH_REQUEST, | |||
}; | |||
}; | |||
export function fetchDomainBlocksSuccess(domains, next) { | |||
return { | |||
type: DOMAIN_BLOCKS_FETCH_SUCCESS, | |||
domains, | |||
next, | |||
}; | |||
}; | |||
export function fetchDomainBlocksFail(error) { | |||
return { | |||
type: DOMAIN_BLOCKS_FETCH_FAIL, | |||
error, | |||
}; | |||
}; |
@ -0,0 +1,14 @@ | |||
import { saveSettings } from './settings'; | |||
export const EMOJI_USE = 'EMOJI_USE'; | |||
export function useEmoji(emoji) { | |||
return dispatch => { | |||
dispatch({ | |||
type: EMOJI_USE, | |||
emoji, | |||
}); | |||
dispatch(saveSettings()); | |||
}; | |||
}; |
@ -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, | |||
}; | |||
}; |
@ -0,0 +1,17 @@ | |||
export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET'; | |||
export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR'; | |||
export function setHeight (key, id, height) { | |||
return { | |||
type: HEIGHT_CACHE_SET, | |||
key, | |||
id, | |||
height, | |||
}; | |||
}; | |||
export function clearHeight () { | |||
return { | |||
type: HEIGHT_CACHE_CLEAR, | |||
}; | |||
}; |
@ -0,0 +1,313 @@ | |||
import api from '../api'; | |||
export const REBLOG_REQUEST = 'REBLOG_REQUEST'; | |||
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; | |||
export const REBLOG_FAIL = 'REBLOG_FAIL'; | |||
export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; | |||
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; | |||
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; | |||
export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; | |||
export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; | |||
export const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; | |||
export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; | |||
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; | |||
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; | |||
export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; | |||
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; | |||
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; | |||
export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; | |||
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; | |||
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; | |||
export const PIN_REQUEST = 'PIN_REQUEST'; | |||
export const PIN_SUCCESS = 'PIN_SUCCESS'; | |||
export const PIN_FAIL = 'PIN_FAIL'; | |||
export const UNPIN_REQUEST = 'UNPIN_REQUEST'; | |||
export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; | |||
export const UNPIN_FAIL = 'UNPIN_FAIL'; | |||
export function reblog(status) { | |||
return function (dispatch, getState) { | |||
dispatch(reblogRequest(status)); | |||
api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { | |||
// The reblog API method returns a new status wrapped around the original. In this case we are only | |||
// interested in how the original is modified, hence passing it skipping the wrapper | |||
dispatch(reblogSuccess(status, response.data.reblog)); | |||
}).catch(function (error) { | |||
dispatch(reblogFail(status, error)); | |||
}); | |||
}; | |||
}; | |||
export function unreblog(status) { | |||
return (dispatch, getState) => { | |||
dispatch(unreblogRequest(status)); | |||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { | |||
dispatch(unreblogSuccess(status, response.data)); | |||
}).catch(error => { | |||
dispatch(unreblogFail(status, error)); | |||
}); | |||
}; | |||
}; | |||
export function reblogRequest(status) { | |||
return { | |||
type: REBLOG_REQUEST, | |||
status: status, | |||
}; | |||
}; | |||
export function reblogSuccess(status, response) { | |||
return { | |||
type: REBLOG_SUCCESS, | |||
status: status, | |||
response: response, | |||
}; | |||
}; | |||
export function reblogFail(status, error) { | |||
return { | |||
type: REBLOG_FAIL, | |||
status: status, | |||
error: error, | |||
}; | |||
}; | |||
export function unreblogRequest(status) { | |||
return { | |||
type: UNREBLOG_REQUEST, | |||
status: status, | |||
}; | |||
}; | |||
export function unreblogSuccess(status, response) { | |||
return { | |||
type: UNREBLOG_SUCCESS, | |||
status: status, | |||
response: response, | |||
}; | |||
}; | |||
export function unreblogFail(status, error) { | |||
return { | |||
type: UNREBLOG_FAIL, | |||
status: status, | |||
error: error, | |||
}; | |||
}; | |||
export function favourite(status) { | |||
return function (dispatch, getState) { | |||
dispatch(favouriteRequest(status)); | |||
api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { | |||
dispatch(favouriteSuccess(status, response.data)); | |||
}).catch(function (error) { | |||
dispatch(favouriteFail(status, error)); | |||
}); | |||
}; | |||
}; | |||
export function unfavourite(status) { | |||
return (dispatch, getState) => { | |||
dispatch(unfavouriteRequest(status)); | |||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { | |||
dispatch(unfavouriteSuccess(status, response.data)); | |||
}).catch(error => { | |||
dispatch(unfavouriteFail(status, error)); | |||
}); | |||
}; | |||
}; | |||
export function favouriteRequest(status) { | |||
return { | |||
type: FAVOURITE_REQUEST, | |||
status: status, | |||
}; | |||
}; | |||
export function favouriteSuccess(status, response) { | |||
return { | |||
type: FAVOURITE_SUCCESS, | |||
status: status, | |||
response: response, | |||
}; | |||
}; | |||
export function favouriteFail(status, error) { | |||
return { | |||
type: FAVOURITE_FAIL, | |||
status: status, | |||
error: error, | |||
}; | |||
}; | |||
export function unfavouriteRequest(status) { | |||
return { | |||
type: UNFAVOURITE_REQUEST, | |||
status: status, | |||
}; | |||
}; | |||
export function unfavouriteSuccess(status, response) { | |||
return { | |||
type: UNFAVOURITE_SUCCESS, | |||
status: status, | |||
response: response, | |||
}; | |||
}; | |||
export function unfavouriteFail(status, error) { | |||
return { | |||
type: UNFAVOURITE_FAIL, | |||
status: status, | |||
error: error, | |||
}; | |||
}; | |||
export function fetchReblogs(id) { | |||
return (dispatch, getState) => { | |||
dispatch(fetchReblogsRequest(id)); | |||
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { | |||
dispatch(fetchReblogsSuccess(id, response.data)); | |||
}).catch(error => { | |||
dispatch(fetchReblogsFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchReblogsRequest(id) { | |||
return { | |||
type: REBLOGS_FETCH_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function fetchReblogsSuccess(id, accounts) { | |||
return { | |||
type: REBLOGS_FETCH_SUCCESS, | |||
id, | |||
accounts, | |||
}; | |||
}; | |||
export function fetchReblogsFail(id, error) { | |||
return { | |||
type: REBLOGS_FETCH_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function fetchFavourites(id) { | |||
return (dispatch, getState) => { | |||
dispatch(fetchFavouritesRequest(id)); | |||
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { | |||
dispatch(fetchFavouritesSuccess(id, response.data)); | |||
}).catch(error => { | |||
dispatch(fetchFavouritesFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchFavouritesRequest(id) { | |||
return { | |||
type: FAVOURITES_FETCH_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function fetchFavouritesSuccess(id, accounts) { | |||
return { | |||
type: FAVOURITES_FETCH_SUCCESS, | |||
id, | |||
accounts, | |||
}; | |||
}; | |||
export function fetchFavouritesFail(id, error) { | |||
return { | |||
type: FAVOURITES_FETCH_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function pin(status) { | |||
return (dispatch, getState) => { | |||
dispatch(pinRequest(status)); | |||
api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { | |||
dispatch(pinSuccess(status, response.data)); | |||
}).catch(error => { | |||
dispatch(pinFail(status, error)); | |||
}); | |||
}; | |||
}; | |||
export function pinRequest(status) { | |||
return { | |||
type: PIN_REQUEST, | |||
status, | |||
}; | |||
}; | |||
export function pinSuccess(status, response) { | |||
return { | |||
type: PIN_SUCCESS, | |||
status, | |||
response, | |||
}; | |||
}; | |||
export function pinFail(status, error) { | |||
return { | |||
type: PIN_FAIL, | |||
status, | |||
error, | |||
}; | |||
}; | |||
export function unpin (status) { | |||
return (dispatch, getState) => { | |||
dispatch(unpinRequest(status)); | |||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { | |||
dispatch(unpinSuccess(status, response.data)); | |||
}).catch(error => { | |||
dispatch(unpinFail(status, error)); | |||
}); | |||
}; | |||
}; | |||
export function unpinRequest(status) { | |||
return { | |||
type: UNPIN_REQUEST, | |||
status, | |||
}; | |||
}; | |||
export function unpinSuccess(status, response) { | |||
return { | |||
type: UNPIN_SUCCESS, | |||
status, | |||
response, | |||
}; | |||
}; | |||
export function unpinFail(status, error) { | |||
return { | |||
type: UNPIN_FAIL, | |||
status, | |||
error, | |||
}; | |||
}; |
@ -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, | |||
}; | |||
}; |
@ -0,0 +1,103 @@ | |||
import api, { getLinks } from '../api'; | |||
import { fetchRelationships } from './accounts'; | |||
import { openModal } from '../../mastodon/actions/modal'; | |||
export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; | |||
export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; | |||
export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL'; | |||
export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; | |||
export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; | |||
export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; | |||
export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; | |||
export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; | |||
export function fetchMutes() { | |||
return (dispatch, getState) => { | |||
dispatch(fetchMutesRequest()); | |||
api(getState).get('/api/v1/mutes').then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); | |||
dispatch(fetchRelationships(response.data.map(item => item.id))); | |||
}).catch(error => dispatch(fetchMutesFail(error))); | |||
}; | |||
}; | |||
export function fetchMutesRequest() { | |||
return { | |||
type: MUTES_FETCH_REQUEST, | |||
}; | |||
}; | |||
export function fetchMutesSuccess(accounts, next) { | |||
return { | |||
type: MUTES_FETCH_SUCCESS, | |||
accounts, | |||
next, | |||
}; | |||
}; | |||
export function fetchMutesFail(error) { | |||
return { | |||
type: MUTES_FETCH_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function expandMutes() { | |||
return (dispatch, getState) => { | |||
const url = getState().getIn(['user_lists', 'mutes', 'next']); | |||
if (url === null) { | |||
return; | |||
} | |||
dispatch(expandMutesRequest()); | |||
api(getState).get(url).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); | |||
dispatch(fetchRelationships(response.data.map(item => item.id))); | |||
}).catch(error => dispatch(expandMutesFail(error))); | |||
}; | |||
}; | |||
export function expandMutesRequest() { | |||
return { | |||
type: MUTES_EXPAND_REQUEST, | |||
}; | |||
}; | |||
export function expandMutesSuccess(accounts, next) { | |||
return { | |||
type: MUTES_EXPAND_SUCCESS, | |||
accounts, | |||
next, | |||
}; | |||
}; | |||
export function expandMutesFail(error) { | |||
return { | |||
type: MUTES_EXPAND_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function initMuteModal(account) { | |||
return dispatch => { | |||
dispatch({ | |||
type: MUTES_INIT_MODAL, | |||
account, | |||
}); | |||
dispatch(openModal('MUTE')); | |||
}; | |||
} | |||
export function toggleHideNotifications() { | |||
return dispatch => { | |||
dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); | |||
}; | |||
} |
@ -0,0 +1,190 @@ | |||
import api, { getLinks } from '../api'; | |||
import { List as ImmutableList } from 'immutable'; | |||
import IntlMessageFormat from 'intl-messageformat'; | |||
import { fetchRelationships } from './accounts'; | |||
import { defineMessages } from 'react-intl'; | |||
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; | |||
export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST'; | |||
export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS'; | |||
export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL'; | |||
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; | |||
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; | |||
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; | |||
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; | |||
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; | |||
defineMessages({ | |||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, | |||
}); | |||
const fetchRelatedRelationships = (dispatch, notifications) => { | |||
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); | |||
if (accountIds > 0) { | |||
dispatch(fetchRelationships(accountIds)); | |||
} | |||
}; | |||
const unescapeHTML = (html) => { | |||
const wrapper = document.createElement('div'); | |||
html = html.replace(/<br \/>|<br>|\n/, ' '); | |||
wrapper.innerHTML = html; | |||
return wrapper.textContent; | |||
}; | |||
export function updateNotifications(notification, intlMessages, intlLocale) { | |||
return (dispatch, getState) => { | |||
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); | |||
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); | |||
dispatch({ | |||
type: NOTIFICATIONS_UPDATE, | |||
notification, | |||
account: notification.account, | |||
status: notification.status, | |||
meta: playSound ? { sound: 'boop' } : undefined, | |||
}); | |||
fetchRelatedRelationships(dispatch, [notification]); | |||
// Desktop notifications | |||
if (typeof window.Notification !== 'undefined' && showAlert) { | |||
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); | |||
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); | |||
const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); | |||
notify.addEventListener('click', () => { | |||
window.focus(); | |||
notify.close(); | |||
}); | |||
} | |||
}; | |||
}; | |||
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); | |||
export function refreshNotifications() { | |||
return (dispatch, getState) => { | |||
const params = {}; | |||
const ids = getState().getIn(['notifications', 'items']); | |||
let skipLoading = false; | |||
if (ids.size > 0) { | |||
params.since_id = ids.first().get('id'); | |||
} | |||
if (getState().getIn(['notifications', 'loaded'])) { | |||
skipLoading = true; | |||
} | |||
params.exclude_types = excludeTypesFromSettings(getState()); | |||
dispatch(refreshNotificationsRequest(skipLoading)); | |||
api(getState).get('/api/v1/notifications', { params }).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null)); | |||
fetchRelatedRelationships(dispatch, response.data); | |||
}).catch(error => { | |||
dispatch(refreshNotificationsFail(error, skipLoading)); | |||
}); | |||
}; | |||
}; | |||
export function refreshNotificationsRequest(skipLoading) { | |||
return { | |||
type: NOTIFICATIONS_REFRESH_REQUEST, | |||
skipLoading, | |||
}; | |||
}; | |||
export function refreshNotificationsSuccess(notifications, skipLoading, next) { | |||
return { | |||
type: NOTIFICATIONS_REFRESH_SUCCESS, | |||
notifications, | |||
accounts: notifications.map(item => item.account), | |||
statuses: notifications.map(item => item.status).filter(status => !!status), | |||
skipLoading, | |||
next, | |||
}; | |||
}; | |||
export function refreshNotificationsFail(error, skipLoading) { | |||
return { | |||
type: NOTIFICATIONS_REFRESH_FAIL, | |||
error, | |||
skipLoading, | |||
}; | |||
}; | |||
export function expandNotifications() { | |||
return (dispatch, getState) => { | |||
const items = getState().getIn(['notifications', 'items'], ImmutableList()); | |||
if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) { | |||
return; | |||
} | |||
const params = { | |||
max_id: items.last().get('id'), | |||
limit: 20, | |||
exclude_types: excludeTypesFromSettings(getState()), | |||
}; | |||
dispatch(expandNotificationsRequest()); | |||
api(getState).get('/api/v1/notifications', { params }).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); | |||
fetchRelatedRelationships(dispatch, response.data); | |||
}).catch(error => { | |||
dispatch(expandNotificationsFail(error)); | |||
}); | |||
}; | |||
}; | |||
export function expandNotificationsRequest() { | |||
return { | |||
type: NOTIFICATIONS_EXPAND_REQUEST, | |||
}; | |||
}; | |||
export function expandNotificationsSuccess(notifications, next) { | |||
return { | |||
type: NOTIFICATIONS_EXPAND_SUCCESS, | |||
notifications, | |||
accounts: notifications.map(item => item.account), | |||
statuses: notifications.map(item => item.status).filter(status => !!status), | |||
next, | |||
}; | |||
}; | |||
export function expandNotificationsFail(error) { | |||
return { | |||
type: NOTIFICATIONS_EXPAND_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function clearNotifications() { | |||
return (dispatch, getState) => { | |||
dispatch({ | |||
type: NOTIFICATIONS_CLEAR, | |||
}); | |||
api(getState).post('/api/v1/notifications/clear'); | |||
}; | |||
}; | |||
export function scrollTopNotifications(top) { | |||
return { | |||
type: NOTIFICATIONS_SCROLL_TOP, | |||
top, | |||
}; | |||
}; |
@ -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()); | |||
} | |||
}; | |||
}; |
@ -0,0 +1,40 @@ | |||
import api from '../api'; | |||
export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; | |||
export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; | |||
export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; | |||
import { me } from '../initial_state'; | |||
export function fetchPinnedStatuses() { | |||
return (dispatch, getState) => { | |||
dispatch(fetchPinnedStatusesRequest()); | |||
api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { | |||
dispatch(fetchPinnedStatusesSuccess(response.data, null)); | |||
}).catch(error => { | |||
dispatch(fetchPinnedStatusesFail(error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchPinnedStatusesRequest() { | |||
return { | |||
type: PINNED_STATUSES_FETCH_REQUEST, | |||
}; | |||
}; | |||
export function fetchPinnedStatusesSuccess(statuses, next) { | |||
return { | |||
type: PINNED_STATUSES_FETCH_SUCCESS, | |||
statuses, | |||
next, | |||
}; | |||
}; | |||
export function fetchPinnedStatusesFail(error) { | |||
return { | |||
type: PINNED_STATUSES_FETCH_FAIL, | |||
error, | |||
}; | |||
}; |
@ -0,0 +1,52 @@ | |||
import axios from 'axios'; | |||
export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; | |||
export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; | |||
export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; | |||
export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE'; | |||
export function setBrowserSupport (value) { | |||
return { | |||
type: SET_BROWSER_SUPPORT, | |||
value, | |||
}; | |||
} | |||
export function setSubscription (subscription) { | |||
return { | |||
type: SET_SUBSCRIPTION, | |||
subscription, | |||
}; | |||
} | |||
export function clearSubscription () { | |||
return { | |||
type: CLEAR_SUBSCRIPTION, | |||
}; | |||
} | |||
export function changeAlerts(key, value) { | |||
return dispatch => { | |||
dispatch({ | |||
type: ALERTS_CHANGE, | |||
key, | |||
value, | |||
}); | |||
dispatch(saveSettings()); | |||
}; | |||
} | |||
export function saveSettings() { | |||
return (_, getState) => { | |||
const state = getState().get('push_notifications'); | |||
const subscription = state.get('subscription'); | |||
const alerts = state.get('alerts'); | |||
axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, { | |||
data: { | |||
alerts, | |||
}, | |||
}); | |||
}; | |||
} |
@ -0,0 +1,80 @@ | |||
import api from '../api'; | |||
import { openModal, closeModal } from './modal'; | |||
export const REPORT_INIT = 'REPORT_INIT'; | |||
export const REPORT_CANCEL = 'REPORT_CANCEL'; | |||
export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; | |||
export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; | |||
export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; | |||
export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; | |||
export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; | |||
export function initReport(account, status) { | |||
return dispatch => { | |||
dispatch({ | |||
type: REPORT_INIT, | |||
account, | |||
status, | |||
}); | |||
dispatch(openModal('REPORT')); | |||
}; | |||
}; | |||
export function cancelReport() { | |||
return { | |||
type: REPORT_CANCEL, | |||
}; | |||
}; | |||
export function toggleStatusReport(statusId, checked) { | |||
return { | |||
type: REPORT_STATUS_TOGGLE, | |||
statusId, | |||
checked, | |||
}; | |||
}; | |||
export function submitReport() { | |||
return (dispatch, getState) => { | |||
dispatch(submitReportRequest()); | |||
api(getState).post('/api/v1/reports', { | |||
account_id: getState().getIn(['reports', 'new', 'account_id']), | |||
status_ids: getState().getIn(['reports', 'new', 'status_ids']), | |||
comment: getState().getIn(['reports', 'new', 'comment']), | |||
}).then(response => { | |||
dispatch(closeModal()); | |||
dispatch(submitReportSuccess(response.data)); | |||
}).catch(error => dispatch(submitReportFail(error))); | |||
}; | |||
}; | |||
export function submitReportRequest() { | |||
return { | |||
type: REPORT_SUBMIT_REQUEST, | |||
}; | |||
}; | |||
export function submitReportSuccess(report) { | |||
return { | |||
type: REPORT_SUBMIT_SUCCESS, | |||
report, | |||
}; | |||
}; | |||
export function submitReportFail(error) { | |||
return { | |||
type: REPORT_SUBMIT_FAIL, | |||
error, | |||
}; | |||
}; | |||
export function changeReportComment(comment) { | |||
return { | |||
type: REPORT_COMMENT_CHANGE, | |||
comment, | |||
}; | |||
}; |
@ -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, | |||
}; | |||
}; |
@ -0,0 +1,31 @@ | |||
import axios from 'axios'; | |||
import { debounce } from 'lodash'; | |||
export const SETTING_CHANGE = 'SETTING_CHANGE'; | |||
export const SETTING_SAVE = 'SETTING_SAVE'; | |||
export function changeSetting(key, value) { | |||
return dispatch => { | |||
dispatch({ | |||
type: SETTING_CHANGE, | |||
key, | |||
value, | |||
}); | |||
dispatch(saveSettings()); | |||
}; | |||
}; | |||
const debouncedSave = debounce((dispatch, getState) => { | |||
if (getState().getIn(['settings', 'saved'])) { | |||
return; | |||
} | |||
const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS(); | |||
axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE })); | |||
}, 5000, { trailing: true }); | |||
export function saveSettings() { | |||
return (dispatch, getState) => debouncedSave(dispatch, getState); | |||
}; |
@ -0,0 +1,217 @@ | |||
import api from '../api'; | |||
import { deleteFromTimelines } from './timelines'; | |||
import { fetchStatusCard } from './cards'; | |||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; | |||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; | |||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; | |||
export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; | |||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; | |||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; | |||
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; | |||
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; | |||
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; | |||
export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST'; | |||
export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS'; | |||
export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL'; | |||
export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; | |||
export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; | |||
export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; | |||
export function fetchStatusRequest(id, skipLoading) { | |||
return { | |||
type: STATUS_FETCH_REQUEST, | |||
id, | |||
skipLoading, | |||
}; | |||
}; | |||
export function fetchStatus(id) { | |||
return (dispatch, getState) => { | |||
const skipLoading = getState().getIn(['statuses', id], null) !== null; | |||
dispatch(fetchContext(id)); | |||
dispatch(fetchStatusCard(id)); | |||
if (skipLoading) { | |||
return; | |||
} | |||
dispatch(fetchStatusRequest(id, skipLoading)); | |||
api(getState).get(`/api/v1/statuses/${id}`).then(response => { | |||
dispatch(fetchStatusSuccess(response.data, skipLoading)); | |||
}).catch(error => { | |||
dispatch(fetchStatusFail(id, error, skipLoading)); | |||
}); | |||
}; | |||
}; | |||
export function fetchStatusSuccess(status, skipLoading) { | |||
return { | |||
type: STATUS_FETCH_SUCCESS, | |||
status, | |||
skipLoading, | |||
}; | |||
}; | |||
export function fetchStatusFail(id, error, skipLoading) { | |||
return { | |||
type: STATUS_FETCH_FAIL, | |||
id, | |||
error, | |||
skipLoading, | |||
skipAlert: true, | |||
}; | |||
}; | |||
export function deleteStatus(id) { | |||
return (dispatch, getState) => { | |||
dispatch(deleteStatusRequest(id)); | |||
api(getState).delete(`/api/v1/statuses/${id}`).then(() => { | |||
dispatch(deleteStatusSuccess(id)); | |||
dispatch(deleteFromTimelines(id)); | |||
}).catch(error => { | |||
dispatch(deleteStatusFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function deleteStatusRequest(id) { | |||
return { | |||
type: STATUS_DELETE_REQUEST, | |||
id: id, | |||
}; | |||
}; | |||
export function deleteStatusSuccess(id) { | |||
return { | |||
type: STATUS_DELETE_SUCCESS, | |||
id: id, | |||
}; | |||
}; | |||
export function deleteStatusFail(id, error) { | |||
return { | |||
type: STATUS_DELETE_FAIL, | |||
id: id, | |||
error: error, | |||
}; | |||
}; | |||
export function fetchContext(id) { | |||
return (dispatch, getState) => { | |||
dispatch(fetchContextRequest(id)); | |||
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { | |||
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); | |||
}).catch(error => { | |||
if (error.response && error.response.status === 404) { | |||
dispatch(deleteFromTimelines(id)); | |||
} | |||
dispatch(fetchContextFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function fetchContextRequest(id) { | |||
return { | |||
type: CONTEXT_FETCH_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function fetchContextSuccess(id, ancestors, descendants) { | |||
return { | |||
type: CONTEXT_FETCH_SUCCESS, | |||
id, | |||
ancestors, | |||
descendants, | |||
statuses: ancestors.concat(descendants), | |||
}; | |||
}; | |||
export function fetchContextFail(id, error) { | |||
return { | |||
type: CONTEXT_FETCH_FAIL, | |||
id, | |||
error, | |||
skipAlert: true, | |||
}; | |||
}; | |||
export function muteStatus(id) { | |||
return (dispatch, getState) => { | |||
dispatch(muteStatusRequest(id)); | |||
api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { | |||
dispatch(muteStatusSuccess(id)); | |||
}).catch(error => { | |||
dispatch(muteStatusFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function muteStatusRequest(id) { | |||
return { | |||
type: STATUS_MUTE_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function muteStatusSuccess(id) { | |||
return { | |||
type: STATUS_MUTE_SUCCESS, | |||
id, | |||
}; | |||
}; | |||
export function muteStatusFail(id, error) { | |||
return { | |||
type: STATUS_MUTE_FAIL, | |||
id, | |||
error, | |||
}; | |||
}; | |||
export function unmuteStatus(id) { | |||
return (dispatch, getState) => { | |||
dispatch(unmuteStatusRequest(id)); | |||
api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => { | |||
dispatch(unmuteStatusSuccess(id)); | |||
}).catch(error => { | |||
dispatch(unmuteStatusFail(id, error)); | |||
}); | |||
}; | |||
}; | |||
export function unmuteStatusRequest(id) { | |||
return { | |||
type: STATUS_UNMUTE_REQUEST, | |||
id, | |||
}; | |||
}; | |||
export function unmuteStatusSuccess(id) { | |||
return { | |||
type: STATUS_UNMUTE_SUCCESS, | |||
id, | |||
}; | |||
}; | |||
export function unmuteStatusFail(id, error) { | |||
return { | |||
type: STATUS_UNMUTE_FAIL, | |||
id, | |||
error, | |||
}; | |||
}; |
@ -0,0 +1,17 @@ | |||
import { Iterable, fromJS } from 'immutable'; | |||
export const STORE_HYDRATE = 'STORE_HYDRATE'; | |||
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; | |||
const convertState = rawState => | |||
fromJS(rawState, (k, v) => | |||
Iterable.isIndexed(v) ? v.toList() : v.toMap()); | |||
export function hydrateStore(rawState) { | |||
const state = convertState(rawState); | |||
return { | |||
type: STORE_HYDRATE, | |||
state, | |||
}; | |||
}; |
@ -0,0 +1,53 @@ | |||
import { connectStream } from '../stream'; | |||
import { | |||
updateTimeline, | |||
deleteFromTimelines, | |||
refreshHomeTimeline, | |||
connectTimeline, | |||
disconnectTimeline, | |||
} from './timelines'; | |||
import { updateNotifications, refreshNotifications } from './notifications'; | |||
import { getLocale } from '../locales'; | |||
const { messages } = getLocale(); | |||
export function connectTimelineStream (timelineId, path, pollingRefresh = null) { | |||
return connectStream (path, pollingRefresh, (dispatch, getState) => { | |||
const locale = getState().getIn(['meta', 'locale']); | |||
return { | |||
onConnect() { | |||
dispatch(connectTimeline(timelineId)); | |||
}, | |||
onDisconnect() { | |||
dispatch(disconnectTimeline(timelineId)); | |||
}, | |||
onReceive (data) { | |||
switch(data.event) { | |||
case 'update': | |||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload))); | |||
break; | |||
case 'delete': | |||
dispatch(deleteFromTimelines(data.payload)); | |||
break; | |||
case 'notification': | |||
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); | |||
break; | |||
} | |||
}, | |||
}; | |||
}); | |||
} | |||
function refreshHomeTimelineAndNotification (dispatch) { | |||
dispatch(refreshHomeTimeline()); | |||
dispatch(refreshNotifications()); | |||
} | |||
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); | |||
export const connectCommunityStream = () => connectTimelineStream('community', 'public:local'); | |||
export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); | |||
export const connectPublicStream = () => connectTimelineStream('public', 'public'); | |||
export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); |
@ -0,0 +1,206 @@ | |||
import api, { getLinks } from '../api'; | |||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | |||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; | |||
export const TIMELINE_DELETE = 'TIMELINE_DELETE'; | |||
export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST'; | |||
export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS'; | |||
export const TIMELINE_REFRESH_FAIL = 'TIMELINE_REFRESH_FAIL'; | |||
export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; | |||
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; | |||
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; | |||
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; | |||
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; | |||
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; | |||
export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; | |||
export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { | |||
return { | |||
type: TIMELINE_REFRESH_SUCCESS, | |||
timeline, | |||
statuses, | |||
skipLoading, | |||
next, | |||
}; | |||
}; | |||
export function updateTimeline(timeline, status) { | |||
return (dispatch, getState) => { | |||
const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; | |||
const parents = []; | |||
if (status.in_reply_to_id) { | |||
let parent = getState().getIn(['statuses', status.in_reply_to_id]); | |||
while (parent && parent.get('in_reply_to_id')) { | |||
parents.push(parent.get('id')); | |||
parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]); | |||
} | |||
} | |||
dispatch({ | |||
type: TIMELINE_UPDATE, | |||
timeline, | |||
status, | |||
references, | |||
}); | |||
if (parents.length > 0) { | |||
dispatch({ | |||
type: TIMELINE_CONTEXT_UPDATE, | |||
status, | |||
references: parents, | |||
}); | |||
} | |||
}; | |||
}; | |||
export function deleteFromTimelines(id) { | |||
return (dispatch, getState) => { | |||
const accountId = getState().getIn(['statuses', id, 'account']); | |||
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); | |||
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); | |||
dispatch({ | |||
type: TIMELINE_DELETE, | |||
id, | |||
accountId, | |||
references, | |||
reblogOf, | |||
}); | |||
}; | |||
}; | |||
export function refreshTimelineRequest(timeline, skipLoading) { | |||
return { | |||
type: TIMELINE_REFRESH_REQUEST, | |||
timeline, | |||
skipLoading, | |||
}; | |||
}; | |||
export function refreshTimeline(timelineId, path, params = {}) { | |||
return function (dispatch, getState) { | |||
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); | |||
if (timeline.get('isLoading') || timeline.get('online')) { | |||
return; | |||
} | |||
const ids = timeline.get('items', ImmutableList()); | |||
const newestId = ids.size > 0 ? ids.first() : null; | |||
let skipLoading = timeline.get('loaded'); | |||
if (newestId !== null) { | |||
params.since_id = newestId; | |||
} | |||
dispatch(refreshTimelineRequest(timelineId, skipLoading)); | |||
api(getState).get(path, { params }).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null)); | |||
}).catch(error => { | |||
dispatch(refreshTimelineFail(timelineId, error, skipLoading)); | |||
}); | |||
}; | |||
}; | |||
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); | |||
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); | |||
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); | |||
export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); | |||
export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); | |||
export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); | |||
export function refreshTimelineFail(timeline, error, skipLoading) { | |||
return { | |||
type: TIMELINE_REFRESH_FAIL, | |||
timeline, | |||
error, | |||
skipLoading, | |||
skipAlert: error.response && error.response.status === 404, | |||
}; | |||
}; | |||
export function expandTimeline(timelineId, path, params = {}) { | |||
return (dispatch, getState) => { | |||
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); | |||
const ids = timeline.get('items', ImmutableList()); | |||
if (timeline.get('isLoading') || ids.size === 0) { | |||
return; | |||
} | |||
params.max_id = ids.last(); | |||
params.limit = 10; | |||
dispatch(expandTimelineRequest(timelineId)); | |||
api(getState).get(path, { params }).then(response => { | |||
const next = getLinks(response).refs.find(link => link.rel === 'next'); | |||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null)); | |||
}).catch(error => { | |||
dispatch(expandTimelineFail(timelineId, error)); | |||
}); | |||
}; | |||
}; | |||
export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home'); | |||
export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public'); | |||
export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); | |||
export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); | |||
export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); | |||
export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); | |||
export function expandTimelineRequest(timeline) { | |||
return { | |||
type: TIMELINE_EXPAND_REQUEST, | |||
timeline, | |||
}; | |||
}; | |||
export function expandTimelineSuccess(timeline, statuses, next) { | |||
return { | |||
type: TIMELINE_EXPAND_SUCCESS, | |||
timeline, | |||
statuses, | |||
next, | |||
}; | |||
}; | |||
export function expandTimelineFail(timeline, error) { | |||
return { | |||
type: TIMELINE_EXPAND_FAIL, | |||
timeline, | |||
error, | |||
}; | |||
}; | |||
export function scrollTopTimeline(timeline, top) { | |||
return { | |||
type: TIMELINE_SCROLL_TOP, | |||
timeline, | |||
top, | |||
}; | |||
}; | |||
export function connectTimeline(timeline) { | |||
return { | |||
type: TIMELINE_CONNECT, | |||
timeline, | |||
}; | |||
}; | |||
export function disconnectTimeline(timeline) { | |||
return { | |||
type: TIMELINE_DISCONNECT, | |||
timeline, | |||
}; | |||
}; |
@ -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; | |||
} | |||
}], | |||
}); |
@ -0,0 +1,18 @@ | |||
import 'intl'; | |||
import 'intl/locale-data/jsonp/en'; | |||
import 'es6-symbol/implement'; | |||
import includes from 'array-includes'; | |||
import assign from 'object-assign'; | |||
import isNaN from 'is-nan'; | |||
if (!Array.prototype.includes) { | |||
includes.shim(); | |||
} | |||
if (!Object.assign) { | |||
Object.assign = assign; | |||
} | |||
if (!Number.isNaN) { | |||
Number.isNaN = isNaN; | |||
} |
@ -0,0 +1,33 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`<Avatar /> Autoplay renders a animated avatar 1`] = ` | |||
<div | |||
className="account__avatar" | |||
onMouseEnter={[Function]} | |||
onMouseLeave={[Function]} | |||
style={ | |||
Object { | |||
"backgroundImage": "url(/animated/alice.gif)", | |||
"backgroundSize": "100px 100px", | |||
"height": "100px", | |||
"width": "100px", | |||
} | |||
} | |||
/> | |||
`; | |||
exports[`<Avatar /> Still renders a still avatar 1`] = ` | |||
<div | |||
className="account__avatar" | |||
onMouseEnter={[Function]} | |||
onMouseLeave={[Function]} | |||
style={ | |||
Object { | |||
"backgroundImage": "url(/static/alice.jpg)", | |||
"backgroundSize": "100px 100px", | |||
"height": "100px", | |||
"width": "100px", | |||
} | |||
} | |||
/> | |||
`; |
@ -0,0 +1,24 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`<AvatarOverlay renders a overlay avatar 1`] = ` | |||
<div | |||
className="account__avatar-overlay" | |||
> | |||
<div | |||
className="account__avatar-overlay-base" | |||
style={ | |||
Object { | |||
"backgroundImage": "url(/static/alice.jpg)", | |||
} | |||
} | |||
/> | |||
<div | |||
className="account__avatar-overlay-overlay" | |||
style={ | |||
Object { | |||
"backgroundImage": "url(/static/eve.jpg)", | |||
} | |||
} | |||
/> | |||
</div> | |||
`; |
@ -0,0 +1,114 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = ` | |||
<button | |||
className="button button-secondary" | |||
disabled={undefined} | |||
onClick={[Function]} | |||
style={ | |||
Object { | |||
"height": "36px", | |||
"lineHeight": "36px", | |||
"padding": "0 16px", | |||
} | |||
} | |||
/> | |||
`; | |||
exports[`<Button /> renders a button element 1`] = ` | |||
<button | |||
className="button" | |||
disabled={undefined} | |||
onClick={[Function]} | |||
style={ | |||
Object { | |||
"height": "36px", | |||
"lineHeight": "36px", | |||
"padding": "0 16px", | |||
} | |||
} | |||
/> | |||
`; | |||
exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = ` | |||
<button | |||
className="button" | |||
disabled={true} | |||
onClick={[Function]} | |||
style={ | |||
Object { | |||
"height": "36px", | |||
"lineHeight": "36px", | |||
"padding": "0 16px", | |||
} | |||
} | |||
/> | |||
`; | |||
exports[`<Button /> renders class="button--block" if props.block given 1`] = ` | |||
<button | |||
className="button button--block" | |||
disabled={undefined} | |||
onClick={[Function]} | |||
style={ | |||
Object { | |||
"height": "36px", | |||
"lineHeight": "36px", | |||
"padding": "0 16px", | |||
} | |||
} | |||
/> | |||
`; | |||
exports[`<Button /> renders the children 1`] = ` | |||
<button | |||
className="button" | |||
disabled={undefined} | |||
onClick={[Function]} | |||
style={ | |||
Object { | |||
"height": "36px", | |||
"lineHeight": "36px", | |||
"padding": "0 16px", | |||
} | |||
} | |||
> | |||
<p> | |||
children | |||
</p> | |||
</button> | |||
`; | |||
exports[`<Button /> renders the given text 1`] = ` | |||
<button | |||
className="button" | |||
disabled={undefined} | |||
onClick={[Function]} | |||
style={ | |||
Object { | |||
"height": "36px", | |||
"lineHeight": "36px", | |||
"padding": "0 16px", | |||
} | |||
} | |||
> | |||
foo | |||
</button> | |||
`; | |||
exports[`<Button /> renders the props.text instead of children 1`] = ` | |||
<button | |||
className="button" | |||
disabled={undefined} | |||
onClick={[Function]} | |||
style={ | |||
Object { | |||
"height": "36px", | |||
"lineHeight": "36px", | |||
"padding": "0 16px", | |||
} | |||
} | |||
> | |||
foo | |||
</button> | |||
`; |
@ -0,0 +1,23 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`<DisplayName /> renders display name + account name 1`] = ` | |||
<span | |||
className="display-name" | |||
> | |||
<strong | |||
className="display-name__html" | |||
dangerouslySetInnerHTML={ | |||
Object { | |||
"__html": "<p>Foo</p>", | |||
} | |||
} | |||
/> | |||
<span | |||
className="display-name__account" | |||
> | |||
@ | |||
bar@baz | |||
</span> | |||
</span> | |||
`; |
@ -0,0 +1,36 @@ | |||
import React from 'react'; | |||
import renderer from 'react-test-renderer'; | |||
import { fromJS } from 'immutable'; | |||
import Avatar from '../avatar'; | |||
describe('<Avatar />', () => { | |||
const account = fromJS({ | |||
username: 'alice', | |||
acct: 'alice', | |||
display_name: 'Alice', | |||
avatar: '/animated/alice.gif', | |||
avatar_static: '/static/alice.jpg', | |||
}); | |||
const size = 100; | |||
describe('Autoplay', () => { | |||
it('renders a animated avatar', () => { | |||
const component = renderer.create(<Avatar account={account} animate size={size} />); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
}); | |||
describe('Still', () => { | |||
it('renders a still avatar', () => { | |||
const component = renderer.create(<Avatar account={account} size={size} />); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
}); | |||
// TODO add autoplay test if possible | |||
}); |
@ -0,0 +1,29 @@ | |||
import React from 'react'; | |||
import renderer from 'react-test-renderer'; | |||
import { fromJS } from 'immutable'; | |||
import AvatarOverlay from '../avatar_overlay'; | |||
describe('<AvatarOverlay', () => { | |||
const account = fromJS({ | |||
username: 'alice', | |||
acct: 'alice', | |||
display_name: 'Alice', | |||
avatar: '/animated/alice.gif', | |||
avatar_static: '/static/alice.jpg', | |||
}); | |||
const friend = fromJS({ | |||
username: 'eve', | |||
acct: 'eve@blackhat.lair', | |||
display_name: 'Evelyn', | |||
avatar: '/animated/eve.gif', | |||
avatar_static: '/static/eve.jpg', | |||
}); | |||
it('renders a overlay avatar', () => { | |||
const component = renderer.create(<AvatarOverlay account={account} friend={friend} />); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
}); |
@ -0,0 +1,75 @@ | |||
import { shallow } from 'enzyme'; | |||
import React from 'react'; | |||
import renderer from 'react-test-renderer'; | |||
import Button from '../button'; | |||
describe('<Button />', () => { | |||
it('renders a button element', () => { | |||
const component = renderer.create(<Button />); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
it('renders the given text', () => { | |||
const text = 'foo'; | |||
const component = renderer.create(<Button text={text} />); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
it('handles click events using the given handler', () => { | |||
const handler = jest.fn(); | |||
const button = shallow(<Button onClick={handler} />); | |||
button.find('button').simulate('click'); | |||
expect(handler.mock.calls.length).toEqual(1); | |||
}); | |||
it('does not handle click events if props.disabled given', () => { | |||
const handler = jest.fn(); | |||
const button = shallow(<Button onClick={handler} disabled />); | |||
button.find('button').simulate('click'); | |||
expect(handler.mock.calls.length).toEqual(0); | |||
}); | |||
it('renders a disabled attribute if props.disabled given', () => { | |||
const component = renderer.create(<Button disabled />); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
it('renders the children', () => { | |||
const children = <p>children</p>; | |||
const component = renderer.create(<Button>{children}</Button>); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
it('renders the props.text instead of children', () => { | |||
const text = 'foo'; | |||
const children = <p>children</p>; | |||
const component = renderer.create(<Button text={text}>{children}</Button>); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
it('renders class="button--block" if props.block given', () => { | |||
const component = renderer.create(<Button block />); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
it('adds class "button-secondary" if props.secondary given', () => { | |||
const component = renderer.create(<Button secondary />); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
}); |
@ -0,0 +1,18 @@ | |||
import React from 'react'; | |||
import renderer from 'react-test-renderer'; | |||
import { fromJS } from 'immutable'; | |||
import DisplayName from '../display_name'; | |||
describe('<DisplayName />', () => { | |||
it('renders display name + account name', () => { | |||
const account = fromJS({ | |||
username: 'bar', | |||
acct: 'bar@baz', | |||
display_name_html: '<p>Foo</p>', | |||
}); | |||
const component = renderer.create(<DisplayName account={account} />); | |||
const tree = component.toJSON(); | |||
expect(tree).toMatchSnapshot(); | |||
}); | |||
}); |
@ -0,0 +1,116 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import Avatar from './avatar'; | |||
import DisplayName from './display_name'; | |||
import Permalink from './permalink'; | |||
import IconButton from './icon_button'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import { me } from '../initial_state'; | |||
const messages = defineMessages({ | |||
follow: { id: 'account.follow', defaultMessage: 'Follow' }, | |||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, | |||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, | |||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, | |||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, | |||
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, | |||
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' }, | |||
}); | |||
@injectIntl | |||
export default class Account extends ImmutablePureComponent { | |||
static propTypes = { | |||
account: ImmutablePropTypes.map.isRequired, | |||
onFollow: PropTypes.func.isRequired, | |||
onBlock: PropTypes.func.isRequired, | |||
onMute: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
hidden: PropTypes.bool, | |||
}; | |||
handleFollow = () => { | |||
this.props.onFollow(this.props.account); | |||
} | |||
handleBlock = () => { | |||
this.props.onBlock(this.props.account); | |||
} | |||
handleMute = () => { | |||
this.props.onMute(this.props.account); | |||
} | |||
handleMuteNotifications = () => { | |||
this.props.onMuteNotifications(this.props.account, true); | |||
} | |||
handleUnmuteNotifications = () => { | |||
this.props.onMuteNotifications(this.props.account, false); | |||
} | |||
render () { | |||
const { account, intl, hidden } = this.props; | |||
if (!account) { | |||
return <div />; | |||
} | |||
if (hidden) { | |||
return ( | |||
<div> | |||
{account.get('display_name')} | |||
{account.get('username')} | |||
</div> | |||
); | |||
} | |||
let buttons; | |||
if (account.get('id') !== me && account.get('relationship', null) !== null) { | |||
const following = account.getIn(['relationship', 'following']); | |||
const requested = account.getIn(['relationship', 'requested']); | |||
const blocking = account.getIn(['relationship', 'blocking']); | |||
const muting = account.getIn(['relationship', 'muting']); | |||
if (requested) { | |||
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; | |||
} else if (blocking) { | |||
buttons = <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; | |||
} else if (muting) { | |||
let hidingNotificationsButton; | |||
if (muting.get('notifications')) { | |||
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />; | |||
} else { | |||
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />; | |||
} | |||
buttons = ( | |||
<div> | |||
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} /> | |||
{hidingNotificationsButton} | |||
</div> | |||
); | |||
} else { | |||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; | |||
} | |||
} | |||
return ( | |||
<div className='account'> | |||
<div className='account__wrapper'> | |||
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> | |||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> | |||
<DisplayName account={account} /> | |||
</Permalink> | |||
<div className='account__relationship'> | |||
{buttons} | |||
</div> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,33 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; | |||
export default class AttachmentList extends ImmutablePureComponent { | |||
static propTypes = { | |||
media: ImmutablePropTypes.list.isRequired, | |||
}; | |||
render () { | |||
const { media } = this.props; | |||
return ( | |||
<div className='attachment-list'> | |||
<div className='attachment-list__icon'> | |||
<i className='fa fa-link' /> | |||
</div> | |||
<ul className='attachment-list__list'> | |||
{media.map(attachment => | |||
<li key={attachment.get('id')}> | |||
<a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a> | |||
</li> | |||
)} | |||
</ul> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,42 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; | |||
const assetHost = process.env.CDN_HOST || ''; | |||
export default class AutosuggestEmoji extends React.PureComponent { | |||
static propTypes = { | |||
emoji: PropTypes.object.isRequired, | |||
}; | |||
render () { | |||
const { emoji } = this.props; | |||
let url; | |||
if (emoji.custom) { | |||
url = emoji.imageUrl; | |||
} else { | |||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; | |||
if (!mapping) { | |||
return null; | |||
} | |||
url = `${assetHost}/emoji/${mapping.filename}.svg`; | |||
} | |||
return ( | |||
<div className='autosuggest-emoji'> | |||
<img | |||
className='emojione' | |||
src={url} | |||
alt={emoji.native || emoji.colons} | |||
/> | |||
{emoji.colons} | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,222 @@ | |||
import React from 'react'; | |||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; | |||
import AutosuggestEmoji from './autosuggest_emoji'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import { isRtl } from '../rtl'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import Textarea from 'react-textarea-autosize'; | |||
import classNames from 'classnames'; | |||
const textAtCursorMatchesToken = (str, caretPosition) => { | |||
let word; | |||
let left = str.slice(0, caretPosition).search(/\S+$/); | |||
let right = str.slice(caretPosition).search(/\s/); | |||
if (right < 0) { | |||
word = str.slice(left); | |||
} else { | |||
word = str.slice(left, right + caretPosition); | |||
} | |||
if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) { | |||
return [null, null]; | |||
} | |||
word = word.trim().toLowerCase(); | |||
if (word.length > 0) { | |||
return [left + 1, word]; | |||
} else { | |||
return [null, null]; | |||
} | |||
}; | |||
export default class AutosuggestTextarea extends ImmutablePureComponent { | |||
static propTypes = { | |||
value: PropTypes.string, | |||
suggestions: ImmutablePropTypes.list, | |||
disabled: PropTypes.bool, | |||
placeholder: PropTypes.string, | |||
onSuggestionSelected: PropTypes.func.isRequired, | |||
onSuggestionsClearRequested: PropTypes.func.isRequired, | |||
onSuggestionsFetchRequested: PropTypes.func.isRequired, | |||
onChange: PropTypes.func.isRequired, | |||
onKeyUp: PropTypes.func, | |||
onKeyDown: PropTypes.func, | |||
onPaste: PropTypes.func.isRequired, | |||
autoFocus: PropTypes.bool, | |||
}; | |||
static defaultProps = { | |||
autoFocus: true, | |||
}; | |||
state = { | |||
suggestionsHidden: false, | |||
selectedSuggestion: 0, | |||
lastToken: null, | |||
tokenStart: 0, | |||
}; | |||
onChange = (e) => { | |||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); | |||
if (token !== null && this.state.lastToken !== token) { | |||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); | |||
this.props.onSuggestionsFetchRequested(token); | |||
} else if (token === null) { | |||
this.setState({ lastToken: null }); | |||
this.props.onSuggestionsClearRequested(); | |||
} | |||
this.props.onChange(e); | |||
} | |||
onKeyDown = (e) => { | |||
const { suggestions, disabled } = this.props; | |||
const { selectedSuggestion, suggestionsHidden } = this.state; | |||
if (disabled) { | |||
e.preventDefault(); | |||
return; | |||
} | |||
switch(e.key) { | |||
case 'Escape': | |||
if (!suggestionsHidden) { | |||
e.preventDefault(); | |||
this.setState({ suggestionsHidden: true }); | |||
} | |||
break; | |||
case 'ArrowDown': | |||
if (suggestions.size > 0 && !suggestionsHidden) { | |||
e.preventDefault(); | |||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); | |||
} | |||
break; | |||
case 'ArrowUp': | |||
if (suggestions.size > 0 && !suggestionsHidden) { | |||
e.preventDefault(); | |||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); | |||
} | |||
break; | |||
case 'Enter': | |||
case 'Tab': | |||
// Select suggestion | |||
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); | |||
} | |||
break; | |||
} | |||
if (e.defaultPrevented || !this.props.onKeyDown) { | |||
return; | |||
} | |||
this.props.onKeyDown(e); | |||
} | |||
onKeyUp = e => { | |||
if (e.key === 'Escape' && this.state.suggestionsHidden) { | |||
document.querySelector('.ui').parentElement.focus(); | |||
} | |||
if (this.props.onKeyUp) { | |||
this.props.onKeyUp(e); | |||
} | |||
} | |||
onBlur = () => { | |||
this.setState({ suggestionsHidden: true }); | |||
} | |||
onSuggestionClick = (e) => { | |||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); | |||
e.preventDefault(); | |||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); | |||
this.textarea.focus(); | |||
} | |||
componentWillReceiveProps (nextProps) { | |||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { | |||
this.setState({ suggestionsHidden: false }); | |||
} | |||
} | |||
setTextarea = (c) => { | |||
this.textarea = c; | |||
} | |||
onPaste = (e) => { | |||
if (e.clipboardData && e.clipboardData.files.length === 1) { | |||
this.props.onPaste(e.clipboardData.files); | |||
e.preventDefault(); | |||
} | |||
} | |||
renderSuggestion = (suggestion, i) => { | |||
const { selectedSuggestion } = this.state; | |||
let inner, key; | |||
if (typeof suggestion === 'object') { | |||
inner = <AutosuggestEmoji emoji={suggestion} />; | |||
key = suggestion.id; | |||
} else { | |||
inner = <AutosuggestAccountContainer id={suggestion} />; | |||
key = suggestion; | |||
} | |||
return ( | |||
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> | |||
{inner} | |||
</div> | |||
); | |||
} | |||
render () { | |||
const { value, suggestions, disabled, placeholder, autoFocus } = this.props; | |||
const { suggestionsHidden } = this.state; | |||
const style = { direction: 'ltr' }; | |||
if (isRtl(value)) { | |||
style.direction = 'rtl'; | |||
} | |||
return ( | |||
<div className='autosuggest-textarea'> | |||
<label> | |||
<span style={{ display: 'none' }}>{placeholder}</span> | |||
<Textarea | |||
inputRef={this.setTextarea} | |||
className='autosuggest-textarea__textarea' | |||
disabled={disabled} | |||
placeholder={placeholder} | |||
autoFocus={autoFocus} | |||
value={value} | |||
onChange={this.onChange} | |||
onKeyDown={this.onKeyDown} | |||
onKeyUp={this.onKeyUp} | |||
onBlur={this.onBlur} | |||
onPaste={this.onPaste} | |||
style={style} | |||
/> | |||
</label> | |||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> | |||
{suggestions.map(this.renderSuggestion)} | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,71 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
export default class Avatar extends React.PureComponent { | |||
static propTypes = { | |||
account: ImmutablePropTypes.map.isRequired, | |||
size: PropTypes.number.isRequired, | |||
style: PropTypes.object, | |||
animate: PropTypes.bool, | |||
inline: PropTypes.bool, | |||
}; | |||
static defaultProps = { | |||
animate: false, | |||
size: 20, | |||
inline: false, | |||
}; | |||
state = { | |||
hovering: false, | |||
}; | |||
handleMouseEnter = () => { | |||
if (this.props.animate) return; | |||
this.setState({ hovering: true }); | |||
} | |||
handleMouseLeave = () => { | |||
if (this.props.animate) return; | |||
this.setState({ hovering: false }); | |||
} | |||
render () { | |||
const { account, size, animate, inline } = this.props; | |||
const { hovering } = this.state; | |||
const src = account.get('avatar'); | |||
const staticSrc = account.get('avatar_static'); | |||
let className = 'account__avatar'; | |||
if (inline) { | |||
className = className + ' account__avatar-inline'; | |||
} | |||
const style = { | |||
...this.props.style, | |||
width: `${size}px`, | |||
height: `${size}px`, | |||
backgroundSize: `${size}px ${size}px`, | |||
}; | |||
if (hovering || animate) { | |||
style.backgroundImage = `url(${src})`; | |||
} else { | |||
style.backgroundImage = `url(${staticSrc})`; | |||
} | |||
return ( | |||
<div | |||
className={className} | |||
onMouseEnter={this.handleMouseEnter} | |||
onMouseLeave={this.handleMouseLeave} | |||
style={style} | |||
/> | |||
); | |||
} | |||
} |
@ -0,0 +1,30 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
export default class AvatarOverlay extends React.PureComponent { | |||
static propTypes = { | |||
account: ImmutablePropTypes.map.isRequired, | |||
friend: ImmutablePropTypes.map.isRequired, | |||
}; | |||
render() { | |||
const { account, friend } = this.props; | |||
const baseStyle = { | |||
backgroundImage: `url(${account.get('avatar_static')})`, | |||
}; | |||
const overlayStyle = { | |||
backgroundImage: `url(${friend.get('avatar_static')})`, | |||
}; | |||
return ( | |||
<div className='account__avatar-overlay'> | |||
<div className='account__avatar-overlay-base' style={baseStyle} /> | |||
<div className='account__avatar-overlay-overlay' style={overlayStyle} /> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,63 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import classNames from 'classnames'; | |||
export default class Button extends React.PureComponent { | |||
static propTypes = { | |||
text: PropTypes.node, | |||
onClick: PropTypes.func, | |||
disabled: PropTypes.bool, | |||
block: PropTypes.bool, | |||
secondary: PropTypes.bool, | |||
size: PropTypes.number, | |||
className: PropTypes.string, | |||
style: PropTypes.object, | |||
children: PropTypes.node, | |||
}; | |||
static defaultProps = { | |||
size: 36, | |||
}; | |||
handleClick = (e) => { | |||
if (!this.props.disabled) { | |||
this.props.onClick(e); | |||
} | |||
} | |||
setRef = (c) => { | |||
this.node = c; | |||
} | |||
focus() { | |||
this.node.focus(); | |||
} | |||
render () { | |||
const style = { | |||
padding: `0 ${this.props.size / 2.25}px`, | |||
height: `${this.props.size}px`, | |||
lineHeight: `${this.props.size}px`, | |||
...this.props.style, | |||
}; | |||
const className = classNames('button', this.props.className, { | |||
'button-secondary': this.props.secondary, | |||
'button--block': this.props.block, | |||
}); | |||
return ( | |||
<button | |||
className={className} | |||
disabled={this.props.disabled} | |||
onClick={this.handleClick} | |||
ref={this.setRef} | |||
style={style} | |||
> | |||
{this.props.text || this.props.children} | |||
</button> | |||
); | |||
} | |||
} |
@ -0,0 +1,22 @@ | |||
import React from 'react'; | |||
import Motion from '../features/ui/util/optional_motion'; | |||
import spring from 'react-motion/lib/spring'; | |||
import PropTypes from 'prop-types'; | |||
const Collapsable = ({ fullHeight, isVisible, children }) => ( | |||
<Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}> | |||
{({ opacity, height }) => | |||
<div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}> | |||
{children} | |||
</div> | |||
} | |||
</Motion> | |||
); | |||
Collapsable.propTypes = { | |||
fullHeight: PropTypes.number.isRequired, | |||
isVisible: PropTypes.bool.isRequired, | |||
children: PropTypes.node.isRequired, | |||
}; | |||
export default Collapsable; |
@ -0,0 +1,52 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import detectPassiveEvents from 'detect-passive-events'; | |||
import { scrollTop } from '../scroll'; | |||
export default class Column extends React.PureComponent { | |||
static propTypes = { | |||
children: PropTypes.node, | |||
}; | |||
scrollTop () { | |||
const scrollable = this.node.querySelector('.scrollable'); | |||
if (!scrollable) { | |||
return; | |||
} | |||
this._interruptScrollAnimation = scrollTop(scrollable); | |||
} | |||
handleWheel = () => { | |||
if (typeof this._interruptScrollAnimation !== 'function') { | |||
return; | |||
} | |||
this._interruptScrollAnimation(); | |||
} | |||
setRef = c => { | |||
this.node = c; | |||
} | |||
componentDidMount () { | |||
this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); | |||
} | |||
componentWillUnmount () { | |||
this.node.removeEventListener('wheel', this.handleWheel); | |||
} | |||
render () { | |||
const { children } = this.props; | |||
return ( | |||
<div role='region' className='column' ref={this.setRef}> | |||
{children} | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,28 @@ | |||
import React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import PropTypes from 'prop-types'; | |||
export default class ColumnBackButton extends React.PureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
handleClick = () => { | |||
if (window.history && window.history.length === 1) { | |||
this.context.router.history.push('/'); | |||
} else { | |||
this.context.router.history.goBack(); | |||
} | |||
} | |||
render () { | |||
return ( | |||
<button onClick={this.handleClick} className='column-back-button'> | |||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' /> | |||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> | |||
</button> | |||
); | |||
} | |||
} |
@ -0,0 +1,27 @@ | |||
import React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import PropTypes from 'prop-types'; | |||
export default class ColumnBackButtonSlim extends React.PureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
handleClick = () => { | |||
if (window.history && window.history.length === 1) this.context.router.history.push('/'); | |||
else this.context.router.history.goBack(); | |||
} | |||
render () { | |||
return ( | |||
<div className='column-back-button--slim'> | |||
<div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'> | |||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' /> | |||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,159 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import classNames from 'classnames'; | |||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; | |||
const messages = defineMessages({ | |||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, | |||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, | |||
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, | |||
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, | |||
}); | |||
@injectIntl | |||
export default class ColumnHeader extends React.PureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
intl: PropTypes.object.isRequired, | |||
title: PropTypes.node.isRequired, | |||
icon: PropTypes.string.isRequired, | |||
active: PropTypes.bool, | |||
multiColumn: PropTypes.bool, | |||
focusable: PropTypes.bool, | |||
showBackButton: PropTypes.bool, | |||
children: PropTypes.node, | |||
pinned: PropTypes.bool, | |||
onPin: PropTypes.func, | |||
onMove: PropTypes.func, | |||
onClick: PropTypes.func, | |||
}; | |||
static defaultProps = { | |||
focusable: true, | |||
} | |||
state = { | |||
collapsed: true, | |||
animating: false, | |||
}; | |||
handleToggleClick = (e) => { | |||
e.stopPropagation(); | |||
this.setState({ collapsed: !this.state.collapsed, animating: true }); | |||
} | |||
handleTitleClick = () => { | |||
this.props.onClick(); | |||
} | |||
handleMoveLeft = () => { | |||
this.props.onMove(-1); | |||
} | |||
handleMoveRight = () => { | |||
this.props.onMove(1); | |||
} | |||
handleBackClick = () => { | |||
if (window.history && window.history.length === 1) this.context.router.history.push('/'); | |||
else this.context.router.history.goBack(); | |||
} | |||
handleTransitionEnd = () => { | |||
this.setState({ animating: false }); | |||
} | |||
render () { | |||
const { title, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage } } = this.props; | |||
const { collapsed, animating } = this.state; | |||
const wrapperClassName = classNames('column-header__wrapper', { | |||
'active': active, | |||
}); | |||
const buttonClassName = classNames('column-header', { | |||
'active': active, | |||
}); | |||
const collapsibleClassName = classNames('column-header__collapsible', { | |||
'collapsed': collapsed, | |||
'animating': animating, | |||
}); | |||
const collapsibleButtonClassName = classNames('column-header__button', { | |||
'active': !collapsed, | |||
}); | |||
let extraContent, pinButton, moveButtons, backButton, collapseButton; | |||
if (children) { | |||
extraContent = ( | |||
<div key='extra-content' className='column-header__collapsible__extra'> | |||
{children} | |||
</div> | |||
); | |||
} | |||
if (multiColumn && pinned) { | |||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; | |||
moveButtons = ( | |||
<div key='move-buttons' className='column-header__setting-arrows'> | |||
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button> | |||
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button> | |||
</div> | |||
); | |||
} else if (multiColumn) { | |||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; | |||
} | |||
if (!pinned && (multiColumn || showBackButton)) { | |||
backButton = ( | |||
<button onClick={this.handleBackClick} className='column-header__back-button'> | |||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' /> | |||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> | |||
</button> | |||
); | |||
} | |||
const collapsedContent = [ | |||
extraContent, | |||
]; | |||
if (multiColumn) { | |||
collapsedContent.push(moveButtons); | |||
collapsedContent.push(pinButton); | |||
} | |||
if (children || multiColumn) { | |||
collapseButton = <button className={collapsibleButtonClassName} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>; | |||
} | |||
return ( | |||
<div className={wrapperClassName}> | |||
<h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> | |||
<i className={`fa fa-fw fa-${icon} column-header__icon`} /> | |||
<span className='column-header__title'> | |||
{title} | |||
</span> | |||
<div className='column-header__buttons'> | |||
{backButton} | |||
{collapseButton} | |||
</div> | |||
</h1> | |||
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> | |||
<div className='column-header__collapsible-inner'> | |||
{(!collapsed || animating) && collapsedContent} | |||
</div> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,20 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
export default class DisplayName extends React.PureComponent { | |||
static propTypes = { | |||
account: ImmutablePropTypes.map.isRequired, | |||
}; | |||
render () { | |||
const displayNameHtml = { __html: this.props.account.get('display_name_html') }; | |||
return ( | |||
<span className='display-name'> | |||
<strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span> | |||
</span> | |||
); | |||
} | |||
} |
@ -0,0 +1,211 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import IconButton from './icon_button'; | |||
import Overlay from 'react-overlays/lib/Overlay'; | |||
import Motion from '../features/ui/util/optional_motion'; | |||
import spring from 'react-motion/lib/spring'; | |||
import detectPassiveEvents from 'detect-passive-events'; | |||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; | |||
class DropdownMenu extends React.PureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
items: PropTypes.array.isRequired, | |||
onClose: PropTypes.func.isRequired, | |||
style: PropTypes.object, | |||
placement: PropTypes.string, | |||
arrowOffsetLeft: PropTypes.string, | |||
arrowOffsetTop: PropTypes.string, | |||
}; | |||
static defaultProps = { | |||
style: {}, | |||
placement: 'bottom', | |||
}; | |||
handleDocumentClick = e => { | |||
if (this.node && !this.node.contains(e.target)) { | |||
this.props.onClose(); | |||
} | |||
} | |||
componentDidMount () { | |||
document.addEventListener('click', this.handleDocumentClick, false); | |||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); | |||
} | |||
componentWillUnmount () { | |||
document.removeEventListener('click', this.handleDocumentClick, false); | |||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); | |||
} | |||
setRef = c => { | |||
this.node = c; | |||
} | |||
handleClick = e => { | |||
const i = Number(e.currentTarget.getAttribute('data-index')); | |||
const { action, to } = this.props.items[i]; | |||
this.props.onClose(); | |||
if (typeof action === 'function') { | |||
e.preventDefault(); | |||
action(); | |||
} else if (to) { | |||
e.preventDefault(); | |||
this.context.router.history.push(to); | |||
} | |||
} | |||
renderItem (option, i) { | |||
if (option === null) { | |||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />; | |||
} | |||
const { text, href = '#' } = option; | |||
return ( | |||
<li className='dropdown-menu__item' key={`${text}-${i}`}> | |||
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}> | |||
{text} | |||
</a> | |||
</li> | |||
); | |||
} | |||
render () { | |||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; | |||
return ( | |||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> | |||
{({ opacity, scaleX, scaleY }) => ( | |||
<div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> | |||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> | |||
<ul> | |||
{items.map((option, i) => this.renderItem(option, i))} | |||
</ul> | |||
</div> | |||
)} | |||
</Motion> | |||
); | |||
} | |||
} | |||
export default class Dropdown extends React.PureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
icon: PropTypes.string.isRequired, | |||
items: PropTypes.array.isRequired, | |||
size: PropTypes.number.isRequired, | |||
ariaLabel: PropTypes.string, | |||
disabled: PropTypes.bool, | |||
status: ImmutablePropTypes.map, | |||
isUserTouching: PropTypes.func, | |||
isModalOpen: PropTypes.bool.isRequired, | |||
onModalOpen: PropTypes.func, | |||
onModalClose: PropTypes.func, | |||
}; | |||
static defaultProps = { | |||
ariaLabel: 'Menu', | |||
}; | |||
state = { | |||
expanded: false, | |||
}; | |||
handleClick = () => { | |||
if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) { | |||
const { status, items } = this.props; | |||
this.props.onModalOpen({ | |||
status, | |||
actions: items, | |||
onClick: this.handleItemClick, | |||
}); | |||
return; | |||
} | |||
this.setState({ expanded: !this.state.expanded }); | |||
} | |||
handleClose = () => { | |||
if (this.props.onModalClose) { | |||
this.props.onModalClose(); | |||
} | |||
this.setState({ expanded: false }); | |||
} | |||
handleKeyDown = e => { | |||
switch(e.key) { | |||
case 'Enter': | |||
this.handleClick(); | |||
break; | |||
case 'Escape': | |||
this.handleClose(); | |||
break; | |||
} | |||
} | |||
handleItemClick = e => { | |||
const i = Number(e.currentTarget.getAttribute('data-index')); | |||
const { action, to } = this.props.items[i]; | |||
this.handleClose(); | |||
if (typeof action === 'function') { | |||
e.preventDefault(); | |||
action(); | |||
} else if (to) { | |||
e.preventDefault(); | |||
this.context.router.history.push(to); | |||
} | |||
} | |||
setTargetRef = c => { | |||
this.target = c; | |||
} | |||
findTarget = () => { | |||
return this.target; | |||
} | |||
render () { | |||
const { icon, items, size, ariaLabel, disabled } = this.props; | |||
const { expanded } = this.state; | |||
return ( | |||
<div onKeyDown={this.handleKeyDown}> | |||
<IconButton | |||
icon={icon} | |||
title={ariaLabel} | |||
active={expanded} | |||
disabled={disabled} | |||
size={size} | |||
ref={this.setTargetRef} | |||
onClick={this.handleClick} | |||
/> | |||
<Overlay show={expanded} placement='bottom' target={this.findTarget}> | |||
<DropdownMenu items={items} onClose={this.handleClose} /> | |||
</Overlay> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,54 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
export default class ExtendedVideoPlayer extends React.PureComponent { | |||
static propTypes = { | |||
src: PropTypes.string.isRequired, | |||
alt: PropTypes.string, | |||
width: PropTypes.number, | |||
height: PropTypes.number, | |||
time: PropTypes.number, | |||
controls: PropTypes.bool.isRequired, | |||
muted: PropTypes.bool.isRequired, | |||
}; | |||
handleLoadedData = () => { | |||
if (this.props.time) { | |||
this.video.currentTime = this.props.time; | |||
} | |||
} | |||
componentDidMount () { | |||
this.video.addEventListener('loadeddata', this.handleLoadedData); | |||
} | |||
componentWillUnmount () { | |||
this.video.removeEventListener('loadeddata', this.handleLoadedData); | |||
} | |||
setRef = (c) => { | |||
this.video = c; | |||
} | |||
render () { | |||
const { src, muted, controls, alt } = this.props; | |||
return ( | |||
<div className='extended-video-player'> | |||
<video | |||
ref={this.setRef} | |||
src={src} | |||
autoPlay | |||
role='button' | |||
tabIndex='0' | |||
aria-label={alt} | |||
muted={muted} | |||
controls={controls} | |||
loop={!controls} | |||
/> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,114 @@ | |||
import React from 'react'; | |||
import Motion from '../features/ui/util/optional_motion'; | |||
import spring from 'react-motion/lib/spring'; | |||
import PropTypes from 'prop-types'; | |||
import classNames from 'classnames'; | |||
export default class IconButton extends React.PureComponent { | |||
static propTypes = { | |||
className: PropTypes.string, | |||
title: PropTypes.string.isRequired, | |||
icon: PropTypes.string.isRequired, | |||
onClick: PropTypes.func, | |||
size: PropTypes.number, | |||
active: PropTypes.bool, | |||
pressed: PropTypes.bool, | |||
expanded: PropTypes.bool, | |||
style: PropTypes.object, | |||
activeStyle: PropTypes.object, | |||
disabled: PropTypes.bool, | |||
inverted: PropTypes.bool, | |||
animate: PropTypes.bool, | |||
overlay: PropTypes.bool, | |||
tabIndex: PropTypes.string, | |||
}; | |||
static defaultProps = { | |||
size: 18, | |||
active: false, | |||
disabled: false, | |||
animate: false, | |||
overlay: false, | |||
tabIndex: '0', | |||
}; | |||
handleClick = (e) => { | |||
e.preventDefault(); | |||
if (!this.props.disabled) { | |||
this.props.onClick(e); | |||
} | |||
} | |||
render () { | |||
const style = { | |||
fontSize: `${this.props.size}px`, | |||
width: `${this.props.size * 1.28571429}px`, | |||
height: `${this.props.size * 1.28571429}px`, | |||
lineHeight: `${this.props.size}px`, | |||
...this.props.style, | |||
...(this.props.active ? this.props.activeStyle : {}), | |||
}; | |||
const { | |||
active, | |||
animate, | |||
className, | |||
disabled, | |||
expanded, | |||
icon, | |||
inverted, | |||
overlay, | |||
pressed, | |||
tabIndex, | |||
title, | |||
} = this.props; | |||
const classes = classNames(className, 'icon-button', { | |||
active, | |||
disabled, | |||
inverted, | |||
overlayed: overlay, | |||
}); | |||
if (!animate) { | |||
// Perf optimization: avoid unnecessary <Motion> components unless | |||
// we actually need to animate. | |||
return ( | |||
<button | |||
aria-label={title} | |||
aria-pressed={pressed} | |||
aria-expanded={expanded} | |||
title={title} | |||
className={classes} | |||
onClick={this.handleClick} | |||
style={style} | |||
tabIndex={tabIndex} | |||
> | |||
<i className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> | |||
</button> | |||
); | |||
} | |||
return ( | |||
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> | |||
{({ rotate }) => | |||
<button | |||
aria-label={title} | |||
aria-pressed={pressed} | |||
aria-expanded={expanded} | |||
title={title} | |||
className={classes} | |||
onClick={this.handleClick} | |||
style={style} | |||
tabIndex={tabIndex} | |||
> | |||
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> | |||
</button> | |||
} | |||
</Motion> | |||
); | |||
} | |||
} |
@ -0,0 +1,130 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; | |||
import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; | |||
import { is } from 'immutable'; | |||
// Diff these props in the "rendered" state | |||
const updateOnPropsForRendered = ['id', 'index', 'listLength']; | |||
// Diff these props in the "unrendered" state | |||
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; | |||
export default class IntersectionObserverArticle extends React.Component { | |||
static propTypes = { | |||
intersectionObserverWrapper: PropTypes.object.isRequired, | |||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |||
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |||
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |||
saveHeightKey: PropTypes.string, | |||
cachedHeight: PropTypes.number, | |||
onHeightChange: PropTypes.func, | |||
children: PropTypes.node, | |||
}; | |||
state = { | |||
isHidden: false, // set to true in requestIdleCallback to trigger un-render | |||
} | |||
shouldComponentUpdate (nextProps, nextState) { | |||
const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight); | |||
const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight); | |||
if (!!isUnrendered !== !!willBeUnrendered) { | |||
// If we're going from rendered to unrendered (or vice versa) then update | |||
return true; | |||
} | |||
// Otherwise, diff based on props | |||
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered; | |||
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop])); | |||
} | |||
componentDidMount () { | |||
const { intersectionObserverWrapper, id } = this.props; | |||
intersectionObserverWrapper.observe( | |||
id, | |||
this.node, | |||
this.handleIntersection | |||
); | |||
this.componentMounted = true; | |||
} | |||
componentWillUnmount () { | |||
const { intersectionObserverWrapper, id } = this.props; | |||
intersectionObserverWrapper.unobserve(id, this.node); | |||
this.componentMounted = false; | |||
} | |||
handleIntersection = (entry) => { | |||
this.entry = entry; | |||
scheduleIdleTask(this.calculateHeight); | |||
this.setState(this.updateStateAfterIntersection); | |||
} | |||
updateStateAfterIntersection = (prevState) => { | |||
if (prevState.isIntersecting && !this.entry.isIntersecting) { | |||
scheduleIdleTask(this.hideIfNotIntersecting); | |||
} | |||
return { | |||
isIntersecting: this.entry.isIntersecting, | |||
isHidden: false, | |||
}; | |||
} | |||
calculateHeight = () => { | |||
const { onHeightChange, saveHeightKey, id } = this.props; | |||
// save the height of the fully-rendered element (this is expensive | |||
// on Chrome, where we need to fall back to getBoundingClientRect) | |||
this.height = getRectFromEntry(this.entry).height; | |||
if (onHeightChange && saveHeightKey) { | |||
onHeightChange(saveHeightKey, id, this.height); | |||
} | |||
} | |||
hideIfNotIntersecting = () => { | |||
if (!this.componentMounted) { | |||
return; | |||
} | |||
// When the browser gets a chance, test if we're still not intersecting, | |||
// and if so, set our isHidden to true to trigger an unrender. The point of | |||
// this is to save DOM nodes and avoid using up too much memory. | |||
// See: https://github.com/tootsuite/mastodon/issues/2900 | |||
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); | |||
} | |||
handleRef = (node) => { | |||
this.node = node; | |||
} | |||
render () { | |||
const { children, id, index, listLength, cachedHeight } = this.props; | |||
const { isIntersecting, isHidden } = this.state; | |||
if (!isIntersecting && (isHidden || cachedHeight)) { | |||
return ( | |||
<article | |||
ref={this.handleRef} | |||
aria-posinset={index} | |||
aria-setsize={listLength} | |||
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }} | |||
data-id={id} | |||
tabIndex='0' | |||
> | |||
{children && React.cloneElement(children, { hidden: true })} | |||
</article> | |||
); | |||
} | |||
return ( | |||
<article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'> | |||
{children && React.cloneElement(children, { hidden: false })} | |||
</article> | |||
); | |||
} | |||
} |
@ -0,0 +1,26 @@ | |||
import React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import PropTypes from 'prop-types'; | |||
export default class LoadMore extends React.PureComponent { | |||
static propTypes = { | |||
onClick: PropTypes.func, | |||
visible: PropTypes.bool, | |||
} | |||
static defaultProps = { | |||
visible: true, | |||
} | |||
render() { | |||
const { visible } = this.props; | |||
return ( | |||
<button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}> | |||
<FormattedMessage id='status.load_more' defaultMessage='Load more' /> | |||
</button> | |||
); | |||
} | |||
} |
@ -0,0 +1,11 @@ | |||
import React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
const LoadingIndicator = () => ( | |||
<div className='loading-indicator'> | |||
<div className='loading-indicator__figure' /> | |||
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> | |||
</div> | |||
); | |||
export default LoadingIndicator; |
@ -0,0 +1,278 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import { is } from 'immutable'; | |||
import IconButton from './icon_button'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import { isIOS } from '../is_mobile'; | |||
import classNames from 'classnames'; | |||
import { autoPlayGif } from '../initial_state'; | |||
const messages = defineMessages({ | |||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, | |||
}); | |||
class Item extends React.PureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
attachment: ImmutablePropTypes.map.isRequired, | |||
standalone: PropTypes.bool, | |||
index: PropTypes.number.isRequired, | |||
size: PropTypes.number.isRequired, | |||
onClick: PropTypes.func.isRequired, | |||
}; | |||
static defaultProps = { | |||
standalone: false, | |||
index: 0, | |||
size: 1, | |||
}; | |||
handleMouseEnter = (e) => { | |||
if (this.hoverToPlay()) { | |||
e.target.play(); | |||
} | |||
} | |||
handleMouseLeave = (e) => { | |||
if (this.hoverToPlay()) { | |||
e.target.pause(); | |||
e.target.currentTime = 0; | |||
} | |||
} | |||
hoverToPlay () { | |||
const { attachment } = this.props; | |||
return !autoPlayGif && attachment.get('type') === 'gifv'; | |||
} | |||
handleClick = (e) => { | |||
const { index, onClick } = this.props; | |||
if (this.context.router && e.button === 0) { | |||
e.preventDefault(); | |||
onClick(index); | |||
} | |||
e.stopPropagation(); | |||
} | |||
render () { | |||
const { attachment, index, size, standalone } = this.props; | |||
let width = 50; | |||
let height = 100; | |||
let top = 'auto'; | |||
let left = 'auto'; | |||
let bottom = 'auto'; | |||
let right = 'auto'; | |||
if (size === 1) { | |||
width = 100; | |||
} | |||
if (size === 4 || (size === 3 && index > 0)) { | |||
height = 50; | |||
} | |||
if (size === 2) { | |||
if (index === 0) { | |||
right = '2px'; | |||
} else { | |||
left = '2px'; | |||
} | |||
} else if (size === 3) { | |||
if (index === 0) { | |||
right = '2px'; | |||
} else if (index > 0) { | |||
left = '2px'; | |||
} | |||
if (index === 1) { | |||
bottom = '2px'; | |||
} else if (index > 1) { | |||
top = '2px'; | |||
} | |||
} else if (size === 4) { | |||
if (index === 0 || index === 2) { | |||
right = '2px'; | |||
} | |||
if (index === 1 || index === 3) { | |||
left = '2px'; | |||
} | |||
if (index < 2) { | |||
bottom = '2px'; | |||
} else { | |||
top = '2px'; | |||
} | |||
} | |||
let thumbnail = ''; | |||
if (attachment.get('type') === 'image') { | |||
const previewUrl = attachment.get('preview_url'); | |||
const previewWidth = attachment.getIn(['meta', 'small', 'width']); | |||
const originalUrl = attachment.get('url'); | |||
const originalWidth = attachment.getIn(['meta', 'original', 'width']); | |||
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; | |||
const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; | |||
const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; | |||
thumbnail = ( | |||
<a | |||
className='media-gallery__item-thumbnail' | |||
href={attachment.get('remote_url') || originalUrl} | |||
onClick={this.handleClick} | |||
target='_blank' | |||
> | |||
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} /> | |||
</a> | |||
); | |||
} else if (attachment.get('type') === 'gifv') { | |||
const autoPlay = !isIOS() && autoPlayGif; | |||
thumbnail = ( | |||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> | |||
<video | |||
className='media-gallery__item-gifv-thumbnail' | |||
aria-label={attachment.get('description')} | |||
role='application' | |||
src={attachment.get('url')} | |||
onClick={this.handleClick} | |||
onMouseEnter={this.handleMouseEnter} | |||
onMouseLeave={this.handleMouseLeave} | |||
autoPlay={autoPlay} | |||
loop | |||
muted | |||
/> | |||
<span className='media-gallery__gifv__label'>GIF</span> | |||
</div> | |||
); | |||
} | |||
return ( | |||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> | |||
{thumbnail} | |||
</div> | |||
); | |||
} | |||
} | |||
@injectIntl | |||
export default class MediaGallery extends React.PureComponent { | |||
static propTypes = { | |||
sensitive: PropTypes.bool, | |||
standalone: PropTypes.bool, | |||
media: ImmutablePropTypes.list.isRequired, | |||
size: PropTypes.object, | |||
height: PropTypes.number.isRequired, | |||
onOpenMedia: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
static defaultProps = { | |||
standalone: false, | |||
}; | |||
state = { | |||
visible: !this.props.sensitive, | |||
}; | |||
componentWillReceiveProps (nextProps) { | |||
if (!is(nextProps.media, this.props.media)) { | |||
this.setState({ visible: !nextProps.sensitive }); | |||
} | |||
} | |||
handleOpen = () => { | |||
this.setState({ visible: !this.state.visible }); | |||
} | |||
handleClick = (index) => { | |||
this.props.onOpenMedia(this.props.media, index); | |||
} | |||
handleRef = (node) => { | |||
if (node && this.isStandaloneEligible()) { | |||
// offsetWidth triggers a layout, so only calculate when we need to | |||
this.setState({ | |||
width: node.offsetWidth, | |||
}); | |||
} | |||
} | |||
isStandaloneEligible() { | |||
const { media, standalone } = this.props; | |||
return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); | |||
} | |||
render () { | |||
const { media, intl, sensitive, height } = this.props; | |||
const { width, visible } = this.state; | |||
let children; | |||
const style = {}; | |||
if (this.isStandaloneEligible()) { | |||
if (!visible && width) { | |||
// only need to forcibly set the height in "sensitive" mode | |||
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); | |||
} else { | |||
// layout automatically, using image's natural aspect ratio | |||
style.height = ''; | |||
} | |||
} else { | |||
// crop the image | |||
style.height = height; | |||
} | |||
if (!visible) { | |||
let warning; | |||
if (sensitive) { | |||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; | |||
} else { | |||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; | |||
} | |||
children = ( | |||
<button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}> | |||
<span className='media-spoiler__warning'>{warning}</span> | |||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> | |||
</button> | |||
); | |||
} else { | |||
const size = media.take(4).size; | |||
if (this.isStandaloneEligible()) { | |||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />; | |||
} else { | |||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />); | |||
} | |||
} | |||
return ( | |||
<div className='media-gallery' style={style}> | |||
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}> | |||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> | |||
</div> | |||
{children} | |||
</div> | |||
); | |||
} | |||
} |
@ -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; |
@ -0,0 +1,34 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
export default class Permalink extends React.PureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
className: PropTypes.string, | |||
href: PropTypes.string.isRequired, | |||
to: PropTypes.string.isRequired, | |||
children: PropTypes.node, | |||
}; | |||
handleClick = (e) => { | |||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { | |||
e.preventDefault(); | |||
this.context.router.history.push(this.props.to); | |||
} | |||
} | |||
render () { | |||
const { href, children, className, ...other } = this.props; | |||
return ( | |||
<a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}> | |||
{children} | |||
</a> | |||
); | |||
} | |||
} |
@ -0,0 +1,147 @@ | |||
import React from 'react'; | |||
import { injectIntl, defineMessages } from 'react-intl'; | |||
import PropTypes from 'prop-types'; | |||
const messages = defineMessages({ | |||
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, | |||
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, | |||
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, | |||
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, | |||
days: { id: 'relative_time.days', defaultMessage: '{number}d' }, | |||
}); | |||
const dateFormatOptions = { | |||
hour12: false, | |||
year: 'numeric', | |||
month: 'short', | |||
day: '2-digit', | |||
hour: '2-digit', | |||
minute: '2-digit', | |||
}; | |||
const shortDateFormatOptions = { | |||
month: 'numeric', | |||
day: 'numeric', | |||
}; | |||
const SECOND = 1000; | |||
const MINUTE = 1000 * 60; | |||
const HOUR = 1000 * 60 * 60; | |||
const DAY = 1000 * 60 * 60 * 24; | |||
const MAX_DELAY = 2147483647; | |||
const selectUnits = delta => { | |||
const absDelta = Math.abs(delta); | |||
if (absDelta < MINUTE) { | |||
return 'second'; | |||
} else if (absDelta < HOUR) { | |||
return 'minute'; | |||
} else if (absDelta < DAY) { | |||
return 'hour'; | |||
} | |||
return 'day'; | |||
}; | |||
const getUnitDelay = units => { | |||
switch (units) { | |||
case 'second': | |||
return SECOND; | |||
case 'minute': | |||
return MINUTE; | |||
case 'hour': | |||
return HOUR; | |||
case 'day': | |||
return DAY; | |||
default: | |||
return MAX_DELAY; | |||
} | |||
}; | |||
@injectIntl | |||
export default class RelativeTimestamp extends React.Component { | |||
static propTypes = { | |||
intl: PropTypes.object.isRequired, | |||
timestamp: PropTypes.string.isRequired, | |||
}; | |||
state = { | |||
now: this.props.intl.now(), | |||
}; | |||
shouldComponentUpdate (nextProps, nextState) { | |||
// As of right now the locale doesn't change without a new page load, | |||
// but we might as well check in case that ever changes. | |||
return this.props.timestamp !== nextProps.timestamp || | |||
this.props.intl.locale !== nextProps.intl.locale || | |||
this.state.now !== nextState.now; | |||
} | |||
componentWillReceiveProps (nextProps) { | |||
if (this.props.timestamp !== nextProps.timestamp) { | |||
this.setState({ now: this.props.intl.now() }); | |||
} | |||
} | |||
componentDidMount () { | |||
this._scheduleNextUpdate(this.props, this.state); | |||
} | |||
componentWillUpdate (nextProps, nextState) { | |||
this._scheduleNextUpdate(nextProps, nextState); | |||
} | |||
componentWillUnmount () { | |||
clearTimeout(this._timer); | |||
} | |||
_scheduleNextUpdate (props, state) { | |||
clearTimeout(this._timer); | |||
const { timestamp } = props; | |||
const delta = (new Date(timestamp)).getTime() - state.now; | |||
const unitDelay = getUnitDelay(selectUnits(delta)); | |||
const unitRemainder = Math.abs(delta % unitDelay); | |||
const updateInterval = 1000 * 10; | |||
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder); | |||
this._timer = setTimeout(() => { | |||
this.setState({ now: this.props.intl.now() }); | |||
}, delay); | |||
} | |||
render () { | |||
const { timestamp, intl } = this.props; | |||
const date = new Date(timestamp); | |||
const delta = this.state.now - date.getTime(); | |||
let relativeTime; | |||
if (delta < 10 * SECOND) { | |||
relativeTime = intl.formatMessage(messages.just_now); | |||
} else if (delta < 3 * DAY) { | |||
if (delta < MINUTE) { | |||
relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); | |||
} else if (delta < HOUR) { | |||
relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); | |||
} else if (delta < DAY) { | |||
relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); | |||
} else { | |||
relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); | |||
} | |||
} else { | |||
relativeTime = intl.formatDate(date, shortDateFormatOptions); | |||
} | |||
return ( | |||
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> | |||
{relativeTime} | |||
</time> | |||
); | |||
} | |||
} |
@ -0,0 +1,198 @@ | |||
import React, { PureComponent } from 'react'; | |||
import { ScrollContainer } from 'react-router-scroll-4'; | |||
import PropTypes from 'prop-types'; | |||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; | |||
import LoadMore from './load_more'; | |||
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; | |||
import { throttle } from 'lodash'; | |||
import { List as ImmutableList } from 'immutable'; | |||
import classNames from 'classnames'; | |||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; | |||
export default class ScrollableList extends PureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
scrollKey: PropTypes.string.isRequired, | |||
onScrollToBottom: PropTypes.func, | |||
onScrollToTop: PropTypes.func, | |||
onScroll: PropTypes.func, | |||
trackScroll: PropTypes.bool, | |||
shouldUpdateScroll: PropTypes.func, | |||
isLoading: PropTypes.bool, | |||
hasMore: PropTypes.bool, | |||
prepend: PropTypes.node, | |||
emptyMessage: PropTypes.node, | |||
children: PropTypes.node, | |||
}; | |||
static defaultProps = { | |||
trackScroll: true, | |||
}; | |||
state = { | |||
lastMouseMove: null, | |||
}; | |||
intersectionObserverWrapper = new IntersectionObserverWrapper(); | |||
handleScroll = throttle(() => { | |||
if (this.node) { | |||
const { scrollTop, scrollHeight, clientHeight } = this.node; | |||
const offset = scrollHeight - scrollTop - clientHeight; | |||
this._oldScrollPosition = scrollHeight - scrollTop; | |||
if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { | |||
this.props.onScrollToBottom(); | |||
} else if (scrollTop < 100 && this.props.onScrollToTop) { | |||
this.props.onScrollToTop(); | |||
} else if (this.props.onScroll) { | |||
this.props.onScroll(); | |||
} | |||
} | |||
}, 150, { | |||
trailing: true, | |||
}); | |||
handleMouseMove = throttle(() => { | |||
this._lastMouseMove = new Date(); | |||
}, 300); | |||
handleMouseLeave = () => { | |||
this._lastMouseMove = null; | |||
} | |||
componentDidMount () { | |||
this.attachScrollListener(); | |||
this.attachIntersectionObserver(); | |||
attachFullscreenListener(this.onFullScreenChange); | |||
// Handle initial scroll posiiton | |||
this.handleScroll(); | |||
} | |||
componentDidUpdate (prevProps) { | |||
const someItemInserted = React.Children.count(prevProps.children) > 0 && | |||
React.Children.count(prevProps.children) < React.Children.count(this.props.children) && | |||
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); | |||
// Reset the scroll position when a new child comes in in order not to | |||
// jerk the scrollbar around if you're already scrolled down the page. | |||
if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) { | |||
const newScrollTop = this.node.scrollHeight - this._oldScrollPosition; | |||
if (this.node.scrollTop !== newScrollTop) { | |||
this.node.scrollTop = newScrollTop; | |||
} | |||
} else { | |||
this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; | |||
} | |||
} | |||
componentWillUnmount () { | |||
this.detachScrollListener(); | |||
this.detachIntersectionObserver(); | |||
detachFullscreenListener(this.onFullScreenChange); | |||
} | |||
onFullScreenChange = () => { | |||
this.setState({ fullscreen: isFullscreen() }); | |||
} | |||
attachIntersectionObserver () { | |||
this.intersectionObserverWrapper.connect({ | |||
root: this.node, | |||
rootMargin: '300% 0px', | |||
}); | |||
} | |||
detachIntersectionObserver () { | |||
this.intersectionObserverWrapper.disconnect(); | |||
} | |||
attachScrollListener () { | |||
this.node.addEventListener('scroll', this.handleScroll); | |||
} | |||
detachScrollListener () { | |||
this.node.removeEventListener('scroll', this.handleScroll); | |||
} | |||
getFirstChildKey (props) { | |||
const { children } = props; | |||
let firstChild = children; | |||
if (children instanceof ImmutableList) { | |||
firstChild = children.get(0); | |||
} else if (Array.isArray(children)) { | |||
firstChild = children[0]; | |||
} | |||
return firstChild && firstChild.key; | |||
} | |||
setRef = (c) => { | |||
this.node = c; | |||
} | |||
handleLoadMore = (e) => { | |||
e.preventDefault(); | |||
this.props.onScrollToBottom(); | |||
} | |||
_recentlyMoved () { | |||
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); | |||
} | |||
render () { | |||
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; | |||
const { fullscreen } = this.state; | |||
const childrenCount = React.Children.count(children); | |||
const loadMore = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; | |||
let scrollableArea = null; | |||
if (isLoading || childrenCount > 0 || !emptyMessage) { | |||
scrollableArea = ( | |||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> | |||
<div role='feed' className='item-list'> | |||
{prepend} | |||
{React.Children.map(this.props.children, (child, index) => ( | |||
<IntersectionObserverArticleContainer | |||
key={child.key} | |||
id={child.key} | |||
index={index} | |||
listLength={childrenCount} | |||
intersectionObserverWrapper={this.intersectionObserverWrapper} | |||
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} | |||
> | |||
{child} | |||
</IntersectionObserverArticleContainer> | |||
))} | |||
{loadMore} | |||
</div> | |||
</div> | |||
); | |||
} else { | |||
scrollableArea = ( | |||
<div className='empty-column-indicator' ref={this.setRef}> | |||
{emptyMessage} | |||
</div> | |||
); | |||
} | |||
if (trackScroll) { | |||
return ( | |||
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> | |||
{scrollableArea} | |||
</ScrollContainer> | |||
); | |||
} else { | |||
return scrollableArea; | |||
} | |||
} | |||
} |
@ -0,0 +1,34 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
export default class SettingText extends React.PureComponent { | |||
static propTypes = { | |||
settings: ImmutablePropTypes.map.isRequired, | |||
settingKey: PropTypes.array.isRequired, | |||
label: PropTypes.string.isRequired, | |||
onChange: PropTypes.func.isRequired, | |||
}; | |||
handleChange = (e) => { | |||
this.props.onChange(this.props.settingKey, e.target.value); | |||
} | |||
render () { | |||
const { settings, settingKey, label } = this.props; | |||
return ( | |||
<label> | |||
<span style={{ display: 'none' }}>{label}</span> | |||
<input | |||
className='setting-text' | |||
value={settings.getIn(settingKey)} | |||
onChange={this.handleChange} | |||
placeholder={label} | |||
/> | |||
</label> | |||
); | |||
} | |||
} |
@ -0,0 +1,246 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import Avatar from './avatar'; | |||
import AvatarOverlay from './avatar_overlay'; | |||
import RelativeTimestamp from './relative_timestamp'; | |||
import DisplayName from './display_name'; | |||
import StatusContent from './status_content'; | |||
import StatusActionBar from './status_action_bar'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import { MediaGallery, Video } from '../features/ui/util/async-components'; | |||
import { HotKeys } from 'react-hotkeys'; | |||
import classNames from 'classnames'; | |||
// We use the component (and not the container) since we do not want | |||
// to use the progress bar to show download progress | |||
import Bundle from '../features/ui/components/bundle'; | |||
export default class Status extends ImmutablePureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
status: ImmutablePropTypes.map, | |||
account: ImmutablePropTypes.map, | |||
onReply: PropTypes.func, | |||
onFavourite: PropTypes.func, | |||
onReblog: PropTypes.func, | |||
onDelete: PropTypes.func, | |||
onPin: PropTypes.func, | |||
onOpenMedia: PropTypes.func, | |||
onOpenVideo: PropTypes.func, | |||
onBlock: PropTypes.func, | |||
onEmbed: PropTypes.func, | |||
onHeightChange: PropTypes.func, | |||
muted: PropTypes.bool, | |||
hidden: PropTypes.bool, | |||
onMoveUp: PropTypes.func, | |||
onMoveDown: PropTypes.func, | |||
}; | |||
state = { | |||
isExpanded: false, | |||
} | |||
// Avoid checking props that are functions (and whose equality will always | |||
// evaluate to false. See react-immutable-pure-component for usage. | |||
updateOnProps = [ | |||
'status', | |||
'account', | |||
'muted', | |||
'hidden', | |||
] | |||
updateOnStates = ['isExpanded'] | |||
handleClick = () => { | |||
if (!this.context.router) { | |||
return; | |||
} | |||
const { status } = this.props; | |||
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); | |||
} | |||
handleAccountClick = (e) => { | |||
if (this.context.router && e.button === 0) { | |||
const id = e.currentTarget.getAttribute('data-id'); | |||
e.preventDefault(); | |||
this.context.router.history.push(`/accounts/${id}`); | |||
} | |||
} | |||
handleExpandedToggle = () => { | |||
this.setState({ isExpanded: !this.state.isExpanded }); | |||
}; | |||
renderLoadingMediaGallery () { | |||
return <div className='media_gallery' style={{ height: '110px' }} />; | |||
} | |||
renderLoadingVideoPlayer () { | |||
return <div className='media-spoiler-video' style={{ height: '110px' }} />; | |||
} | |||
handleOpenVideo = startTime => { | |||
this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime); | |||
} | |||
handleHotkeyReply = e => { | |||
e.preventDefault(); | |||
this.props.onReply(this._properStatus(), this.context.router.history); | |||
} | |||
handleHotkeyFavourite = () => { | |||
this.props.onFavourite(this._properStatus()); | |||
} | |||
handleHotkeyBoost = e => { | |||
this.props.onReblog(this._properStatus(), e); | |||
} | |||
handleHotkeyMention = e => { | |||
e.preventDefault(); | |||
this.props.onMention(this._properStatus().get('account'), this.context.router.history); | |||
} | |||
handleHotkeyOpen = () => { | |||
this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`); | |||
} | |||
handleHotkeyOpenProfile = () => { | |||
this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`); | |||
} | |||
handleHotkeyMoveUp = () => { | |||
this.props.onMoveUp(this.props.status.get('id')); | |||
} | |||
handleHotkeyMoveDown = () => { | |||
this.props.onMoveDown(this.props.status.get('id')); | |||
} | |||
_properStatus () { | |||
const { status } = this.props; | |||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | |||
return status.get('reblog'); | |||
} else { | |||
return status; | |||
} | |||
} | |||
render () { | |||
let media = null; | |||
let statusAvatar, prepend; | |||
const { hidden } = this.props; | |||
const { isExpanded } = this.state; | |||
let { status, account, ...other } = this.props; | |||
if (status === null) { | |||
return null; | |||
} | |||
if (hidden) { | |||
return ( | |||
<div> | |||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} | |||
{status.get('content')} | |||
</div> | |||
); | |||
} | |||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | |||
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; | |||
prepend = ( | |||
<div className='status__prepend'> | |||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> | |||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} /> | |||
</div> | |||
); | |||
account = status.get('account'); | |||
status = status.get('reblog'); | |||
} | |||
if (status.get('media_attachments').size > 0 && !this.props.muted) { | |||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { | |||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { | |||
const video = status.getIn(['media_attachments', 0]); | |||
media = ( | |||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > | |||
{Component => <Component | |||
preview={video.get('preview_url')} | |||
src={video.get('url')} | |||
width={239} | |||
height={110} | |||
sensitive={status.get('sensitive')} | |||
onOpenVideo={this.handleOpenVideo} | |||
/>} | |||
</Bundle> | |||
); | |||
} else { | |||
media = ( | |||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} > | |||
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />} | |||
</Bundle> | |||
); | |||
} | |||
} | |||
if (account === undefined || account === null) { | |||
statusAvatar = <Avatar account={status.get('account')} size={48} />; | |||
}else{ | |||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; | |||
} | |||
const handlers = this.props.muted ? {} : { | |||
reply: this.handleHotkeyReply, | |||
favourite: this.handleHotkeyFavourite, | |||
boost: this.handleHotkeyBoost, | |||
mention: this.handleHotkeyMention, | |||
open: this.handleHotkeyOpen, | |||
openProfile: this.handleHotkeyOpenProfile, | |||
moveUp: this.handleHotkeyMoveUp, | |||
moveDown: this.handleHotkeyMoveDown, | |||
}; | |||
return ( | |||
<HotKeys handlers={handlers}> | |||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}> | |||
{prepend} | |||
<div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}> | |||
<div className='status__info'> | |||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> | |||
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'> | |||
<div className='status__avatar'> | |||
{statusAvatar} | |||
</div> | |||
<DisplayName account={status.get('account')} /> | |||
</a> | |||
</div> | |||
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> | |||
{media} | |||
<StatusActionBar status={status} account={account} {...other} /> | |||
</div> | |||
</div> | |||
</HotKeys> | |||
); | |||
} | |||
} |
@ -0,0 +1,188 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import IconButton from './icon_button'; | |||
import DropdownMenuContainer from '../containers/dropdown_menu_container'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import { me } from '../initial_state'; | |||
const messages = defineMessages({ | |||
delete: { id: 'status.delete', defaultMessage: 'Delete' }, | |||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, | |||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, | |||
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, | |||
reply: { id: 'status.reply', defaultMessage: 'Reply' }, | |||
share: { id: 'status.share', defaultMessage: 'Share' }, | |||
more: { id: 'status.more', defaultMessage: 'More' }, | |||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, | |||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, | |||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, | |||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, | |||
open: { id: 'status.open', defaultMessage: 'Expand this status' }, | |||
report: { id: 'status.report', defaultMessage: 'Report @{name}' }, | |||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, | |||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, | |||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, | |||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, | |||
embed: { id: 'status.embed', defaultMessage: 'Embed' }, | |||
}); | |||
@injectIntl | |||
export default class StatusActionBar extends ImmutablePureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
status: ImmutablePropTypes.map.isRequired, | |||
onReply: PropTypes.func, | |||
onFavourite: PropTypes.func, | |||
onReblog: PropTypes.func, | |||
onDelete: PropTypes.func, | |||
onMention: PropTypes.func, | |||
onMute: PropTypes.func, | |||
onBlock: PropTypes.func, | |||
onReport: PropTypes.func, | |||
onEmbed: PropTypes.func, | |||
onMuteConversation: PropTypes.func, | |||
onPin: PropTypes.func, | |||
withDismiss: PropTypes.bool, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
// Avoid checking props that are functions (and whose equality will always | |||
// evaluate to false. See react-immutable-pure-component for usage. | |||
updateOnProps = [ | |||
'status', | |||
'withDismiss', | |||
] | |||
handleReplyClick = () => { | |||
this.props.onReply(this.props.status, this.context.router.history); | |||
} | |||
handleShareClick = () => { | |||
navigator.share({ | |||
text: this.props.status.get('search_index'), | |||
url: this.props.status.get('url'), | |||
}); | |||
} | |||
handleFavouriteClick = () => { | |||
this.props.onFavourite(this.props.status); | |||
} | |||
handleReblogClick = (e) => { | |||
this.props.onReblog(this.props.status, e); | |||
} | |||
handleDeleteClick = () => { | |||
this.props.onDelete(this.props.status); | |||
} | |||
handlePinClick = () => { | |||
this.props.onPin(this.props.status); | |||
} | |||
handleMentionClick = () => { | |||
this.props.onMention(this.props.status.get('account'), this.context.router.history); | |||
} | |||
handleMuteClick = () => { | |||
this.props.onMute(this.props.status.get('account')); | |||
} | |||
handleBlockClick = () => { | |||
this.props.onBlock(this.props.status.get('account')); | |||
} | |||
handleOpen = () => { | |||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); | |||
} | |||
handleEmbed = () => { | |||
this.props.onEmbed(this.props.status); | |||
} | |||
handleReport = () => { | |||
this.props.onReport(this.props.status); | |||
} | |||
handleConversationMuteClick = () => { | |||
this.props.onMuteConversation(this.props.status); | |||
} | |||
render () { | |||
const { status, intl, withDismiss } = this.props; | |||
const mutingConversation = status.get('muted'); | |||
const anonymousAccess = !me; | |||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); | |||
let menu = []; | |||
let reblogIcon = 'retweet'; | |||
let replyIcon; | |||
let replyTitle; | |||
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); | |||
if (publicStatus) { | |||
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); | |||
} | |||
menu.push(null); | |||
if (status.getIn(['account', 'id']) === me || withDismiss) { | |||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); | |||
menu.push(null); | |||
} | |||
if (status.getIn(['account', 'id']) === me) { | |||
if (publicStatus) { | |||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); | |||
} | |||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); | |||
} else { | |||
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); | |||
menu.push(null); | |||
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); | |||
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); | |||
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); | |||
} | |||
if (status.get('visibility') === 'direct') { | |||
reblogIcon = 'envelope'; | |||
} else if (status.get('visibility') === 'private') { | |||
reblogIcon = 'lock'; | |||
} | |||
if (status.get('in_reply_to_id', null) === null) { | |||
replyIcon = 'reply'; | |||
replyTitle = intl.formatMessage(messages.reply); | |||
} else { | |||
replyIcon = 'reply-all'; | |||
replyTitle = intl.formatMessage(messages.replyAll); | |||
} | |||
const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( | |||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} /> | |||
); | |||
return ( | |||
<div className='status__action-bar'> | |||
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> | |||
<IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> | |||
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> | |||
{shareButton} | |||
<div className='status__action-bar-dropdown'> | |||
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} /> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,185 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import { isRtl } from '../rtl'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import Permalink from './permalink'; | |||
import classnames from 'classnames'; | |||
export default class StatusContent extends React.PureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
status: ImmutablePropTypes.map.isRequired, | |||
expanded: PropTypes.bool, | |||
onExpandedToggle: PropTypes.func, | |||
onClick: PropTypes.func, | |||
}; | |||
state = { | |||
hidden: true, | |||
}; | |||
_updateStatusLinks () { | |||
const node = this.node; | |||
const links = node.querySelectorAll('a'); | |||
for (var i = 0; i < links.length; ++i) { | |||
let link = links[i]; | |||
if (link.classList.contains('status-link')) { | |||
continue; | |||
} | |||
link.classList.add('status-link'); | |||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); | |||
if (mention) { | |||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false); | |||
link.setAttribute('title', mention.get('acct')); | |||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { | |||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); | |||
} else { | |||
link.setAttribute('title', link.href); | |||
} | |||
link.setAttribute('target', '_blank'); | |||
link.setAttribute('rel', 'noopener'); | |||
} | |||
} | |||
componentDidMount () { | |||
this._updateStatusLinks(); | |||
} | |||
componentDidUpdate () { | |||
this._updateStatusLinks(); | |||
} | |||
onMentionClick = (mention, e) => { | |||
if (this.context.router && e.button === 0) { | |||
e.preventDefault(); | |||
this.context.router.history.push(`/accounts/${mention.get('id')}`); | |||
} | |||
} | |||
onHashtagClick = (hashtag, e) => { | |||
hashtag = hashtag.replace(/^#/, '').toLowerCase(); | |||
if (this.context.router && e.button === 0) { | |||
e.preventDefault(); | |||
this.context.router.history.push(`/timelines/tag/${hashtag}`); | |||
} | |||
} | |||
handleMouseDown = (e) => { | |||
this.startXY = [e.clientX, e.clientY]; | |||
} | |||
handleMouseUp = (e) => { | |||
if (!this.startXY) { | |||
return; | |||
} | |||
const [ startX, startY ] = this.startXY; | |||
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; | |||
if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { | |||
return; | |||
} | |||
if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) { | |||
this.props.onClick(); | |||
} | |||
this.startXY = null; | |||
} | |||
handleSpoilerClick = (e) => { | |||
e.preventDefault(); | |||
if (this.props.onExpandedToggle) { | |||
// The parent manages the state | |||
this.props.onExpandedToggle(); | |||
} else { | |||
this.setState({ hidden: !this.state.hidden }); | |||
} | |||
} | |||
setRef = (c) => { | |||
this.node = c; | |||
} | |||
render () { | |||
const { status } = this.props; | |||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; | |||
const content = { __html: status.get('contentHtml') }; | |||
const spoilerContent = { __html: status.get('spoilerHtml') }; | |||
const directionStyle = { direction: 'ltr' }; | |||
const classNames = classnames('status__content', { | |||
'status__content--with-action': this.props.onClick && this.context.router, | |||
'status__content--with-spoiler': status.get('spoiler_text').length > 0, | |||
}); | |||
if (isRtl(status.get('search_index'))) { | |||
directionStyle.direction = 'rtl'; | |||
} | |||
if (status.get('spoiler_text').length > 0) { | |||
let mentionsPlaceholder = ''; | |||
const mentionLinks = status.get('mentions').map(item => ( | |||
<Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'> | |||
@<span>{item.get('username')}</span> | |||
</Permalink> | |||
)).reduce((aggregate, item) => [...aggregate, item, ' '], []); | |||
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />; | |||
if (hidden) { | |||
mentionsPlaceholder = <div>{mentionLinks}</div>; | |||
} | |||
return ( | |||
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> | |||
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> | |||
<span dangerouslySetInnerHTML={spoilerContent} /> | |||
{' '} | |||
<button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>{toggleText}</button> | |||
</p> | |||
{mentionsPlaceholder} | |||
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> | |||
</div> | |||
); | |||
} else if (this.props.onClick) { | |||
return ( | |||
<div | |||
ref={this.setRef} | |||
tabIndex='0' | |||
className={classNames} | |||
style={directionStyle} | |||
onMouseDown={this.handleMouseDown} | |||
onMouseUp={this.handleMouseUp} | |||
dangerouslySetInnerHTML={content} | |||
/> | |||
); | |||
} else { | |||
return ( | |||
<div | |||
tabIndex='0' | |||
ref={this.setRef} | |||
className='status__content' | |||
style={directionStyle} | |||
dangerouslySetInnerHTML={content} | |||
/> | |||
); | |||
} | |||
} | |||
} |
@ -0,0 +1,72 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import StatusContainer from '../containers/status_container'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import ScrollableList from './scrollable_list'; | |||
export default class StatusList extends ImmutablePureComponent { | |||
static propTypes = { | |||
scrollKey: PropTypes.string.isRequired, | |||
statusIds: ImmutablePropTypes.list.isRequired, | |||
onScrollToBottom: PropTypes.func, | |||
onScrollToTop: PropTypes.func, | |||
onScroll: PropTypes.func, | |||
trackScroll: PropTypes.bool, | |||
shouldUpdateScroll: PropTypes.func, | |||
isLoading: PropTypes.bool, | |||
hasMore: PropTypes.bool, | |||
prepend: PropTypes.node, | |||
emptyMessage: PropTypes.node, | |||
}; | |||
static defaultProps = { | |||
trackScroll: true, | |||
}; | |||
handleMoveUp = id => { | |||
const elementIndex = this.props.statusIds.indexOf(id) - 1; | |||
this._selectChild(elementIndex); | |||
} | |||
handleMoveDown = id => { | |||
const elementIndex = this.props.statusIds.indexOf(id) + 1; | |||
this._selectChild(elementIndex); | |||
} | |||
_selectChild (index) { | |||
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); | |||
if (element) { | |||
element.focus(); | |||
} | |||
} | |||
setRef = c => { | |||
this.node = c; | |||
} | |||
render () { | |||
const { statusIds, ...other } = this.props; | |||
const { isLoading } = other; | |||
const scrollableContent = (isLoading || statusIds.size > 0) ? ( | |||
statusIds.map((statusId) => ( | |||
<StatusContainer | |||
key={statusId} | |||
id={statusId} | |||
onMoveUp={this.handleMoveUp} | |||
onMoveDown={this.handleMoveDown} | |||
/> | |||
)) | |||
) : null; | |||
return ( | |||
<ScrollableList {...other} ref={this.setRef}> | |||
{scrollableContent} | |||
</ScrollableList> | |||
); | |||
} | |||
} |
@ -0,0 +1,72 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import { makeGetAccount } from '../selectors'; | |||
import Account from '../components/account'; | |||
import { | |||
followAccount, | |||
unfollowAccount, | |||
blockAccount, | |||
unblockAccount, | |||
muteAccount, | |||
unmuteAccount, | |||
} from '../actions/accounts'; | |||
import { openModal } from '../actions/modal'; | |||
import { initMuteModal } from '../actions/mutes'; | |||
import { unfollowModal } from '../initial_state'; | |||
const messages = defineMessages({ | |||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, | |||
}); | |||
const makeMapStateToProps = () => { | |||
const getAccount = makeGetAccount(); | |||
const mapStateToProps = (state, props) => ({ | |||
account: getAccount(state, props.id), | |||
}); | |||
return mapStateToProps; | |||
}; | |||
const mapDispatchToProps = (dispatch, { intl }) => ({ | |||
onFollow (account) { | |||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { | |||
if (unfollowModal) { | |||
dispatch(openModal('CONFIRM', { | |||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, | |||
confirm: intl.formatMessage(messages.unfollowConfirm), | |||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))), | |||
})); | |||
} else { | |||
dispatch(unfollowAccount(account.get('id'))); | |||
} | |||
} else { | |||
dispatch(followAccount(account.get('id'))); | |||
} | |||
}, | |||
onBlock (account) { | |||
if (account.getIn(['relationship', 'blocking'])) { | |||
dispatch(unblockAccount(account.get('id'))); | |||
} else { | |||
dispatch(blockAccount(account.get('id'))); | |||
} | |||
}, | |||
onMute (account) { | |||
if (account.getIn(['relationship', 'muting'])) { | |||
dispatch(unmuteAccount(account.get('id'))); | |||
} else { | |||
dispatch(initMuteModal(account)); | |||
} | |||
}, | |||
onMuteNotifications (account, notifications) { | |||
dispatch(muteAccount(account.get('id'), notifications)); | |||
}, | |||
}); | |||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); |
@ -0,0 +1,18 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import Card from '../features/status/components/card'; | |||
import { fromJS } from 'immutable'; | |||
export default class CardContainer extends React.PureComponent { | |||
static propTypes = { | |||
locale: PropTypes.string, | |||
card: PropTypes.array.isRequired, | |||
}; | |||
render () { | |||
const { card, ...props } = this.props; | |||
return <Card card={fromJS(card)} {...props} />; | |||
} | |||
} |
@ -0,0 +1,38 @@ | |||
import React from 'react'; | |||
import { Provider } from 'react-redux'; | |||
import PropTypes from 'prop-types'; | |||
import configureStore from '../store/configureStore'; | |||
import { hydrateStore } from '../actions/store'; | |||
import { IntlProvider, addLocaleData } from 'react-intl'; | |||
import { getLocale } from '../locales'; | |||
import Compose from '../features/standalone/compose'; | |||
import initialState from '../initial_state'; | |||
const { localeData, messages } = getLocale(); | |||
addLocaleData(localeData); | |||
const store = configureStore(); | |||
if (initialState) { | |||
store.dispatch(hydrateStore(initialState)); | |||
} | |||
export default class TimelineContainer extends React.PureComponent { | |||
static propTypes = { | |||
locale: PropTypes.string.isRequired, | |||
}; | |||
render () { | |||
const { locale } = this.props; | |||
return ( | |||
<IntlProvider locale={locale} messages={messages}> | |||
<Provider store={store}> | |||
<Compose /> | |||
</Provider> | |||
</IntlProvider> | |||
); | |||
} | |||
} |
@ -0,0 +1,16 @@ | |||
import { openModal, closeModal } from '../actions/modal'; | |||
import { connect } from 'react-redux'; | |||
import DropdownMenu from '../components/dropdown_menu'; | |||
import { isUserTouching } from '../is_mobile'; | |||
const mapStateToProps = state => ({ | |||
isModalOpen: state.get('modal').modalType === 'ACTIONS', | |||
}); | |||
const mapDispatchToProps = dispatch => ({ | |||
isUserTouching, | |||
onModalOpen: props => dispatch(openModal('ACTIONS', props)), | |||
onModalClose: () => dispatch(closeModal()), | |||
}); | |||
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); |
@ -0,0 +1,17 @@ | |||
import { connect } from 'react-redux'; | |||
import IntersectionObserverArticle from '../components/intersection_observer_article'; | |||
import { setHeight } from '../actions/height_cache'; | |||
const makeMapStateToProps = (state, props) => ({ | |||
cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), | |||
}); | |||
const mapDispatchToProps = (dispatch) => ({ | |||
onHeightChange (key, id, height) { | |||
dispatch(setHeight(key, id, height)); | |||
}, | |||
}); | |||
export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle); |
@ -0,0 +1,70 @@ | |||
import React from 'react'; | |||
import { Provider } from 'react-redux'; | |||
import PropTypes from 'prop-types'; | |||
import configureStore from '../store/configureStore'; | |||
import { showOnboardingOnce } from '../actions/onboarding'; | |||
import { BrowserRouter, Route } from 'react-router-dom'; | |||
import { ScrollContext } from 'react-router-scroll-4'; | |||
import UI from '../features/ui'; | |||
import { hydrateStore } from '../actions/store'; | |||
import { connectUserStream } from '../actions/streaming'; | |||
import { IntlProvider, addLocaleData } from 'react-intl'; | |||
import { getLocale } from '../locales'; | |||
import initialState from '../initial_state'; | |||
const { localeData, messages } = getLocale(); | |||
addLocaleData(localeData); | |||
export const store = configureStore(); | |||
const hydrateAction = hydrateStore(initialState); | |||
store.dispatch(hydrateAction); | |||
export default class Mastodon extends React.PureComponent { | |||
static propTypes = { | |||
locale: PropTypes.string.isRequired, | |||
}; | |||
componentDidMount() { | |||
this.disconnect = store.dispatch(connectUserStream()); | |||
// Desktop notifications | |||
// Ask after 1 minute | |||
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { | |||
window.setTimeout(() => Notification.requestPermission(), 60 * 1000); | |||
} | |||
// Protocol handler | |||
// Ask after 5 minutes | |||
if (typeof navigator.registerProtocolHandler !== 'undefined') { | |||
const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s'; | |||
window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000); | |||
} | |||
store.dispatch(showOnboardingOnce()); | |||
} | |||
componentWillUnmount () { | |||
if (this.disconnect) { | |||
this.disconnect(); | |||
this.disconnect = null; | |||
} | |||
} | |||
render () { | |||
const { locale } = this.props; | |||
return ( | |||
<IntlProvider locale={locale} messages={messages}> | |||
<Provider store={store}> | |||
<BrowserRouter basename='/web'> | |||
<ScrollContext> | |||
<Route path='/' component={UI} /> | |||
</ScrollContext> | |||
</BrowserRouter> | |||
</Provider> | |||
</IntlProvider> | |||
); | |||
} | |||
} |
@ -0,0 +1,34 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import { IntlProvider, addLocaleData } from 'react-intl'; | |||
import { getLocale } from '../locales'; | |||
import MediaGallery from '../components/media_gallery'; | |||
import { fromJS } from 'immutable'; | |||
const { localeData, messages } = getLocale(); | |||
addLocaleData(localeData); | |||
export default class MediaGalleryContainer extends React.PureComponent { | |||
static propTypes = { | |||
locale: PropTypes.string.isRequired, | |||
media: PropTypes.array.isRequired, | |||
}; | |||
handleOpenMedia = () => {} | |||
render () { | |||
const { locale, media, ...props } = this.props; | |||
return ( | |||
<IntlProvider locale={locale} messages={messages}> | |||
<MediaGallery | |||
{...props} | |||
media={fromJS(media)} | |||
onOpenMedia={this.handleOpenMedia} | |||
/> | |||
</IntlProvider> | |||
); | |||
} | |||
} |
@ -0,0 +1,133 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import Status from '../components/status'; | |||
import { makeGetStatus } from '../selectors'; | |||
import { | |||
replyCompose, | |||
mentionCompose, | |||
} from '../actions/compose'; | |||
import { | |||
reblog, | |||
favourite, | |||
unreblog, | |||
unfavourite, | |||
pin, | |||
unpin, | |||
} from '../actions/interactions'; | |||
import { blockAccount } from '../actions/accounts'; | |||
import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; | |||
import { initMuteModal } from '../actions/mutes'; | |||
import { initReport } from '../actions/reports'; | |||
import { openModal } from '../actions/modal'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import { boostModal, deleteModal } from '../initial_state'; | |||
const messages = defineMessages({ | |||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, | |||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, | |||
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, | |||
}); | |||
const makeMapStateToProps = () => { | |||
const getStatus = makeGetStatus(); | |||
const mapStateToProps = (state, props) => ({ | |||
status: getStatus(state, props.id), | |||
}); | |||
return mapStateToProps; | |||
}; | |||
const mapDispatchToProps = (dispatch, { intl }) => ({ | |||
onReply (status, router) { | |||
dispatch(replyCompose(status, router)); | |||
}, | |||
onModalReblog (status) { | |||
dispatch(reblog(status)); | |||
}, | |||
onReblog (status, e) { | |||
if (status.get('reblogged')) { | |||
dispatch(unreblog(status)); | |||
} else { | |||
if (e.shiftKey || !boostModal) { | |||
this.onModalReblog(status); | |||
} else { | |||
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); | |||
} | |||
} | |||
}, | |||
onFavourite (status) { | |||
if (status.get('favourited')) { | |||
dispatch(unfavourite(status)); | |||
} else { | |||
dispatch(favourite(status)); | |||
} | |||
}, | |||
onPin (status) { | |||
if (status.get('pinned')) { | |||
dispatch(unpin(status)); | |||
} else { | |||
dispatch(pin(status)); | |||
} | |||
}, | |||
onEmbed (status) { | |||
dispatch(openModal('EMBED', { url: status.get('url') })); | |||
}, | |||
onDelete (status) { | |||
if (!deleteModal) { | |||
dispatch(deleteStatus(status.get('id'))); | |||
} else { | |||
dispatch(openModal('CONFIRM', { | |||
message: intl.formatMessage(messages.deleteMessage), | |||
confirm: intl.formatMessage(messages.deleteConfirm), | |||
onConfirm: () => dispatch(deleteStatus(status.get('id'))), | |||
})); | |||
} | |||
}, | |||
onMention (account, router) { | |||
dispatch(mentionCompose(account, router)); | |||
}, | |||
onOpenMedia (media, index) { | |||
dispatch(openModal('MEDIA', { media, index })); | |||
}, | |||
onOpenVideo (media, time) { | |||
dispatch(openModal('VIDEO', { media, time })); | |||
}, | |||
onBlock (account) { | |||
dispatch(openModal('CONFIRM', { | |||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, | |||
confirm: intl.formatMessage(messages.blockConfirm), | |||
onConfirm: () => dispatch(blockAccount(account.get('id'))), | |||
})); | |||
}, | |||
onReport (status) { | |||
dispatch(initReport(status.get('account'), status)); | |||
}, | |||
onMute (account) { | |||
dispatch(initMuteModal(account)); | |||
}, | |||
onMuteConversation (status) { | |||
if (status.get('muted')) { | |||
dispatch(unmuteStatus(status.get('id'))); | |||
} else { | |||
dispatch(muteStatus(status.get('id'))); | |||
} | |||
}, | |||
}); | |||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); |
@ -0,0 +1,48 @@ | |||
import React from 'react'; | |||
import { Provider } from 'react-redux'; | |||
import PropTypes from 'prop-types'; | |||
import configureStore from '../store/configureStore'; | |||
import { hydrateStore } from '../actions/store'; | |||
import { IntlProvider, addLocaleData } from 'react-intl'; | |||
import { getLocale } from '../locales'; | |||
import PublicTimeline from '../features/standalone/public_timeline'; | |||
import HashtagTimeline from '../features/standalone/hashtag_timeline'; | |||
import initialState from '../initial_state'; | |||
const { localeData, messages } = getLocale(); | |||
addLocaleData(localeData); | |||
const store = configureStore(); | |||
if (initialState) { | |||
store.dispatch(hydrateStore(initialState)); | |||
} | |||
export default class TimelineContainer extends React.PureComponent { | |||
static propTypes = { | |||
locale: PropTypes.string.isRequired, | |||
hashtag: PropTypes.string, | |||
}; | |||
render () { | |||
const { locale, hashtag } = this.props; | |||
let timeline; | |||
if (hashtag) { | |||
timeline = <HashtagTimeline hashtag={hashtag} />; | |||
} else { | |||
timeline = <PublicTimeline />; | |||
} | |||
return ( | |||
<IntlProvider locale={locale} messages={messages}> | |||
<Provider store={store}> | |||
{timeline} | |||
</Provider> | |||
</IntlProvider> | |||
); | |||
} | |||
} |
@ -0,0 +1,26 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import { IntlProvider, addLocaleData } from 'react-intl'; | |||
import { getLocale } from '../locales'; | |||
import Video from '../features/video'; | |||
const { localeData, messages } = getLocale(); | |||
addLocaleData(localeData); | |||
export default class VideoContainer extends React.PureComponent { | |||
static propTypes = { | |||
locale: PropTypes.string.isRequired, | |||
}; | |||
render () { | |||
const { locale, ...props } = this.props; | |||
return ( | |||
<IntlProvider locale={locale} messages={messages}> | |||
<Video {...props} /> | |||
</IntlProvider> | |||
); | |||
} | |||
} |
@ -0,0 +1,5 @@ | |||
import 'intersection-observer'; | |||
import 'requestidlecallback'; | |||
import objectFitImages from 'object-fit-images'; | |||
objectFitImages(); |
@ -0,0 +1,133 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; | |||
import { Link } from 'react-router-dom'; | |||
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; | |||
import { me } from '../../../initial_state'; | |||
const messages = defineMessages({ | |||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, | |||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, | |||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, | |||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, | |||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, | |||
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, | |||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, | |||
follow: { id: 'account.follow', defaultMessage: 'Follow' }, | |||
report: { id: 'account.report', defaultMessage: 'Report @{name}' }, | |||
share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, | |||
media: { id: 'account.media', defaultMessage: 'Media' }, | |||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, | |||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, | |||
}); | |||
@injectIntl | |||
export default class ActionBar extends React.PureComponent { | |||
static propTypes = { | |||
account: ImmutablePropTypes.map.isRequired, | |||
onFollow: PropTypes.func, | |||
onBlock: PropTypes.func.isRequired, | |||
onMention: PropTypes.func.isRequired, | |||
onReport: PropTypes.func.isRequired, | |||
onMute: PropTypes.func.isRequired, | |||
onBlockDomain: PropTypes.func.isRequired, | |||
onUnblockDomain: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
handleShare = () => { | |||
navigator.share({ | |||
url: this.props.account.get('url'), | |||
}); | |||
} | |||
render () { | |||
const { account, intl } = this.props; | |||
let menu = []; | |||
let extraInfo = ''; | |||
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); | |||
if ('share' in navigator) { | |||
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); | |||
} | |||
menu.push(null); | |||
menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` }); | |||
menu.push(null); | |||
if (account.get('id') === me) { | |||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); | |||
} else { | |||
if (account.getIn(['relationship', 'muting'])) { | |||
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); | |||
} else { | |||
menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute }); | |||
} | |||
if (account.getIn(['relationship', 'blocking'])) { | |||
menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock }); | |||
} else { | |||
menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); | |||
} | |||
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport }); | |||
} | |||
if (account.get('acct') !== account.get('username')) { | |||
const domain = account.get('acct').split('@')[1]; | |||
extraInfo = ( | |||
<div className='account__disclaimer'> | |||
<FormattedMessage | |||
id='account.disclaimer_full' | |||
defaultMessage="Information below may reflect the user's profile incompletely." | |||
/> | |||
{' '} | |||
<a target='_blank' rel='noopener' href={account.get('url')}> | |||
<FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' /> | |||
</a> | |||
</div> | |||
); | |||
menu.push(null); | |||
if (account.getIn(['relationship', 'domain_blocking'])) { | |||
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain }); | |||
} else { | |||
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain }); | |||
} | |||
} | |||
return ( | |||
<div> | |||
{extraInfo} | |||
<div className='account__action-bar'> | |||
<div className='account__action-bar-dropdown'> | |||
<DropdownMenuContainer items={menu} icon='bars' size={24} direction='right' /> | |||
</div> | |||
<div className='account__action-bar-links'> | |||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> | |||
<span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span> | |||
<strong><FormattedNumber value={account.get('statuses_count')} /></strong> | |||
</Link> | |||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> | |||
<span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span> | |||
<strong><FormattedNumber value={account.get('following_count')} /></strong> | |||
</Link> | |||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> | |||
<span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span> | |||
<strong><FormattedNumber value={account.get('followers_count')} /></strong> | |||
</Link> | |||
</div> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,128 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import IconButton from '../../../components/icon_button'; | |||
import Motion from '../../ui/util/optional_motion'; | |||
import spring from 'react-motion/lib/spring'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import { autoPlayGif, me } from '../../../initial_state'; | |||
const messages = defineMessages({ | |||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, | |||
follow: { id: 'account.follow', defaultMessage: 'Follow' }, | |||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, | |||
}); | |||
class Avatar extends ImmutablePureComponent { | |||
static propTypes = { | |||
account: ImmutablePropTypes.map.isRequired, | |||
}; | |||
state = { | |||
isHovered: false, | |||
}; | |||
handleMouseOver = () => { | |||
if (this.state.isHovered) return; | |||
this.setState({ isHovered: true }); | |||
} | |||
handleMouseOut = () => { | |||
if (!this.state.isHovered) return; | |||
this.setState({ isHovered: false }); | |||
} | |||
render () { | |||
const { account } = this.props; | |||
const { isHovered } = this.state; | |||
return ( | |||
<Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}> | |||
{({ radius }) => | |||
<a | |||
href={account.get('url')} | |||
className='account__header__avatar' | |||
role='presentation' | |||
target='_blank' | |||
rel='noopener' | |||
style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }} | |||
onMouseOver={this.handleMouseOver} | |||
onMouseOut={this.handleMouseOut} | |||
onFocus={this.handleMouseOver} | |||
onBlur={this.handleMouseOut} | |||
> | |||
<span style={{ display: 'none' }}>{account.get('acct')}</span> | |||
</a> | |||
} | |||
</Motion> | |||
); | |||
} | |||
} | |||
@injectIntl | |||
export default class Header extends ImmutablePureComponent { | |||
static propTypes = { | |||
account: ImmutablePropTypes.map, | |||
onFollow: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
render () { | |||
const { account, intl } = this.props; | |||
if (!account) { | |||
return null; | |||
} | |||
let info = ''; | |||
let actionBtn = ''; | |||
let lockedIcon = ''; | |||
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { | |||
info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>; | |||
} | |||
if (me !== account.get('id')) { | |||
if (account.getIn(['relationship', 'requested'])) { | |||
actionBtn = ( | |||
<div className='account--action-button'> | |||
<IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} /> | |||
</div> | |||
); | |||
} else if (!account.getIn(['relationship', 'blocking'])) { | |||
actionBtn = ( | |||
<div className='account--action-button'> | |||
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> | |||
</div> | |||
); | |||
} | |||
} | |||
if (account.get('locked')) { | |||
lockedIcon = <i className='fa fa-lock' />; | |||
} | |||
const content = { __html: account.get('note_emojified') }; | |||
const displayNameHtml = { __html: account.get('display_name_html') }; | |||
return ( | |||
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> | |||
<div> | |||
<Avatar account={account} /> | |||
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} /> | |||
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span> | |||
<div className='account__header__content' dangerouslySetInnerHTML={content} /> | |||
{info} | |||
{actionBtn} | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,39 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import Permalink from '../../../components/permalink'; | |||
export default class MediaItem extends ImmutablePureComponent { | |||
static propTypes = { | |||
media: ImmutablePropTypes.map.isRequired, | |||
}; | |||
render () { | |||
const { media } = this.props; | |||
const status = media.get('status'); | |||
let content, style; | |||
if (media.get('type') === 'gifv') { | |||
content = <span className='media-gallery__gifv__label'>GIF</span>; | |||
} | |||
if (!status.get('sensitive')) { | |||
style = { backgroundImage: `url(${media.get('preview_url')})` }; | |||
} | |||
return ( | |||
<div className='account-gallery__item'> | |||
<Permalink | |||
to={`/statuses/${status.get('id')}`} | |||
href={status.get('url')} | |||
style={style} | |||
> | |||
{content} | |||
</Permalink> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,111 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import { fetchAccount } from '../../actions/accounts'; | |||
import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from '../../actions/timelines'; | |||
import LoadingIndicator from '../../components/loading_indicator'; | |||
import Column from '../ui/components/column'; | |||
import ColumnBackButton from '../../components/column_back_button'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import { getAccountGallery } from '../../selectors'; | |||
import MediaItem from './components/media_item'; | |||
import HeaderContainer from '../account_timeline/containers/header_container'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { ScrollContainer } from 'react-router-scroll-4'; | |||
import LoadMore from '../../components/load_more'; | |||
const mapStateToProps = (state, props) => ({ | |||
medias: getAccountGallery(state, props.params.accountId), | |||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), | |||
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']), | |||
}); | |||
@connect(mapStateToProps) | |||
export default class AccountGallery extends ImmutablePureComponent { | |||
static propTypes = { | |||
params: PropTypes.object.isRequired, | |||
dispatch: PropTypes.func.isRequired, | |||
medias: ImmutablePropTypes.list.isRequired, | |||
isLoading: PropTypes.bool, | |||
hasMore: PropTypes.bool, | |||
}; | |||
componentDidMount () { | |||
this.props.dispatch(fetchAccount(this.props.params.accountId)); | |||
this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); | |||
} | |||
componentWillReceiveProps (nextProps) { | |||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { | |||
this.props.dispatch(fetchAccount(nextProps.params.accountId)); | |||
this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); | |||
} | |||
} | |||
handleScrollToBottom = () => { | |||
if (this.props.hasMore) { | |||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); | |||
} | |||
} | |||
handleScroll = (e) => { | |||
const { scrollTop, scrollHeight, clientHeight } = e.target; | |||
const offset = scrollHeight - scrollTop - clientHeight; | |||
if (150 > offset && !this.props.isLoading) { | |||
this.handleScrollToBottom(); | |||
} | |||
} | |||
handleLoadMore = (e) => { | |||
e.preventDefault(); | |||
this.handleScrollToBottom(); | |||
} | |||
render () { | |||
const { medias, isLoading, hasMore } = this.props; | |||
let loadMore = null; | |||
if (!medias && isLoading) { | |||
return ( | |||
<Column> | |||
<LoadingIndicator /> | |||
</Column> | |||
); | |||
} | |||
if (!isLoading && medias.size > 0 && hasMore) { | |||
loadMore = <LoadMore onClick={this.handleLoadMore} />; | |||
} | |||
return ( | |||
<Column> | |||
<ColumnBackButton /> | |||
<ScrollContainer scrollKey='account_gallery'> | |||
<div className='scrollable' onScroll={this.handleScroll}> | |||
<HeaderContainer accountId={this.props.params.accountId} /> | |||
<div className='account-section-headline'> | |||
<FormattedMessage id='account.media' defaultMessage='Media' /> | |||
</div> | |||
<div className='account-gallery__container'> | |||
{medias.map(media => | |||
<MediaItem | |||
key={media.get('id')} | |||
media={media} | |||
/> | |||
)} | |||
{loadMore} | |||
</div> | |||
</div> | |||
</ScrollContainer> | |||
</Column> | |||
); | |||
} | |||
} |
@ -0,0 +1,89 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import InnerHeader from '../../account/components/header'; | |||
import ActionBar from '../../account/components/action_bar'; | |||
import MissingIndicator from '../../../components/missing_indicator'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
export default class Header extends ImmutablePureComponent { | |||
static propTypes = { | |||
account: ImmutablePropTypes.map, | |||
onFollow: PropTypes.func.isRequired, | |||
onBlock: PropTypes.func.isRequired, | |||
onMention: PropTypes.func.isRequired, | |||
onReport: PropTypes.func.isRequired, | |||
onMute: PropTypes.func.isRequired, | |||
onBlockDomain: PropTypes.func.isRequired, | |||
onUnblockDomain: PropTypes.func.isRequired, | |||
}; | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
handleFollow = () => { | |||
this.props.onFollow(this.props.account); | |||
} | |||
handleBlock = () => { | |||
this.props.onBlock(this.props.account); | |||
} | |||
handleMention = () => { | |||
this.props.onMention(this.props.account, this.context.router.history); | |||
} | |||
handleReport = () => { | |||
this.props.onReport(this.props.account); | |||
} | |||
handleMute = () => { | |||
this.props.onMute(this.props.account); | |||
} | |||
handleBlockDomain = () => { | |||
const domain = this.props.account.get('acct').split('@')[1]; | |||
if (!domain) return; | |||
this.props.onBlockDomain(domain, this.props.account.get('id')); | |||
} | |||
handleUnblockDomain = () => { | |||
const domain = this.props.account.get('acct').split('@')[1]; | |||
if (!domain) return; | |||
this.props.onUnblockDomain(domain, this.props.account.get('id')); | |||
} | |||
render () { | |||
const { account } = this.props; | |||
if (account === null) { | |||
return <MissingIndicator />; | |||
} | |||
return ( | |||
<div className='account-timeline__header'> | |||
<InnerHeader | |||
account={account} | |||
onFollow={this.handleFollow} | |||
/> | |||
<ActionBar | |||
account={account} | |||
onBlock={this.handleBlock} | |||
onMention={this.handleMention} | |||
onReport={this.handleReport} | |||
onMute={this.handleMute} | |||
onBlockDomain={this.handleBlockDomain} | |||
onUnblockDomain={this.handleUnblockDomain} | |||
/> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,96 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import { makeGetAccount } from '../../../selectors'; | |||
import Header from '../components/header'; | |||
import { | |||
followAccount, | |||
unfollowAccount, | |||
blockAccount, | |||
unblockAccount, | |||
unmuteAccount, | |||
} from '../../../actions/accounts'; | |||
import { mentionCompose } from '../../../actions/compose'; | |||
import { initMuteModal } from '../../../actions/mutes'; | |||
import { initReport } from '../../../actions/reports'; | |||
import { openModal } from '../../../actions/modal'; | |||
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import { unfollowModal } from '../../../initial_state'; | |||
const messages = defineMessages({ | |||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, | |||
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, | |||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, | |||
}); | |||
const makeMapStateToProps = () => { | |||
const getAccount = makeGetAccount(); | |||
const mapStateToProps = (state, { accountId }) => ({ | |||
account: getAccount(state, accountId), | |||
}); | |||
return mapStateToProps; | |||
}; | |||
const mapDispatchToProps = (dispatch, { intl }) => ({ | |||
onFollow (account) { | |||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { | |||
if (unfollowModal) { | |||
dispatch(openModal('CONFIRM', { | |||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, | |||
confirm: intl.formatMessage(messages.unfollowConfirm), | |||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))), | |||
})); | |||
} else { | |||
dispatch(unfollowAccount(account.get('id'))); | |||
} | |||
} else { | |||
dispatch(followAccount(account.get('id'))); | |||
} | |||
}, | |||
onBlock (account) { | |||
if (account.getIn(['relationship', 'blocking'])) { | |||
dispatch(unblockAccount(account.get('id'))); | |||
} else { | |||
dispatch(openModal('CONFIRM', { | |||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, | |||
confirm: intl.formatMessage(messages.blockConfirm), | |||
onConfirm: () => dispatch(blockAccount(account.get('id'))), | |||
})); | |||
} | |||
}, | |||
onMention (account, router) { | |||
dispatch(mentionCompose(account, router)); | |||
}, | |||
onReport (account) { | |||
dispatch(initReport(account)); | |||
}, | |||
onMute (account) { | |||
if (account.getIn(['relationship', 'muting'])) { | |||
dispatch(unmuteAccount(account.get('id'))); | |||
} else { | |||
dispatch(initMuteModal(account)); | |||
} | |||
}, | |||
onBlockDomain (domain, accountId) { | |||
dispatch(openModal('CONFIRM', { | |||
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />, | |||
confirm: intl.formatMessage(messages.blockDomainConfirm), | |||
onConfirm: () => dispatch(blockDomain(domain, accountId)), | |||
})); | |||
}, | |||
onUnblockDomain (domain, accountId) { | |||
dispatch(unblockDomain(domain, accountId)); | |||
}, | |||
}); | |||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); |
@ -0,0 +1,77 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import { fetchAccount } from '../../actions/accounts'; | |||
import { refreshAccountTimeline, expandAccountTimeline } from '../../actions/timelines'; | |||
import StatusList from '../../components/status_list'; | |||
import LoadingIndicator from '../../components/loading_indicator'; | |||
import Column from '../ui/components/column'; | |||
import HeaderContainer from './containers/header_container'; | |||
import ColumnBackButton from '../../components/column_back_button'; | |||
import { List as ImmutableList } from 'immutable'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
const mapStateToProps = (state, props) => ({ | |||
statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()), | |||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']), | |||
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']), | |||
}); | |||
@connect(mapStateToProps) | |||
export default class AccountTimeline extends ImmutablePureComponent { | |||
static propTypes = { | |||
params: PropTypes.object.isRequired, | |||
dispatch: PropTypes.func.isRequired, | |||
statusIds: ImmutablePropTypes.list, | |||
isLoading: PropTypes.bool, | |||
hasMore: PropTypes.bool, | |||
}; | |||
componentWillMount () { | |||
this.props.dispatch(fetchAccount(this.props.params.accountId)); | |||
this.props.dispatch(refreshAccountTimeline(this.props.params.accountId)); | |||
} | |||
componentWillReceiveProps (nextProps) { | |||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { | |||
this.props.dispatch(fetchAccount(nextProps.params.accountId)); | |||
this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId)); | |||
} | |||
} | |||
handleScrollToBottom = () => { | |||
if (!this.props.isLoading && this.props.hasMore) { | |||
this.props.dispatch(expandAccountTimeline(this.props.params.accountId)); | |||
} | |||
} | |||
render () { | |||
const { statusIds, isLoading, hasMore } = this.props; | |||
if (!statusIds && isLoading) { | |||
return ( | |||
<Column> | |||
<LoadingIndicator /> | |||
</Column> | |||
); | |||
} | |||
return ( | |||
<Column> | |||
<ColumnBackButton /> | |||
<StatusList | |||
prepend={<HeaderContainer accountId={this.props.params.accountId} />} | |||
scrollKey='account_timeline' | |||
statusIds={statusIds} | |||
isLoading={isLoading} | |||
hasMore={hasMore} | |||
onScrollToBottom={this.handleScrollToBottom} | |||
/> | |||
</Column> | |||
); | |||
} | |||
} |
@ -0,0 +1,70 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import LoadingIndicator from '../../components/loading_indicator'; | |||
import { ScrollContainer } from 'react-router-scroll-4'; | |||
import Column from '../ui/components/column'; | |||
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; | |||
import AccountContainer from '../../containers/account_container'; | |||
import { fetchBlocks, expandBlocks } from '../../actions/blocks'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
const messages = defineMessages({ | |||
heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, | |||
}); | |||
const mapStateToProps = state => ({ | |||
accountIds: state.getIn(['user_lists', 'blocks', 'items']), | |||
}); | |||
@connect(mapStateToProps) | |||
@injectIntl | |||
export default class Blocks extends ImmutablePureComponent { | |||
static propTypes = { | |||
params: PropTypes.object.isRequired, | |||
dispatch: PropTypes.func.isRequired, | |||
accountIds: ImmutablePropTypes.list, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
componentWillMount () { | |||
this.props.dispatch(fetchBlocks()); | |||
} | |||
handleScroll = (e) => { | |||
const { scrollTop, scrollHeight, clientHeight } = e.target; | |||
if (scrollTop === scrollHeight - clientHeight) { | |||
this.props.dispatch(expandBlocks()); | |||
} | |||
} | |||
render () { | |||
const { intl, accountIds } = this.props; | |||
if (!accountIds) { | |||
return ( | |||
<Column> | |||
<LoadingIndicator /> | |||
</Column> | |||
); | |||
} | |||
return ( | |||
<Column icon='ban' heading={intl.formatMessage(messages.heading)}> | |||
<ColumnBackButtonSlim /> | |||
<ScrollContainer scrollKey='blocks'> | |||
<div className='scrollable' onScroll={this.handleScroll}> | |||
{accountIds.map(id => | |||
<AccountContainer key={id} id={id} /> | |||
)} | |||
</div> | |||
</ScrollContainer> | |||
</Column> | |||
); | |||
} | |||
} |
@ -0,0 +1,35 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import SettingText from '../../../components/setting_text'; | |||
const messages = defineMessages({ | |||
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, | |||
settings: { id: 'home.settings', defaultMessage: 'Column settings' }, | |||
}); | |||
@injectIntl | |||
export default class ColumnSettings extends React.PureComponent { | |||
static propTypes = { | |||
settings: ImmutablePropTypes.map.isRequired, | |||
onChange: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
render () { | |||
const { settings, onChange, intl } = this.props; | |||
return ( | |||
<div> | |||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> | |||
<div className='column-settings__row'> | |||
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,17 @@ | |||
import { connect } from 'react-redux'; | |||
import ColumnSettings from '../components/column_settings'; | |||
import { changeSetting } from '../../../actions/settings'; | |||
const mapStateToProps = state => ({ | |||
settings: state.getIn(['settings', 'community']), | |||
}); | |||
const mapDispatchToProps = dispatch => ({ | |||
onChange (key, checked) { | |||
dispatch(changeSetting(['community', ...key], checked)); | |||
}, | |||
}); | |||
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); |
@ -0,0 +1,107 @@ | |||
import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import PropTypes from 'prop-types'; | |||
import StatusListContainer from '../ui/containers/status_list_container'; | |||
import Column from '../../components/column'; | |||
import ColumnHeader from '../../components/column_header'; | |||
import { | |||
refreshCommunityTimeline, | |||
expandCommunityTimeline, | |||
} from '../../actions/timelines'; | |||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import ColumnSettingsContainer from './containers/column_settings_container'; | |||
import { connectCommunityStream } from '../../actions/streaming'; | |||
const messages = defineMessages({ | |||
title: { id: 'column.community', defaultMessage: 'Local timeline' }, | |||
}); | |||
const mapStateToProps = state => ({ | |||
hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, | |||
}); | |||
@connect(mapStateToProps) | |||
@injectIntl | |||
export default class CommunityTimeline extends React.PureComponent { | |||
static propTypes = { | |||
dispatch: PropTypes.func.isRequired, | |||
columnId: PropTypes.string, | |||
intl: PropTypes.object.isRequired, | |||
hasUnread: PropTypes.bool, | |||
multiColumn: PropTypes.bool, | |||
}; | |||
handlePin = () => { | |||
const { columnId, dispatch } = this.props; | |||
if (columnId) { | |||
dispatch(removeColumn(columnId)); | |||
} else { | |||
dispatch(addColumn('COMMUNITY', {})); | |||
} | |||
} | |||
handleMove = (dir) => { | |||
const { columnId, dispatch } = this.props; | |||
dispatch(moveColumn(columnId, dir)); | |||
} | |||
handleHeaderClick = () => { | |||
this.column.scrollTop(); | |||
} | |||
componentDidMount () { | |||
const { dispatch } = this.props; | |||
dispatch(refreshCommunityTimeline()); | |||
this.disconnect = dispatch(connectCommunityStream()); | |||
} | |||
componentWillUnmount () { | |||
if (this.disconnect) { | |||
this.disconnect(); | |||
this.disconnect = null; | |||
} | |||
} | |||
setRef = c => { | |||
this.column = c; | |||
} | |||
handleLoadMore = () => { | |||
this.props.dispatch(expandCommunityTimeline()); | |||
} | |||
render () { | |||
const { intl, hasUnread, columnId, multiColumn } = this.props; | |||
const pinned = !!columnId; | |||
return ( | |||
<Column ref={this.setRef}> | |||
<ColumnHeader | |||
icon='users' | |||
active={hasUnread} | |||
title={intl.formatMessage(messages.title)} | |||
onPin={this.handlePin} | |||
onMove={this.handleMove} | |||
onClick={this.handleHeaderClick} | |||
pinned={pinned} | |||
multiColumn={multiColumn} | |||
> | |||
<ColumnSettingsContainer /> | |||
</ColumnHeader> | |||
<StatusListContainer | |||
trackScroll={!pinned} | |||
scrollKey={`community_timeline-${columnId}`} | |||
timelineId='community' | |||
loadMore={this.handleLoadMore} | |||
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} | |||
/> | |||
</Column> | |||
); | |||
} | |||
} |
@ -0,0 +1,24 @@ | |||
import React from 'react'; | |||
import Avatar from '../../../components/avatar'; | |||
import DisplayName from '../../../components/display_name'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
export default class AutosuggestAccount extends ImmutablePureComponent { | |||
static propTypes = { | |||
account: ImmutablePropTypes.map.isRequired, | |||
}; | |||
render () { | |||
const { account } = this.props; | |||
return ( | |||
<div className='autosuggest-account'> | |||
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div> | |||
<DisplayName account={account} /> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,25 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import { length } from 'stringz'; | |||
export default class CharacterCounter extends React.PureComponent { | |||
static propTypes = { | |||
text: PropTypes.string.isRequired, | |||
max: PropTypes.number.isRequired, | |||
}; | |||
checkRemainingText (diff) { | |||
if (diff < 0) { | |||
return <span className='character-counter character-counter--over'>{diff}</span>; | |||
} | |||
return <span className='character-counter'>{diff}</span>; | |||
} | |||
render () { | |||
const diff = this.props.max - length(this.props.text); | |||
return this.checkRemainingText(diff); | |||
} | |||
} |
@ -0,0 +1,212 @@ | |||
import React from 'react'; | |||
import CharacterCounter from './character_counter'; | |||
import Button from '../../../components/button'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import ReplyIndicatorContainer from '../containers/reply_indicator_container'; | |||
import AutosuggestTextarea from '../../../components/autosuggest_textarea'; | |||
import UploadButtonContainer from '../containers/upload_button_container'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import Collapsable from '../../../components/collapsable'; | |||
import SpoilerButtonContainer from '../containers/spoiler_button_container'; | |||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; | |||
import SensitiveButtonContainer from '../containers/sensitive_button_container'; | |||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; | |||
import UploadFormContainer from '../containers/upload_form_container'; | |||
import WarningContainer from '../containers/warning_container'; | |||
import { isMobile } from '../../../is_mobile'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import { length } from 'stringz'; | |||
import { countableText } from '../util/counter'; | |||
const messages = defineMessages({ | |||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, | |||
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, | |||
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, | |||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, | |||
}); | |||
@injectIntl | |||
export default class ComposeForm extends ImmutablePureComponent { | |||
static propTypes = { | |||
intl: PropTypes.object.isRequired, | |||
text: PropTypes.string.isRequired, | |||
suggestion_token: PropTypes.string, | |||
suggestions: ImmutablePropTypes.list, | |||
spoiler: PropTypes.bool, | |||
privacy: PropTypes.string, | |||
spoiler_text: PropTypes.string, | |||
focusDate: PropTypes.instanceOf(Date), | |||
preselectDate: PropTypes.instanceOf(Date), | |||
is_submitting: PropTypes.bool, | |||
is_uploading: PropTypes.bool, | |||
onChange: PropTypes.func.isRequired, | |||
onSubmit: PropTypes.func.isRequired, | |||
onClearSuggestions: PropTypes.func.isRequired, | |||
onFetchSuggestions: PropTypes.func.isRequired, | |||
onSuggestionSelected: PropTypes.func.isRequired, | |||
onChangeSpoilerText: PropTypes.func.isRequired, | |||
onPaste: PropTypes.func.isRequired, | |||
onPickEmoji: PropTypes.func.isRequired, | |||
showSearch: PropTypes.bool, | |||
}; | |||
static defaultProps = { | |||
showSearch: false, | |||
}; | |||
handleChange = (e) => { | |||
this.props.onChange(e.target.value); | |||
} | |||
handleKeyDown = (e) => { | |||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { | |||
this.handleSubmit(); | |||
} | |||
} | |||
handleSubmit = () => { | |||
if (this.props.text !== this.autosuggestTextarea.textarea.value) { | |||
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly) | |||
// Update the state to match the current text | |||
this.props.onChange(this.autosuggestTextarea.textarea.value); | |||
} | |||
this.props.onSubmit(); | |||
} | |||
onSuggestionsClearRequested = () => { | |||
this.props.onClearSuggestions(); | |||
} | |||
onSuggestionsFetchRequested = (token) => { | |||
this.props.onFetchSuggestions(token); | |||
} | |||
onSuggestionSelected = (tokenStart, token, value) => { | |||
this._restoreCaret = null; | |||
this.props.onSuggestionSelected(tokenStart, token, value); | |||
} | |||
handleChangeSpoilerText = (e) => { | |||
this.props.onChangeSpoilerText(e.target.value); | |||
} | |||
componentWillReceiveProps (nextProps) { | |||
// If this is the update where we've finished uploading, | |||
// save the last caret position so we can restore it below! | |||
if (!nextProps.is_uploading && this.props.is_uploading) { | |||
this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart; | |||
} | |||
} | |||
componentDidUpdate (prevProps) { | |||
// This statement does several things: | |||
// - If we're beginning a reply, and, | |||
// - Replying to zero or one users, places the cursor at the end of the textbox. | |||
// - Replying to more than one user, selects any usernames past the first; | |||
// this provides a convenient shortcut to drop everyone else from the conversation. | |||
// - If we've just finished uploading an image, and have a saved caret position, | |||
// restores the cursor to that position after the text changes! | |||
if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) { | |||
let selectionEnd, selectionStart; | |||
if (this.props.preselectDate !== prevProps.preselectDate) { | |||
selectionEnd = this.props.text.length; | |||
selectionStart = this.props.text.search(/\s/) + 1; | |||
} else if (typeof this._restoreCaret === 'number') { | |||
selectionStart = this._restoreCaret; | |||
selectionEnd = this._restoreCaret; | |||
} else { | |||
selectionEnd = this.props.text.length; | |||
selectionStart = selectionEnd; | |||
} | |||
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); | |||
this.autosuggestTextarea.textarea.focus(); | |||
} else if(prevProps.is_submitting && !this.props.is_submitting) { | |||
this.autosuggestTextarea.textarea.focus(); | |||
} | |||
} | |||
setAutosuggestTextarea = (c) => { | |||
this.autosuggestTextarea = c; | |||
} | |||
handleEmojiPick = (data) => { | |||
const position = this.autosuggestTextarea.textarea.selectionStart; | |||
const emojiChar = data.native; | |||
this._restoreCaret = position + emojiChar.length + 1; | |||
this.props.onPickEmoji(position, data); | |||
} | |||
render () { | |||
const { intl, onPaste, showSearch } = this.props; | |||
const disabled = this.props.is_submitting; | |||
const text = [this.props.spoiler_text, countableText(this.props.text)].join(''); | |||
let publishText = ''; | |||
if (this.props.privacy === 'private' || this.props.privacy === 'direct') { | |||
publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; | |||
} else { | |||
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); | |||
} | |||
return ( | |||
<div className='compose-form'> | |||
<Collapsable isVisible={this.props.spoiler} fullHeight={50}> | |||
<div className='spoiler-input'> | |||
<label> | |||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span> | |||
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' /> | |||
</label> | |||
</div> | |||
</Collapsable> | |||
<WarningContainer /> | |||
<ReplyIndicatorContainer /> | |||
<div className='compose-form__autosuggest-wrapper'> | |||
<AutosuggestTextarea | |||
ref={this.setAutosuggestTextarea} | |||
placeholder={intl.formatMessage(messages.placeholder)} | |||
disabled={disabled} | |||
value={this.props.text} | |||
onChange={this.handleChange} | |||
suggestions={this.props.suggestions} | |||
onKeyDown={this.handleKeyDown} | |||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} | |||
onSuggestionsClearRequested={this.onSuggestionsClearRequested} | |||
onSuggestionSelected={this.onSuggestionSelected} | |||
onPaste={onPaste} | |||
autoFocus={!showSearch && !isMobile(window.innerWidth)} | |||
/> | |||
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> | |||
</div> | |||
<div className='compose-form__modifiers'> | |||
<UploadFormContainer /> | |||
</div> | |||
<div className='compose-form__buttons-wrapper'> | |||
<div className='compose-form__buttons'> | |||
<UploadButtonContainer /> | |||
<PrivacyDropdownContainer /> | |||
<SensitiveButtonContainer /> | |||
<SpoilerButtonContainer /> | |||
</div> | |||
<div className='compose-form__publish'> | |||
<div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> | |||
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0)} block /></div> | |||
</div> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,376 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; | |||
import Overlay from 'react-overlays/lib/Overlay'; | |||
import classNames from 'classnames'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import detectPassiveEvents from 'detect-passive-events'; | |||
import { buildCustomEmojis } from '../../emoji/emoji'; | |||
const messages = defineMessages({ | |||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, | |||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, | |||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' }, | |||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, | |||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, | |||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, | |||
people: { id: 'emoji_button.people', defaultMessage: 'People' }, | |||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, | |||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, | |||
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, | |||
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, | |||
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, | |||
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, | |||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, | |||
}); | |||
const assetHost = process.env.CDN_HOST || ''; | |||
let EmojiPicker, Emoji; // load asynchronously | |||
const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`; | |||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; | |||
const categoriesSort = [ | |||
'recent', | |||
'custom', | |||
'people', | |||
'nature', | |||
'foods', | |||
'activity', | |||
'places', | |||
'objects', | |||
'symbols', | |||
'flags', | |||
]; | |||
class ModifierPickerMenu extends React.PureComponent { | |||
static propTypes = { | |||
active: PropTypes.bool, | |||
onSelect: PropTypes.func.isRequired, | |||
onClose: PropTypes.func.isRequired, | |||
}; | |||
handleClick = e => { | |||
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); | |||
} | |||
componentWillReceiveProps (nextProps) { | |||
if (nextProps.active) { | |||
this.attachListeners(); | |||
} else { | |||
this.removeListeners(); | |||
} | |||
} | |||
componentWillUnmount () { | |||
this.removeListeners(); | |||
} | |||
handleDocumentClick = e => { | |||
if (this.node && !this.node.contains(e.target)) { | |||
this.props.onClose(); | |||
} | |||
} | |||
attachListeners () { | |||
document.addEventListener('click', this.handleDocumentClick, false); | |||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); | |||
} | |||
removeListeners () { | |||
document.removeEventListener('click', this.handleDocumentClick, false); | |||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); | |||
} | |||
setRef = c => { | |||
this.node = c; | |||
} | |||
render () { | |||
const { active } = this.props; | |||
return ( | |||
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> | |||
<button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button> | |||
<button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button> | |||
<button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button> | |||
<button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button> | |||
<button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button> | |||
<button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button> | |||
</div> | |||
); | |||
} | |||
} | |||
class ModifierPicker extends React.PureComponent { | |||
static propTypes = { | |||
active: PropTypes.bool, | |||
modifier: PropTypes.number, | |||
onChange: PropTypes.func, | |||
onClose: PropTypes.func, | |||
onOpen: PropTypes.func, | |||
}; | |||
handleClick = () => { | |||
if (this.props.active) { | |||
this.props.onClose(); | |||
} else { | |||
this.props.onOpen(); | |||
} | |||
} | |||
handleSelect = modifier => { | |||
this.props.onChange(modifier); | |||
this.props.onClose(); | |||
} | |||
render () { | |||
const { active, modifier } = this.props; | |||
return ( | |||
<div className='emoji-picker-dropdown__modifiers'> | |||
<Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} /> | |||
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} /> | |||
</div> | |||
); | |||
} | |||
} | |||
@injectIntl | |||
class EmojiPickerMenu extends React.PureComponent { | |||
static propTypes = { | |||
custom_emojis: ImmutablePropTypes.list, | |||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), | |||
loading: PropTypes.bool, | |||
onClose: PropTypes.func.isRequired, | |||
onPick: PropTypes.func.isRequired, | |||
style: PropTypes.object, | |||
placement: PropTypes.string, | |||
arrowOffsetLeft: PropTypes.string, | |||
arrowOffsetTop: PropTypes.string, | |||
intl: PropTypes.object.isRequired, | |||
skinTone: PropTypes.number.isRequired, | |||
onSkinTone: PropTypes.func.isRequired, | |||
}; | |||
static defaultProps = { | |||
style: {}, | |||
loading: true, | |||
placement: 'bottom', | |||
frequentlyUsedEmojis: [], | |||
}; | |||
state = { | |||
modifierOpen: false, | |||
}; | |||
handleDocumentClick = e => { | |||
if (this.node && !this.node.contains(e.target)) { | |||
this.props.onClose(); | |||
} | |||
} | |||
componentDidMount () { | |||
document.addEventListener('click', this.handleDocumentClick, false); | |||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); | |||
} | |||
componentWillUnmount () { | |||
document.removeEventListener('click', this.handleDocumentClick, false); | |||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); | |||
} | |||
setRef = c => { | |||
this.node = c; | |||
} | |||
getI18n = () => { | |||
const { intl } = this.props; | |||
return { | |||
search: intl.formatMessage(messages.emoji_search), | |||
notfound: intl.formatMessage(messages.emoji_not_found), | |||
categories: { | |||
search: intl.formatMessage(messages.search_results), | |||
recent: intl.formatMessage(messages.recent), | |||
people: intl.formatMessage(messages.people), | |||
nature: intl.formatMessage(messages.nature), | |||
foods: intl.formatMessage(messages.food), | |||
activity: intl.formatMessage(messages.activity), | |||
places: intl.formatMessage(messages.travel), | |||
objects: intl.formatMessage(messages.objects), | |||
symbols: intl.formatMessage(messages.symbols), | |||
flags: intl.formatMessage(messages.flags), | |||
custom: intl.formatMessage(messages.custom), | |||
}, | |||
}; | |||
} | |||
handleClick = emoji => { | |||
if (!emoji.native) { | |||
emoji.native = emoji.colons; | |||
} | |||
this.props.onClose(); | |||
this.props.onPick(emoji); | |||
} | |||
handleModifierOpen = () => { | |||
this.setState({ modifierOpen: true }); | |||
} | |||
handleModifierClose = () => { | |||
this.setState({ modifierOpen: false }); | |||
} | |||
handleModifierChange = modifier => { | |||
this.props.onSkinTone(modifier); | |||
} | |||
render () { | |||
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props; | |||
if (loading) { | |||
return <div style={{ width: 299 }} />; | |||
} | |||
const title = intl.formatMessage(messages.emoji); | |||
const { modifierOpen } = this.state; | |||
return ( | |||
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> | |||
<EmojiPicker | |||
perLine={8} | |||
emojiSize={22} | |||
sheetSize={32} | |||
custom={buildCustomEmojis(custom_emojis)} | |||
color='' | |||
emoji='' | |||
set='twitter' | |||
title={title} | |||
i18n={this.getI18n()} | |||
onClick={this.handleClick} | |||
include={categoriesSort} | |||
recent={frequentlyUsedEmojis} | |||
skin={skinTone} | |||
showPreview={false} | |||
backgroundImageFn={backgroundImageFn} | |||
emojiTooltip | |||
/> | |||
<ModifierPicker | |||
active={modifierOpen} | |||
modifier={skinTone} | |||
onOpen={this.handleModifierOpen} | |||
onClose={this.handleModifierClose} | |||
onChange={this.handleModifierChange} | |||
/> | |||
</div> | |||
); | |||
} | |||
} | |||
@injectIntl | |||
export default class EmojiPickerDropdown extends React.PureComponent { | |||
static propTypes = { | |||
custom_emojis: ImmutablePropTypes.list, | |||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), | |||
intl: PropTypes.object.isRequired, | |||
onPickEmoji: PropTypes.func.isRequired, | |||
onSkinTone: PropTypes.func.isRequired, | |||
skinTone: PropTypes.number.isRequired, | |||
}; | |||
state = { | |||
active: false, | |||
loading: false, | |||
}; | |||
setRef = (c) => { | |||
this.dropdown = c; | |||
} | |||
onShowDropdown = () => { | |||
this.setState({ active: true }); | |||
if (!EmojiPicker) { | |||
this.setState({ loading: true }); | |||
EmojiPickerAsync().then(EmojiMart => { | |||
EmojiPicker = EmojiMart.Picker; | |||
Emoji = EmojiMart.Emoji; | |||
this.setState({ loading: false }); | |||
}).catch(() => { | |||
this.setState({ loading: false }); | |||
}); | |||
} | |||
} | |||
onHideDropdown = () => { | |||
this.setState({ active: false }); | |||
} | |||
onToggle = (e) => { | |||
if (!this.state.loading && (!e.key || e.key === 'Enter')) { | |||
if (this.state.active) { | |||
this.onHideDropdown(); | |||
} else { | |||
this.onShowDropdown(); | |||
} | |||
} | |||
} | |||
handleKeyDown = e => { | |||
if (e.key === 'Escape') { | |||
this.onHideDropdown(); | |||
} | |||
} | |||
setTargetRef = c => { | |||
this.target = c; | |||
} | |||
findTarget = () => { | |||
return this.target; | |||
} | |||
render () { | |||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; | |||
const title = intl.formatMessage(messages.emoji); | |||
const { active, loading } = this.state; | |||
return ( | |||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> | |||
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> | |||
<img | |||
className={classNames('emojione', { 'pulse-loading': active && loading })} | |||
alt='🙂' | |||
src={`${assetHost}/emoji/1f602.svg`} | |||
/> | |||
</div> | |||
<Overlay show={active} placement='bottom' target={this.findTarget}> | |||
<EmojiPickerMenu | |||
custom_emojis={this.props.custom_emojis} | |||
loading={loading} | |||
onClose={this.onHideDropdown} | |||
onPick={onPickEmoji} | |||
onSkinTone={onSkinTone} | |||
skinTone={skinTone} | |||
frequentlyUsedEmojis={frequentlyUsedEmojis} | |||
/> | |||
</Overlay> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,38 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import Avatar from '../../../components/avatar'; | |||
import IconButton from '../../../components/icon_button'; | |||
import Permalink from '../../../components/permalink'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
export default class NavigationBar extends ImmutablePureComponent { | |||
static propTypes = { | |||
account: ImmutablePropTypes.map.isRequired, | |||
onClose: PropTypes.func.isRequired, | |||
}; | |||
render () { | |||
return ( | |||
<div className='navigation-bar'> | |||
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> | |||
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> | |||
<Avatar account={this.props.account} size={40} /> | |||
</Permalink> | |||
<div className='navigation-bar__profile'> | |||
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> | |||
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong> | |||
</Permalink> | |||
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> | |||
</div> | |||
<IconButton title='' icon='close' onClick={this.props.onClose} /> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,200 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import { injectIntl, defineMessages } from 'react-intl'; | |||
import IconButton from '../../../components/icon_button'; | |||
import Overlay from 'react-overlays/lib/Overlay'; | |||
import Motion from '../../ui/util/optional_motion'; | |||
import spring from 'react-motion/lib/spring'; | |||
import detectPassiveEvents from 'detect-passive-events'; | |||
import classNames from 'classnames'; | |||
const messages = defineMessages({ | |||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, | |||
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, | |||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, | |||
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, | |||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, | |||
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, | |||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, | |||
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, | |||
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, | |||
}); | |||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; | |||
class PrivacyDropdownMenu extends React.PureComponent { | |||
static propTypes = { | |||
style: PropTypes.object, | |||
items: PropTypes.array.isRequired, | |||
value: PropTypes.string.isRequired, | |||
onClose: PropTypes.func.isRequired, | |||
onChange: PropTypes.func.isRequired, | |||
}; | |||
handleDocumentClick = e => { | |||
if (this.node && !this.node.contains(e.target)) { | |||
this.props.onClose(); | |||
} | |||
} | |||
handleClick = e => { | |||
if (e.key === 'Escape') { | |||
this.props.onClose(); | |||
} else if (!e.key || e.key === 'Enter') { | |||
const value = e.currentTarget.getAttribute('data-index'); | |||
e.preventDefault(); | |||
this.props.onClose(); | |||
this.props.onChange(value); | |||
} | |||
} | |||
componentDidMount () { | |||
document.addEventListener('click', this.handleDocumentClick, false); | |||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); | |||
} | |||
componentWillUnmount () { | |||
document.removeEventListener('click', this.handleDocumentClick, false); | |||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); | |||
} | |||
setRef = c => { | |||
this.node = c; | |||
} | |||
render () { | |||
const { style, items, value } = this.props; | |||
return ( | |||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> | |||
{({ opacity, scaleX, scaleY }) => ( | |||
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> | |||
{items.map(item => | |||
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}> | |||
<div className='privacy-dropdown__option__icon'> | |||
<i className={`fa fa-fw fa-${item.icon}`} /> | |||
</div> | |||
<div className='privacy-dropdown__option__content'> | |||
<strong>{item.text}</strong> | |||
{item.meta} | |||
</div> | |||
</div> | |||
)} | |||
</div> | |||
)} | |||
</Motion> | |||
); | |||
} | |||
} | |||
@injectIntl | |||
export default class PrivacyDropdown extends React.PureComponent { | |||
static propTypes = { | |||
isUserTouching: PropTypes.func, | |||
isModalOpen: PropTypes.bool.isRequired, | |||
onModalOpen: PropTypes.func, | |||
onModalClose: PropTypes.func, | |||
value: PropTypes.string.isRequired, | |||
onChange: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
state = { | |||
open: false, | |||
}; | |||
handleToggle = () => { | |||
if (this.props.isUserTouching()) { | |||
if (this.state.open) { | |||
this.props.onModalClose(); | |||
} else { | |||
this.props.onModalOpen({ | |||
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), | |||
onClick: this.handleModalActionClick, | |||
}); | |||
} | |||
} else { | |||
this.setState({ open: !this.state.open }); | |||
} | |||
} | |||
handleModalActionClick = (e) => { | |||
e.preventDefault(); | |||
const { value } = this.options[e.currentTarget.getAttribute('data-index')]; | |||
this.props.onModalClose(); | |||
this.props.onChange(value); | |||
} | |||
handleKeyDown = e => { | |||
switch(e.key) { | |||
case 'Enter': | |||
this.handleToggle(); | |||
break; | |||
case 'Escape': | |||
this.handleClose(); | |||
break; | |||
} | |||
} | |||
handleClose = () => { | |||
this.setState({ open: false }); | |||
} | |||
handleChange = value => { | |||
this.props.onChange(value); | |||
} | |||
componentWillMount () { | |||
const { intl: { formatMessage } } = this.props; | |||
this.options = [ | |||
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, | |||
{ icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, | |||
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, | |||
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, | |||
]; | |||
} | |||
render () { | |||
const { value, intl } = this.props; | |||
const { open } = this.state; | |||
const valueOption = this.options.find(item => item.value === value); | |||
return ( | |||
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}> | |||
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}> | |||
<IconButton | |||
className='privacy-dropdown__value-icon' | |||
icon={valueOption.icon} | |||
title={intl.formatMessage(messages.change_privacy)} | |||
size={18} | |||
expanded={open} | |||
active={open} | |||
inverted | |||
onClick={this.handleToggle} | |||
style={{ height: null, lineHeight: '27px' }} | |||
/> | |||
</div> | |||
<Overlay show={open} placement='bottom' target={this}> | |||
<PrivacyDropdownMenu | |||
items={this.options} | |||
value={value} | |||
onClose={this.handleClose} | |||
onChange={this.handleChange} | |||
/> | |||
</Overlay> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,63 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import Avatar from '../../../components/avatar'; | |||
import IconButton from '../../../components/icon_button'; | |||
import DisplayName from '../../../components/display_name'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
const messages = defineMessages({ | |||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, | |||
}); | |||
@injectIntl | |||
export default class ReplyIndicator extends ImmutablePureComponent { | |||
static contextTypes = { | |||
router: PropTypes.object, | |||
}; | |||
static propTypes = { | |||
status: ImmutablePropTypes.map, | |||
onCancel: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
handleClick = () => { | |||
this.props.onCancel(); | |||
} | |||
handleAccountClick = (e) => { | |||
if (e.button === 0) { | |||
e.preventDefault(); | |||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); | |||
} | |||
} | |||
render () { | |||
const { status, intl } = this.props; | |||
if (!status) { | |||
return null; | |||
} | |||
const content = { __html: status.get('contentHtml') }; | |||
return ( | |||
<div className='reply-indicator'> | |||
<div className='reply-indicator__header'> | |||
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> | |||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'> | |||
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div> | |||
<DisplayName account={status.get('account')} /> | |||
</a> | |||
</div> | |||
<div className='reply-indicator__content' dangerouslySetInnerHTML={content} /> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,129 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import Overlay from 'react-overlays/lib/Overlay'; | |||
import Motion from '../../ui/util/optional_motion'; | |||
import spring from 'react-motion/lib/spring'; | |||
const messages = defineMessages({ | |||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, | |||
}); | |||
class SearchPopout extends React.PureComponent { | |||
static propTypes = { | |||
style: PropTypes.object, | |||
}; | |||
render () { | |||
const { style } = this.props; | |||
return ( | |||
<div style={{ ...style, position: 'absolute', width: 285 }}> | |||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> | |||
{({ opacity, scaleX, scaleY }) => ( | |||
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> | |||
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4> | |||
<ul> | |||
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li> | |||
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> | |||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> | |||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li> | |||
</ul> | |||
<FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' /> | |||
</div> | |||
)} | |||
</Motion> | |||
</div> | |||
); | |||
} | |||
} | |||
@injectIntl | |||
export default class Search extends React.PureComponent { | |||
static propTypes = { | |||
value: PropTypes.string.isRequired, | |||
submitted: PropTypes.bool, | |||
onChange: PropTypes.func.isRequired, | |||
onSubmit: PropTypes.func.isRequired, | |||
onClear: PropTypes.func.isRequired, | |||
onShow: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
state = { | |||
expanded: false, | |||
}; | |||
handleChange = (e) => { | |||
this.props.onChange(e.target.value); | |||
} | |||
handleClear = (e) => { | |||
e.preventDefault(); | |||
if (this.props.value.length > 0 || this.props.submitted) { | |||
this.props.onClear(); | |||
} | |||
} | |||
handleKeyDown = (e) => { | |||
if (e.key === 'Enter') { | |||
e.preventDefault(); | |||
this.props.onSubmit(); | |||
} else if (e.key === 'Escape') { | |||
document.querySelector('.ui').parentElement.focus(); | |||
} | |||
} | |||
noop () { | |||
} | |||
handleFocus = () => { | |||
this.setState({ expanded: true }); | |||
this.props.onShow(); | |||
} | |||
handleBlur = () => { | |||
this.setState({ expanded: false }); | |||
} | |||
render () { | |||
const { intl, value, submitted } = this.props; | |||
const { expanded } = this.state; | |||
const hasValue = value.length > 0 || submitted; | |||
return ( | |||
<div className='search'> | |||
<label> | |||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span> | |||
<input | |||
className='search__input' | |||
type='text' | |||
placeholder={intl.formatMessage(messages.placeholder)} | |||
value={value} | |||
onChange={this.handleChange} | |||
onKeyUp={this.handleKeyDown} | |||
onFocus={this.handleFocus} | |||
onBlur={this.handleBlur} | |||
/> | |||
</label> | |||
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}> | |||
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> | |||
<i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} /> | |||
</div> | |||
<Overlay show={expanded && !hasValue} placement='bottom' target={this}> | |||
<SearchPopout /> | |||
</Overlay> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,65 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import AccountContainer from '../../../containers/account_container'; | |||
import StatusContainer from '../../../containers/status_container'; | |||
import { Link } from 'react-router-dom'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
export default class SearchResults extends ImmutablePureComponent { | |||
static propTypes = { | |||
results: ImmutablePropTypes.map.isRequired, | |||
}; | |||
render () { | |||
const { results } = this.props; | |||
let accounts, statuses, hashtags; | |||
let count = 0; | |||
if (results.get('accounts') && results.get('accounts').size > 0) { | |||
count += results.get('accounts').size; | |||
accounts = ( | |||
<div className='search-results__section'> | |||
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} | |||
</div> | |||
); | |||
} | |||
if (results.get('statuses') && results.get('statuses').size > 0) { | |||
count += results.get('statuses').size; | |||
statuses = ( | |||
<div className='search-results__section'> | |||
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} | |||
</div> | |||
); | |||
} | |||
if (results.get('hashtags') && results.get('hashtags').size > 0) { | |||
count += results.get('hashtags').size; | |||
hashtags = ( | |||
<div className='search-results__section'> | |||
{results.get('hashtags').map(hashtag => | |||
<Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}> | |||
#{hashtag} | |||
</Link> | |||
)} | |||
</div> | |||
); | |||
} | |||
return ( | |||
<div className='search-results'> | |||
<div className='search-results__header'> | |||
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} /> | |||
</div> | |||
{accounts} | |||
{statuses} | |||
{hashtags} | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,29 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
export default class TextIconButton extends React.PureComponent { | |||
static propTypes = { | |||
label: PropTypes.string.isRequired, | |||
title: PropTypes.string, | |||
active: PropTypes.bool, | |||
onClick: PropTypes.func.isRequired, | |||
ariaControls: PropTypes.string, | |||
}; | |||
handleClick = (e) => { | |||
e.preventDefault(); | |||
this.props.onClick(); | |||
} | |||
render () { | |||
const { label, title, active, ariaControls } = this.props; | |||
return ( | |||
<button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> | |||
{label} | |||
</button> | |||
); | |||
} | |||
} |
@ -0,0 +1,96 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import IconButton from '../../../components/icon_button'; | |||
import Motion from '../../ui/util/optional_motion'; | |||
import spring from 'react-motion/lib/spring'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import classNames from 'classnames'; | |||
const messages = defineMessages({ | |||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, | |||
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, | |||
}); | |||
@injectIntl | |||
export default class Upload extends ImmutablePureComponent { | |||
static propTypes = { | |||
media: ImmutablePropTypes.map.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
onUndo: PropTypes.func.isRequired, | |||
onDescriptionChange: PropTypes.func.isRequired, | |||
}; | |||
state = { | |||
hovered: false, | |||
focused: false, | |||
dirtyDescription: null, | |||
}; | |||
handleUndoClick = () => { | |||
this.props.onUndo(this.props.media.get('id')); | |||
} | |||
handleInputChange = e => { | |||
this.setState({ dirtyDescription: e.target.value }); | |||
} | |||
handleMouseEnter = () => { | |||
this.setState({ hovered: true }); | |||
} | |||
handleMouseLeave = () => { | |||
this.setState({ hovered: false }); | |||
} | |||
handleInputFocus = () => { | |||
this.setState({ focused: true }); | |||
} | |||
handleInputBlur = () => { | |||
const { dirtyDescription } = this.state; | |||
this.setState({ focused: false, dirtyDescription: null }); | |||
if (dirtyDescription !== null) { | |||
this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); | |||
} | |||
} | |||
render () { | |||
const { intl, media } = this.props; | |||
const active = this.state.hovered || this.state.focused; | |||
const description = this.state.dirtyDescription || media.get('description') || ''; | |||
return ( | |||
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> | |||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> | |||
{({ scale }) => ( | |||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}> | |||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} /> | |||
<div className={classNames('compose-form__upload-description', { active })}> | |||
<label> | |||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> | |||
<input | |||
placeholder={intl.formatMessage(messages.description)} | |||
type='text' | |||
value={description} | |||
maxLength={420} | |||
onFocus={this.handleInputFocus} | |||
onChange={this.handleInputChange} | |||
onBlur={this.handleInputBlur} | |||
/> | |||
</label> | |||
</div> | |||
</div> | |||
)} | |||
</Motion> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,77 @@ | |||
import React from 'react'; | |||
import IconButton from '../../../components/icon_button'; | |||
import PropTypes from 'prop-types'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import { connect } from 'react-redux'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
const messages = defineMessages({ | |||
upload: { id: 'upload_button.label', defaultMessage: 'Add media' }, | |||
}); | |||
const makeMapStateToProps = () => { | |||
const mapStateToProps = state => ({ | |||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), | |||
}); | |||
return mapStateToProps; | |||
}; | |||
const iconStyle = { | |||
height: null, | |||
lineHeight: '27px', | |||
}; | |||
@connect(makeMapStateToProps) | |||
@injectIntl | |||
export default class UploadButton extends ImmutablePureComponent { | |||
static propTypes = { | |||
disabled: PropTypes.bool, | |||
onSelectFile: PropTypes.func.isRequired, | |||
style: PropTypes.object, | |||
resetFileKey: PropTypes.number, | |||
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
handleChange = (e) => { | |||
if (e.target.files.length > 0) { | |||
this.props.onSelectFile(e.target.files); | |||
} | |||
} | |||
handleClick = () => { | |||
this.fileElement.click(); | |||
} | |||
setRef = (c) => { | |||
this.fileElement = c; | |||
} | |||
render () { | |||
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; | |||
return ( | |||
<div className='compose-form__upload-button'> | |||
<IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} /> | |||
<label> | |||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span> | |||
<input | |||
key={resetFileKey} | |||
ref={this.setRef} | |||
type='file' | |||
multiple={false} | |||
accept={acceptContentTypes.toArray().join(',')} | |||
onChange={this.handleChange} | |||
disabled={disabled} | |||
style={{ display: 'none' }} | |||
/> | |||
</label> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,29 @@ | |||
import React from 'react'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import UploadProgressContainer from '../containers/upload_progress_container'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import UploadContainer from '../containers/upload_container'; | |||
export default class UploadForm extends ImmutablePureComponent { | |||
static propTypes = { | |||
mediaIds: ImmutablePropTypes.list.isRequired, | |||
}; | |||
render () { | |||
const { mediaIds } = this.props; | |||
return ( | |||
<div className='compose-form__upload-wrapper'> | |||
<UploadProgressContainer /> | |||
<div className='compose-form__uploads-wrapper'> | |||
{mediaIds.map(id => ( | |||
<UploadContainer id={id} key={id} /> | |||
))} | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@ -0,0 +1,42 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import Motion from '../../ui/util/optional_motion'; | |||
import spring from 'react-motion/lib/spring'; | |||
import { FormattedMessage } from 'react-intl'; | |||
export default class UploadProgress extends React.PureComponent { | |||
static propTypes = { | |||
active: PropTypes.bool, | |||
progress: PropTypes.number, | |||
}; | |||
render () { | |||
const { active, progress } = this.props; | |||
if (!active) { | |||
return null; | |||
} | |||
return ( | |||
<div className='upload-progress'> | |||
<div className='upload-progress__icon'> | |||
<i className='fa fa-upload' /> | |||
</div> | |||
<div className='upload-progress__message'> | |||
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> | |||
<div className='upload-progress__backdrop'> | |||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> | |||
{({ width }) => | |||
<div className='upload-progress__tracker' style={{ width: `${width}%` }} /> | |||
} | |||
</Motion> | |||
</div> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |