mirror of https://github.com/tootsuite/mastodon
				
				
				
			Restore vanilla components
							parent
							
								
									45c44989c8
								
							
						
					
					
						commit
						e19fc6a9f8
					
				|  | @ -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
	
	 kibigo!
						kibigo!