parent
45c44989c8
commit
e19fc6a9f8
272 changed files with 27052 additions and 20 deletions
@ -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> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue