parent
45c44989c8
commit
e19fc6a9f8
272 changed files with 27052 additions and 20 deletions
@ -0,0 +1,659 @@ |
||||
import api, { getLinks } from '../api'; |
||||
|
||||
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; |
||||
export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; |
||||
export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; |
||||
|
||||
export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; |
||||
export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; |
||||
export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; |
||||
|
||||
export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; |
||||
export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; |
||||
export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; |
||||
|
||||
export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; |
||||
export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; |
||||
export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; |
||||
|
||||
export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; |
||||
export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; |
||||
export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; |
||||
|
||||
export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; |
||||
export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; |
||||
export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; |
||||
|
||||
export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; |
||||
export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; |
||||
export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; |
||||
|
||||
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; |
||||
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; |
||||
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; |
||||
|
||||
export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST'; |
||||
export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS'; |
||||
export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL'; |
||||
|
||||
export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; |
||||
export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; |
||||
export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; |
||||
|
||||
export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; |
||||
export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; |
||||
export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; |
||||
|
||||
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; |
||||
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; |
||||
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; |
||||
|
||||
export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; |
||||
export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; |
||||
export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; |
||||
|
||||
export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; |
||||
export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; |
||||
export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; |
||||
|
||||
export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; |
||||
export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; |
||||
export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; |
||||
|
||||
export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; |
||||
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; |
||||
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; |
||||
|
||||
export function fetchAccount(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchRelationships([id])); |
||||
|
||||
if (getState().getIn(['accounts', id], null) !== null) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(fetchAccountRequest(id)); |
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}`).then(response => { |
||||
dispatch(fetchAccountSuccess(response.data)); |
||||
}).catch(error => { |
||||
dispatch(fetchAccountFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchAccountRequest(id) { |
||||
return { |
||||
type: ACCOUNT_FETCH_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchAccountSuccess(account) { |
||||
return { |
||||
type: ACCOUNT_FETCH_SUCCESS, |
||||
account, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchAccountFail(id, error) { |
||||
return { |
||||
type: ACCOUNT_FETCH_FAIL, |
||||
id, |
||||
error, |
||||
skipAlert: true, |
||||
}; |
||||
}; |
||||
|
||||
export function followAccount(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(followAccountRequest(id)); |
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => { |
||||
dispatch(followAccountSuccess(response.data)); |
||||
}).catch(error => { |
||||
dispatch(followAccountFail(error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function unfollowAccount(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(unfollowAccountRequest(id)); |
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { |
||||
dispatch(unfollowAccountSuccess(response.data, getState().get('statuses'))); |
||||
}).catch(error => { |
||||
dispatch(unfollowAccountFail(error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function followAccountRequest(id) { |
||||
return { |
||||
type: ACCOUNT_FOLLOW_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function followAccountSuccess(relationship) { |
||||
return { |
||||
type: ACCOUNT_FOLLOW_SUCCESS, |
||||
relationship, |
||||
}; |
||||
}; |
||||
|
||||
export function followAccountFail(error) { |
||||
return { |
||||
type: ACCOUNT_FOLLOW_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function unfollowAccountRequest(id) { |
||||
return { |
||||
type: ACCOUNT_UNFOLLOW_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function unfollowAccountSuccess(relationship, statuses) { |
||||
return { |
||||
type: ACCOUNT_UNFOLLOW_SUCCESS, |
||||
relationship, |
||||
statuses, |
||||
}; |
||||
}; |
||||
|
||||
export function unfollowAccountFail(error) { |
||||
return { |
||||
type: ACCOUNT_UNFOLLOW_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function blockAccount(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(blockAccountRequest(id)); |
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { |
||||
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
|
||||
dispatch(blockAccountSuccess(response.data, getState().get('statuses'))); |
||||
}).catch(error => { |
||||
dispatch(blockAccountFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function unblockAccount(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(unblockAccountRequest(id)); |
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => { |
||||
dispatch(unblockAccountSuccess(response.data)); |
||||
}).catch(error => { |
||||
dispatch(unblockAccountFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function blockAccountRequest(id) { |
||||
return { |
||||
type: ACCOUNT_BLOCK_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function blockAccountSuccess(relationship, statuses) { |
||||
return { |
||||
type: ACCOUNT_BLOCK_SUCCESS, |
||||
relationship, |
||||
statuses, |
||||
}; |
||||
}; |
||||
|
||||
export function blockAccountFail(error) { |
||||
return { |
||||
type: ACCOUNT_BLOCK_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function unblockAccountRequest(id) { |
||||
return { |
||||
type: ACCOUNT_UNBLOCK_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function unblockAccountSuccess(relationship) { |
||||
return { |
||||
type: ACCOUNT_UNBLOCK_SUCCESS, |
||||
relationship, |
||||
}; |
||||
}; |
||||
|
||||
export function unblockAccountFail(error) { |
||||
return { |
||||
type: ACCOUNT_UNBLOCK_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
|
||||
export function muteAccount(id, notifications) { |
||||
return (dispatch, getState) => { |
||||
dispatch(muteAccountRequest(id)); |
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { |
||||
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
|
||||
dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); |
||||
}).catch(error => { |
||||
dispatch(muteAccountFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function unmuteAccount(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(unmuteAccountRequest(id)); |
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { |
||||
dispatch(unmuteAccountSuccess(response.data)); |
||||
}).catch(error => { |
||||
dispatch(unmuteAccountFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function muteAccountRequest(id) { |
||||
return { |
||||
type: ACCOUNT_MUTE_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function muteAccountSuccess(relationship, statuses) { |
||||
return { |
||||
type: ACCOUNT_MUTE_SUCCESS, |
||||
relationship, |
||||
statuses, |
||||
}; |
||||
}; |
||||
|
||||
export function muteAccountFail(error) { |
||||
return { |
||||
type: ACCOUNT_MUTE_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function unmuteAccountRequest(id) { |
||||
return { |
||||
type: ACCOUNT_UNMUTE_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function unmuteAccountSuccess(relationship) { |
||||
return { |
||||
type: ACCOUNT_UNMUTE_SUCCESS, |
||||
relationship, |
||||
}; |
||||
}; |
||||
|
||||
export function unmuteAccountFail(error) { |
||||
return { |
||||
type: ACCOUNT_UNMUTE_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
|
||||
export function fetchFollowers(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchFollowersRequest(id)); |
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
|
||||
dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); |
||||
dispatch(fetchRelationships(response.data.map(item => item.id))); |
||||
}).catch(error => { |
||||
dispatch(fetchFollowersFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowersRequest(id) { |
||||
return { |
||||
type: FOLLOWERS_FETCH_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowersSuccess(id, accounts, next) { |
||||
return { |
||||
type: FOLLOWERS_FETCH_SUCCESS, |
||||
id, |
||||
accounts, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowersFail(id, error) { |
||||
return { |
||||
type: FOLLOWERS_FETCH_FAIL, |
||||
id, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowers(id) { |
||||
return (dispatch, getState) => { |
||||
const url = getState().getIn(['user_lists', 'followers', id, 'next']); |
||||
|
||||
if (url === null) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(expandFollowersRequest(id)); |
||||
|
||||
api(getState).get(url).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
|
||||
dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); |
||||
dispatch(fetchRelationships(response.data.map(item => item.id))); |
||||
}).catch(error => { |
||||
dispatch(expandFollowersFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowersRequest(id) { |
||||
return { |
||||
type: FOLLOWERS_EXPAND_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowersSuccess(id, accounts, next) { |
||||
return { |
||||
type: FOLLOWERS_EXPAND_SUCCESS, |
||||
id, |
||||
accounts, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowersFail(id, error) { |
||||
return { |
||||
type: FOLLOWERS_EXPAND_FAIL, |
||||
id, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowing(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchFollowingRequest(id)); |
||||
|
||||
api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
|
||||
dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); |
||||
dispatch(fetchRelationships(response.data.map(item => item.id))); |
||||
}).catch(error => { |
||||
dispatch(fetchFollowingFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowingRequest(id) { |
||||
return { |
||||
type: FOLLOWING_FETCH_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowingSuccess(id, accounts, next) { |
||||
return { |
||||
type: FOLLOWING_FETCH_SUCCESS, |
||||
id, |
||||
accounts, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowingFail(id, error) { |
||||
return { |
||||
type: FOLLOWING_FETCH_FAIL, |
||||
id, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowing(id) { |
||||
return (dispatch, getState) => { |
||||
const url = getState().getIn(['user_lists', 'following', id, 'next']); |
||||
|
||||
if (url === null) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(expandFollowingRequest(id)); |
||||
|
||||
api(getState).get(url).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
|
||||
dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); |
||||
dispatch(fetchRelationships(response.data.map(item => item.id))); |
||||
}).catch(error => { |
||||
dispatch(expandFollowingFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowingRequest(id) { |
||||
return { |
||||
type: FOLLOWING_EXPAND_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowingSuccess(id, accounts, next) { |
||||
return { |
||||
type: FOLLOWING_EXPAND_SUCCESS, |
||||
id, |
||||
accounts, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowingFail(id, error) { |
||||
return { |
||||
type: FOLLOWING_EXPAND_FAIL, |
||||
id, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchRelationships(accountIds) { |
||||
return (dispatch, getState) => { |
||||
const loadedRelationships = getState().get('relationships'); |
||||
const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); |
||||
|
||||
if (newAccountIds.length === 0) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(fetchRelationshipsRequest(newAccountIds)); |
||||
|
||||
api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { |
||||
dispatch(fetchRelationshipsSuccess(response.data)); |
||||
}).catch(error => { |
||||
dispatch(fetchRelationshipsFail(error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchRelationshipsRequest(ids) { |
||||
return { |
||||
type: RELATIONSHIPS_FETCH_REQUEST, |
||||
ids, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchRelationshipsSuccess(relationships) { |
||||
return { |
||||
type: RELATIONSHIPS_FETCH_SUCCESS, |
||||
relationships, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchRelationshipsFail(error) { |
||||
return { |
||||
type: RELATIONSHIPS_FETCH_FAIL, |
||||
error, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowRequests() { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchFollowRequestsRequest()); |
||||
|
||||
api(getState).get('/api/v1/follow_requests').then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)); |
||||
}).catch(error => dispatch(fetchFollowRequestsFail(error))); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowRequestsRequest() { |
||||
return { |
||||
type: FOLLOW_REQUESTS_FETCH_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowRequestsSuccess(accounts, next) { |
||||
return { |
||||
type: FOLLOW_REQUESTS_FETCH_SUCCESS, |
||||
accounts, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFollowRequestsFail(error) { |
||||
return { |
||||
type: FOLLOW_REQUESTS_FETCH_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowRequests() { |
||||
return (dispatch, getState) => { |
||||
const url = getState().getIn(['user_lists', 'follow_requests', 'next']); |
||||
|
||||
if (url === null) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(expandFollowRequestsRequest()); |
||||
|
||||
api(getState).get(url).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)); |
||||
}).catch(error => dispatch(expandFollowRequestsFail(error))); |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowRequestsRequest() { |
||||
return { |
||||
type: FOLLOW_REQUESTS_EXPAND_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowRequestsSuccess(accounts, next) { |
||||
return { |
||||
type: FOLLOW_REQUESTS_EXPAND_SUCCESS, |
||||
accounts, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFollowRequestsFail(error) { |
||||
return { |
||||
type: FOLLOW_REQUESTS_EXPAND_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function authorizeFollowRequest(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(authorizeFollowRequestRequest(id)); |
||||
|
||||
api(getState) |
||||
.post(`/api/v1/follow_requests/${id}/authorize`) |
||||
.then(() => dispatch(authorizeFollowRequestSuccess(id))) |
||||
.catch(error => dispatch(authorizeFollowRequestFail(id, error))); |
||||
}; |
||||
}; |
||||
|
||||
export function authorizeFollowRequestRequest(id) { |
||||
return { |
||||
type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function authorizeFollowRequestSuccess(id) { |
||||
return { |
||||
type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function authorizeFollowRequestFail(id, error) { |
||||
return { |
||||
type: FOLLOW_REQUEST_AUTHORIZE_FAIL, |
||||
id, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
|
||||
export function rejectFollowRequest(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(rejectFollowRequestRequest(id)); |
||||
|
||||
api(getState) |
||||
.post(`/api/v1/follow_requests/${id}/reject`) |
||||
.then(() => dispatch(rejectFollowRequestSuccess(id))) |
||||
.catch(error => dispatch(rejectFollowRequestFail(id, error))); |
||||
}; |
||||
}; |
||||
|
||||
export function rejectFollowRequestRequest(id) { |
||||
return { |
||||
type: FOLLOW_REQUEST_REJECT_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function rejectFollowRequestSuccess(id) { |
||||
return { |
||||
type: FOLLOW_REQUEST_REJECT_SUCCESS, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function rejectFollowRequestFail(id, error) { |
||||
return { |
||||
type: FOLLOW_REQUEST_REJECT_FAIL, |
||||
id, |
||||
error, |
||||
}; |
||||
}; |
@ -0,0 +1,24 @@ |
||||
export const ALERT_SHOW = 'ALERT_SHOW'; |
||||
export const ALERT_DISMISS = 'ALERT_DISMISS'; |
||||
export const ALERT_CLEAR = 'ALERT_CLEAR'; |
||||
|
||||
export function dismissAlert(alert) { |
||||
return { |
||||
type: ALERT_DISMISS, |
||||
alert, |
||||
}; |
||||
}; |
||||
|
||||
export function clearAlert() { |
||||
return { |
||||
type: ALERT_CLEAR, |
||||
}; |
||||
}; |
||||
|
||||
export function showAlert(title, message) { |
||||
return { |
||||
type: ALERT_SHOW, |
||||
title, |
||||
message, |
||||
}; |
||||
}; |
@ -0,0 +1,82 @@ |
||||
import api, { getLinks } from '../api'; |
||||
import { fetchRelationships } from './accounts'; |
||||
|
||||
export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; |
||||
export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; |
||||
export const BLOCKS_FETCH_FAIL = 'BLOCKS_FETCH_FAIL'; |
||||
|
||||
export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; |
||||
export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; |
||||
export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; |
||||
|
||||
export function fetchBlocks() { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchBlocksRequest()); |
||||
|
||||
api(getState).get('/api/v1/blocks').then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null)); |
||||
dispatch(fetchRelationships(response.data.map(item => item.id))); |
||||
}).catch(error => dispatch(fetchBlocksFail(error))); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchBlocksRequest() { |
||||
return { |
||||
type: BLOCKS_FETCH_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchBlocksSuccess(accounts, next) { |
||||
return { |
||||
type: BLOCKS_FETCH_SUCCESS, |
||||
accounts, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchBlocksFail(error) { |
||||
return { |
||||
type: BLOCKS_FETCH_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function expandBlocks() { |
||||
return (dispatch, getState) => { |
||||
const url = getState().getIn(['user_lists', 'blocks', 'next']); |
||||
|
||||
if (url === null) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(expandBlocksRequest()); |
||||
|
||||
api(getState).get(url).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(expandBlocksSuccess(response.data, next ? next.uri : null)); |
||||
dispatch(fetchRelationships(response.data.map(item => item.id))); |
||||
}).catch(error => dispatch(expandBlocksFail(error))); |
||||
}; |
||||
}; |
||||
|
||||
export function expandBlocksRequest() { |
||||
return { |
||||
type: BLOCKS_EXPAND_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function expandBlocksSuccess(accounts, next) { |
||||
return { |
||||
type: BLOCKS_EXPAND_SUCCESS, |
||||
accounts, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function expandBlocksFail(error) { |
||||
return { |
||||
type: BLOCKS_EXPAND_FAIL, |
||||
error, |
||||
}; |
||||
}; |
@ -0,0 +1,25 @@ |
||||
export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; |
||||
export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; |
||||
export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; |
||||
|
||||
export function fetchBundleRequest(skipLoading) { |
||||
return { |
||||
type: BUNDLE_FETCH_REQUEST, |
||||
skipLoading, |
||||
}; |
||||
} |
||||
|
||||
export function fetchBundleSuccess(skipLoading) { |
||||
return { |
||||
type: BUNDLE_FETCH_SUCCESS, |
||||
skipLoading, |
||||
}; |
||||
} |
||||
|
||||
export function fetchBundleFail(error, skipLoading) { |
||||
return { |
||||
type: BUNDLE_FETCH_FAIL, |
||||
error, |
||||
skipLoading, |
||||
}; |
||||
} |
@ -0,0 +1,52 @@ |
||||
import api from '../api'; |
||||
|
||||
export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST'; |
||||
export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS'; |
||||
export const STATUS_CARD_FETCH_FAIL = 'STATUS_CARD_FETCH_FAIL'; |
||||
|
||||
export function fetchStatusCard(id) { |
||||
return (dispatch, getState) => { |
||||
if (getState().getIn(['cards', id], null) !== null) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(fetchStatusCardRequest(id)); |
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/card`).then(response => { |
||||
if (!response.data.url) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(fetchStatusCardSuccess(id, response.data)); |
||||
}).catch(error => { |
||||
dispatch(fetchStatusCardFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchStatusCardRequest(id) { |
||||
return { |
||||
type: STATUS_CARD_FETCH_REQUEST, |
||||
id, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchStatusCardSuccess(id, card) { |
||||
return { |
||||
type: STATUS_CARD_FETCH_SUCCESS, |
||||
id, |
||||
card, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchStatusCardFail(id, error) { |
||||
return { |
||||
type: STATUS_CARD_FETCH_FAIL, |
||||
id, |
||||
error, |
||||
skipLoading: true, |
||||
skipAlert: true, |
||||
}; |
||||
}; |
@ -0,0 +1,40 @@ |
||||
import { saveSettings } from './settings'; |
||||
|
||||
export const COLUMN_ADD = 'COLUMN_ADD'; |
||||
export const COLUMN_REMOVE = 'COLUMN_REMOVE'; |
||||
export const COLUMN_MOVE = 'COLUMN_MOVE'; |
||||
|
||||
export function addColumn(id, params) { |
||||
return dispatch => { |
||||
dispatch({ |
||||
type: COLUMN_ADD, |
||||
id, |
||||
params, |
||||
}); |
||||
|
||||
dispatch(saveSettings()); |
||||
}; |
||||
}; |
||||
|
||||
export function removeColumn(uuid) { |
||||
return dispatch => { |
||||
dispatch({ |
||||
type: COLUMN_REMOVE, |
||||
uuid, |
||||
}); |
||||
|
||||
dispatch(saveSettings()); |
||||
}; |
||||
}; |
||||
|
||||
export function moveColumn(uuid, direction) { |
||||
return dispatch => { |
||||
dispatch({ |
||||
type: COLUMN_MOVE, |
||||
uuid, |
||||
direction, |
||||
}); |
||||
|
||||
dispatch(saveSettings()); |
||||
}; |
||||
}; |
@ -0,0 +1,376 @@ |
||||
import api from '../api'; |
||||
import { throttle } from 'lodash'; |
||||
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; |
||||
import { useEmoji } from './emojis'; |
||||
|
||||
import { |
||||
updateTimeline, |
||||
refreshHomeTimeline, |
||||
refreshCommunityTimeline, |
||||
refreshPublicTimeline, |
||||
} from './timelines'; |
||||
|
||||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; |
||||
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; |
||||
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; |
||||
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; |
||||
export const COMPOSE_REPLY = 'COMPOSE_REPLY'; |
||||
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; |
||||
export const COMPOSE_MENTION = 'COMPOSE_MENTION'; |
||||
export const COMPOSE_RESET = 'COMPOSE_RESET'; |
||||
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; |
||||
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; |
||||
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; |
||||
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; |
||||
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; |
||||
|
||||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; |
||||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; |
||||
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; |
||||
|
||||
export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; |
||||
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; |
||||
|
||||
export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; |
||||
export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; |
||||
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; |
||||
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; |
||||
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; |
||||
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; |
||||
|
||||
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; |
||||
|
||||
export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; |
||||
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; |
||||
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; |
||||
|
||||
export function changeCompose(text) { |
||||
return { |
||||
type: COMPOSE_CHANGE, |
||||
text: text, |
||||
}; |
||||
}; |
||||
|
||||
export function replyCompose(status, router) { |
||||
return (dispatch, getState) => { |
||||
dispatch({ |
||||
type: COMPOSE_REPLY, |
||||
status: status, |
||||
}); |
||||
|
||||
if (!getState().getIn(['compose', 'mounted'])) { |
||||
router.push('/statuses/new'); |
||||
} |
||||
}; |
||||
}; |
||||
|
||||
export function cancelReplyCompose() { |
||||
return { |
||||
type: COMPOSE_REPLY_CANCEL, |
||||
}; |
||||
}; |
||||
|
||||
export function resetCompose() { |
||||
return { |
||||
type: COMPOSE_RESET, |
||||
}; |
||||
}; |
||||
|
||||
export function mentionCompose(account, router) { |
||||
return (dispatch, getState) => { |
||||
dispatch({ |
||||
type: COMPOSE_MENTION, |
||||
account: account, |
||||
}); |
||||
|
||||
if (!getState().getIn(['compose', 'mounted'])) { |
||||
router.push('/statuses/new'); |
||||
} |
||||
}; |
||||
}; |
||||
|
||||
export function submitCompose() { |
||||
return function (dispatch, getState) { |
||||
const status = getState().getIn(['compose', 'text'], ''); |
||||
|
||||
if (!status || !status.length) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(submitComposeRequest()); |
||||
|
||||
api(getState).post('/api/v1/statuses', { |
||||
status, |
||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), |
||||
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), |
||||
sensitive: getState().getIn(['compose', 'sensitive']), |
||||
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), |
||||
visibility: getState().getIn(['compose', 'privacy']), |
||||
}, { |
||||
headers: { |
||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), |
||||
}, |
||||
}).then(function (response) { |
||||
dispatch(submitComposeSuccess({ ...response.data })); |
||||
|
||||
// To make the app more responsive, immediately get the status into the columns
|
||||
|
||||
const insertOrRefresh = (timelineId, refreshAction) => { |
||||
if (getState().getIn(['timelines', timelineId, 'online'])) { |
||||
dispatch(updateTimeline(timelineId, { ...response.data })); |
||||
} else if (getState().getIn(['timelines', timelineId, 'loaded'])) { |
||||
dispatch(refreshAction()); |
||||
} |
||||
}; |
||||
|
||||
insertOrRefresh('home', refreshHomeTimeline); |
||||
|
||||
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { |
||||
insertOrRefresh('community', refreshCommunityTimeline); |
||||
insertOrRefresh('public', refreshPublicTimeline); |
||||
} |
||||
}).catch(function (error) { |
||||
dispatch(submitComposeFail(error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function submitComposeRequest() { |
||||
return { |
||||
type: COMPOSE_SUBMIT_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function submitComposeSuccess(status) { |
||||
return { |
||||
type: COMPOSE_SUBMIT_SUCCESS, |
||||
status: status, |
||||
}; |
||||
}; |
||||
|
||||
export function submitComposeFail(error) { |
||||
return { |
||||
type: COMPOSE_SUBMIT_FAIL, |
||||
error: error, |
||||
}; |
||||
}; |
||||
|
||||
export function uploadCompose(files) { |
||||
return function (dispatch, getState) { |
||||
if (getState().getIn(['compose', 'media_attachments']).size > 3) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(uploadComposeRequest()); |
||||
|
||||
let data = new FormData(); |
||||
data.append('file', files[0]); |
||||
|
||||
api(getState).post('/api/v1/media', data, { |
||||
onUploadProgress: function (e) { |
||||
dispatch(uploadComposeProgress(e.loaded, e.total)); |
||||
}, |
||||
}).then(function (response) { |
||||
dispatch(uploadComposeSuccess(response.data)); |
||||
}).catch(function (error) { |
||||
dispatch(uploadComposeFail(error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function changeUploadCompose(id, description) { |
||||
return (dispatch, getState) => { |
||||
dispatch(changeUploadComposeRequest()); |
||||
|
||||
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { |
||||
dispatch(changeUploadComposeSuccess(response.data)); |
||||
}).catch(error => { |
||||
dispatch(changeUploadComposeFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function changeUploadComposeRequest() { |
||||
return { |
||||
type: COMPOSE_UPLOAD_CHANGE_REQUEST, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
export function changeUploadComposeSuccess(media) { |
||||
return { |
||||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS, |
||||
media: media, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
|
||||
export function changeUploadComposeFail(error) { |
||||
return { |
||||
type: COMPOSE_UPLOAD_CHANGE_FAIL, |
||||
error: error, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
|
||||
export function uploadComposeRequest() { |
||||
return { |
||||
type: COMPOSE_UPLOAD_REQUEST, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
|
||||
export function uploadComposeProgress(loaded, total) { |
||||
return { |
||||
type: COMPOSE_UPLOAD_PROGRESS, |
||||
loaded: loaded, |
||||
total: total, |
||||
}; |
||||
}; |
||||
|
||||
export function uploadComposeSuccess(media) { |
||||
return { |
||||
type: COMPOSE_UPLOAD_SUCCESS, |
||||
media: media, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
|
||||
export function uploadComposeFail(error) { |
||||
return { |
||||
type: COMPOSE_UPLOAD_FAIL, |
||||
error: error, |
||||
skipLoading: true, |
||||
}; |
||||
}; |
||||
|
||||
export function undoUploadCompose(media_id) { |
||||
return { |
||||
type: COMPOSE_UPLOAD_UNDO, |
||||
media_id: media_id, |
||||
}; |
||||
}; |
||||
|
||||
export function clearComposeSuggestions() { |
||||
return { |
||||
type: COMPOSE_SUGGESTIONS_CLEAR, |
||||
}; |
||||
}; |
||||
|
||||
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { |
||||
api(getState).get('/api/v1/accounts/search', { |
||||
params: { |
||||
q: token.slice(1), |
||||
resolve: false, |
||||
limit: 4, |
||||
}, |
||||
}).then(response => { |
||||
dispatch(readyComposeSuggestionsAccounts(token, response.data)); |
||||
}); |
||||
}, 200, { leading: true, trailing: true }); |
||||
|
||||
const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { |
||||
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 }); |
||||
dispatch(readyComposeSuggestionsEmojis(token, results)); |
||||
}; |
||||
|
||||
export function fetchComposeSuggestions(token) { |
||||
return (dispatch, getState) => { |
||||
if (token[0] === ':') { |
||||
fetchComposeSuggestionsEmojis(dispatch, getState, token); |
||||
} else { |
||||
fetchComposeSuggestionsAccounts(dispatch, getState, token); |
||||
} |
||||
}; |
||||
}; |
||||
|
||||
export function readyComposeSuggestionsEmojis(token, emojis) { |
||||
return { |
||||
type: COMPOSE_SUGGESTIONS_READY, |
||||
token, |
||||
emojis, |
||||
}; |
||||
}; |
||||
|
||||
export function readyComposeSuggestionsAccounts(token, accounts) { |
||||
return { |
||||
type: COMPOSE_SUGGESTIONS_READY, |
||||
token, |
||||
accounts, |
||||
}; |
||||
}; |
||||
|
||||
export function selectComposeSuggestion(position, token, suggestion) { |
||||
return (dispatch, getState) => { |
||||
let completion, startPosition; |
||||
|
||||
if (typeof suggestion === 'object' && suggestion.id) { |
||||
completion = suggestion.native || suggestion.colons; |
||||
startPosition = position - 1; |
||||
|
||||
dispatch(useEmoji(suggestion)); |
||||
} else { |
||||
completion = getState().getIn(['accounts', suggestion, 'acct']); |
||||
startPosition = position; |
||||
} |
||||
|
||||
dispatch({ |
||||
type: COMPOSE_SUGGESTION_SELECT, |
||||
position: startPosition, |
||||
token, |
||||
completion, |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function mountCompose() { |
||||
return { |
||||
type: COMPOSE_MOUNT, |
||||
}; |
||||
}; |
||||
|
||||
export function unmountCompose() { |
||||
return { |
||||
type: COMPOSE_UNMOUNT, |
||||
}; |
||||
}; |
||||
|
||||
export function changeComposeSensitivity() { |
||||
return { |
||||
type: COMPOSE_SENSITIVITY_CHANGE, |
||||
}; |
||||
}; |
||||
|
||||
export function changeComposeSpoilerness() { |
||||
return { |
||||
type: COMPOSE_SPOILERNESS_CHANGE, |
||||
}; |
||||
}; |
||||
|
||||
export function changeComposeSpoilerText(text) { |
||||
return { |
||||
type: COMPOSE_SPOILER_TEXT_CHANGE, |
||||
text, |
||||
}; |
||||
}; |
||||
|
||||
export function changeComposeVisibility(value) { |
||||
return { |
||||
type: COMPOSE_VISIBILITY_CHANGE, |
||||
value, |
||||
}; |
||||
}; |
||||
|
||||
export function insertEmojiCompose(position, emoji) { |
||||
return { |
||||
type: COMPOSE_EMOJI_INSERT, |
||||
position, |
||||
emoji, |
||||
}; |
||||
}; |
||||
|
||||
export function changeComposing(value) { |
||||
return { |
||||
type: COMPOSE_COMPOSING_CHANGE, |
||||
value, |
||||
}; |
||||
} |
@ -0,0 +1,117 @@ |
||||
import api, { getLinks } from '../api'; |
||||
|
||||
export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST'; |
||||
export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS'; |
||||
export const DOMAIN_BLOCK_FAIL = 'DOMAIN_BLOCK_FAIL'; |
||||
|
||||
export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST'; |
||||
export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS'; |
||||
export const DOMAIN_UNBLOCK_FAIL = 'DOMAIN_UNBLOCK_FAIL'; |
||||
|
||||
export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; |
||||
export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS'; |
||||
export const DOMAIN_BLOCKS_FETCH_FAIL = 'DOMAIN_BLOCKS_FETCH_FAIL'; |
||||
|
||||
export function blockDomain(domain, accountId) { |
||||
return (dispatch, getState) => { |
||||
dispatch(blockDomainRequest(domain)); |
||||
|
||||
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { |
||||
dispatch(blockDomainSuccess(domain, accountId)); |
||||
}).catch(err => { |
||||
dispatch(blockDomainFail(domain, err)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function blockDomainRequest(domain) { |
||||
return { |
||||
type: DOMAIN_BLOCK_REQUEST, |
||||
domain, |
||||
}; |
||||
}; |
||||
|
||||
export function blockDomainSuccess(domain, accountId) { |
||||
return { |
||||
type: DOMAIN_BLOCK_SUCCESS, |
||||
domain, |
||||
accountId, |
||||
}; |
||||
}; |
||||
|
||||
export function blockDomainFail(domain, error) { |
||||
return { |
||||
type: DOMAIN_BLOCK_FAIL, |
||||
domain, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function unblockDomain(domain, accountId) { |
||||
return (dispatch, getState) => { |
||||
dispatch(unblockDomainRequest(domain)); |
||||
|
||||
api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { |
||||
dispatch(unblockDomainSuccess(domain, accountId)); |
||||
}).catch(err => { |
||||
dispatch(unblockDomainFail(domain, err)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function unblockDomainRequest(domain) { |
||||
return { |
||||
type: DOMAIN_UNBLOCK_REQUEST, |
||||
domain, |
||||
}; |
||||
}; |
||||
|
||||
export function unblockDomainSuccess(domain, accountId) { |
||||
return { |
||||
type: DOMAIN_UNBLOCK_SUCCESS, |
||||
domain, |
||||
accountId, |
||||
}; |
||||
}; |
||||
|
||||
export function unblockDomainFail(domain, error) { |
||||
return { |
||||
type: DOMAIN_UNBLOCK_FAIL, |
||||
domain, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchDomainBlocks() { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchDomainBlocksRequest()); |
||||
|
||||
api(getState).get().then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null)); |
||||
}).catch(err => { |
||||
dispatch(fetchDomainBlocksFail(err)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchDomainBlocksRequest() { |
||||
return { |
||||
type: DOMAIN_BLOCKS_FETCH_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchDomainBlocksSuccess(domains, next) { |
||||
return { |
||||
type: DOMAIN_BLOCKS_FETCH_SUCCESS, |
||||
domains, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchDomainBlocksFail(error) { |
||||
return { |
||||
type: DOMAIN_BLOCKS_FETCH_FAIL, |
||||
error, |
||||
}; |
||||
}; |
@ -0,0 +1,14 @@ |
||||
import { saveSettings } from './settings'; |
||||
|
||||
export const EMOJI_USE = 'EMOJI_USE'; |
||||
|
||||
export function useEmoji(emoji) { |
||||
return dispatch => { |
||||
dispatch({ |
||||
type: EMOJI_USE, |
||||
emoji, |
||||
}); |
||||
|
||||
dispatch(saveSettings()); |
||||
}; |
||||
}; |
@ -0,0 +1,83 @@ |
||||
import api, { getLinks } from '../api'; |
||||
|
||||
export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST'; |
||||
export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS'; |
||||
export const FAVOURITED_STATUSES_FETCH_FAIL = 'FAVOURITED_STATUSES_FETCH_FAIL'; |
||||
|
||||
export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST'; |
||||
export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS'; |
||||
export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL'; |
||||
|
||||
export function fetchFavouritedStatuses() { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchFavouritedStatusesRequest()); |
||||
|
||||
api(getState).get('/api/v1/favourites').then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null)); |
||||
}).catch(error => { |
||||
dispatch(fetchFavouritedStatusesFail(error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFavouritedStatusesRequest() { |
||||
return { |
||||
type: FAVOURITED_STATUSES_FETCH_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFavouritedStatusesSuccess(statuses, next) { |
||||
return { |
||||
type: FAVOURITED_STATUSES_FETCH_SUCCESS, |
||||
statuses, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFavouritedStatusesFail(error) { |
||||
return { |
||||
type: FAVOURITED_STATUSES_FETCH_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFavouritedStatuses() { |
||||
return (dispatch, getState) => { |
||||
const url = getState().getIn(['status_lists', 'favourites', 'next'], null); |
||||
|
||||
if (url === null) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(expandFavouritedStatusesRequest()); |
||||
|
||||
api(getState).get(url).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null)); |
||||
}).catch(error => { |
||||
dispatch(expandFavouritedStatusesFail(error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function expandFavouritedStatusesRequest() { |
||||
return { |
||||
type: FAVOURITED_STATUSES_EXPAND_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFavouritedStatusesSuccess(statuses, next) { |
||||
return { |
||||
type: FAVOURITED_STATUSES_EXPAND_SUCCESS, |
||||
statuses, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function expandFavouritedStatusesFail(error) { |
||||
return { |
||||
type: FAVOURITED_STATUSES_EXPAND_FAIL, |
||||
error, |
||||
}; |
||||
}; |
@ -0,0 +1,17 @@ |
||||
export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET'; |
||||
export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR'; |
||||
|
||||
export function setHeight (key, id, height) { |
||||
return { |
||||
type: HEIGHT_CACHE_SET, |
||||
key, |
||||
id, |
||||
height, |
||||
}; |
||||
}; |
||||
|
||||
export function clearHeight () { |
||||
return { |
||||
type: HEIGHT_CACHE_CLEAR, |
||||
}; |
||||
}; |
@ -0,0 +1,313 @@ |
||||
import api from '../api'; |
||||
|
||||
export const REBLOG_REQUEST = 'REBLOG_REQUEST'; |
||||
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; |
||||
export const REBLOG_FAIL = 'REBLOG_FAIL'; |
||||
|
||||
export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; |
||||
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; |
||||
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; |
||||
|
||||
export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; |
||||
export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; |
||||
export const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; |
||||
|
||||
export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; |
||||
export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; |
||||
export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; |
||||
|
||||
export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; |
||||
export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; |
||||
export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; |
||||
|
||||
export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; |
||||
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; |
||||
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; |
||||
|
||||
export const PIN_REQUEST = 'PIN_REQUEST'; |
||||
export const PIN_SUCCESS = 'PIN_SUCCESS'; |
||||
export const PIN_FAIL = 'PIN_FAIL'; |
||||
|
||||
export const UNPIN_REQUEST = 'UNPIN_REQUEST'; |
||||
export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; |
||||
export const UNPIN_FAIL = 'UNPIN_FAIL'; |
||||
|
||||
export function reblog(status) { |
||||
return function (dispatch, getState) { |
||||
dispatch(reblogRequest(status)); |
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { |
||||
// The reblog API method returns a new status wrapped around the original. In this case we are only
|
||||
// interested in how the original is modified, hence passing it skipping the wrapper
|
||||
dispatch(reblogSuccess(status, response.data.reblog)); |
||||
}).catch(function (error) { |
||||
dispatch(reblogFail(status, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function unreblog(status) { |
||||
return (dispatch, getState) => { |
||||
dispatch(unreblogRequest(status)); |
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { |
||||
dispatch(unreblogSuccess(status, response.data)); |
||||
}).catch(error => { |
||||
dispatch(unreblogFail(status, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function reblogRequest(status) { |
||||
return { |
||||
type: REBLOG_REQUEST, |
||||
status: status, |
||||
}; |
||||
}; |
||||
|
||||
export function reblogSuccess(status, response) { |
||||
return { |
||||
type: REBLOG_SUCCESS, |
||||
status: status, |
||||
response: response, |
||||
}; |
||||
}; |
||||
|
||||
export function reblogFail(status, error) { |
||||
return { |
||||
type: REBLOG_FAIL, |
||||
status: status, |
||||
error: error, |
||||
}; |
||||
}; |
||||
|
||||
export function unreblogRequest(status) { |
||||
return { |
||||
type: UNREBLOG_REQUEST, |
||||
status: status, |
||||
}; |
||||
}; |
||||
|
||||
export function unreblogSuccess(status, response) { |
||||
return { |
||||
type: UNREBLOG_SUCCESS, |
||||
status: status, |
||||
response: response, |
||||
}; |
||||
}; |
||||
|
||||
export function unreblogFail(status, error) { |
||||
return { |
||||
type: UNREBLOG_FAIL, |
||||
status: status, |
||||
error: error, |
||||
}; |
||||
}; |
||||
|
||||
export function favourite(status) { |
||||
return function (dispatch, getState) { |
||||
dispatch(favouriteRequest(status)); |
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { |
||||
dispatch(favouriteSuccess(status, response.data)); |
||||
}).catch(function (error) { |
||||
dispatch(favouriteFail(status, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function unfavourite(status) { |
||||
return (dispatch, getState) => { |
||||
dispatch(unfavouriteRequest(status)); |
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { |
||||
dispatch(unfavouriteSuccess(status, response.data)); |
||||
}).catch(error => { |
||||
dispatch(unfavouriteFail(status, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function favouriteRequest(status) { |
||||
return { |
||||
type: FAVOURITE_REQUEST, |
||||
status: status, |
||||
}; |
||||
}; |
||||
|
||||
export function favouriteSuccess(status, response) { |
||||
return { |
||||
type: FAVOURITE_SUCCESS, |
||||
status: status, |
||||
response: response, |
||||
}; |
||||
}; |
||||
|
||||
export function favouriteFail(status, error) { |
||||
return { |
||||
type: FAVOURITE_FAIL, |
||||
status: status, |
||||
error: error, |
||||
}; |
||||
}; |
||||
|
||||
export function unfavouriteRequest(status) { |
||||
return { |
||||
type: UNFAVOURITE_REQUEST, |
||||
status: status, |
||||
}; |
||||
}; |
||||
|
||||
export function unfavouriteSuccess(status, response) { |
||||
return { |
||||
type: UNFAVOURITE_SUCCESS, |
||||
status: status, |
||||
response: response, |
||||
}; |
||||
}; |
||||
|
||||
export function unfavouriteFail(status, error) { |
||||
return { |
||||
type: UNFAVOURITE_FAIL, |
||||
status: status, |
||||
error: error, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchReblogs(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchReblogsRequest(id)); |
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { |
||||
dispatch(fetchReblogsSuccess(id, response.data)); |
||||
}).catch(error => { |
||||
dispatch(fetchReblogsFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchReblogsRequest(id) { |
||||
return { |
||||
type: REBLOGS_FETCH_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchReblogsSuccess(id, accounts) { |
||||
return { |
||||
type: REBLOGS_FETCH_SUCCESS, |
||||
id, |
||||
accounts, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchReblogsFail(id, error) { |
||||
return { |
||||
type: REBLOGS_FETCH_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFavourites(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchFavouritesRequest(id)); |
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { |
||||
dispatch(fetchFavouritesSuccess(id, response.data)); |
||||
}).catch(error => { |
||||
dispatch(fetchFavouritesFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFavouritesRequest(id) { |
||||
return { |
||||
type: FAVOURITES_FETCH_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFavouritesSuccess(id, accounts) { |
||||
return { |
||||
type: FAVOURITES_FETCH_SUCCESS, |
||||
id, |
||||
accounts, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchFavouritesFail(id, error) { |
||||
return { |
||||
type: FAVOURITES_FETCH_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function pin(status) { |
||||
return (dispatch, getState) => { |
||||
dispatch(pinRequest(status)); |
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { |
||||
dispatch(pinSuccess(status, response.data)); |
||||
}).catch(error => { |
||||
dispatch(pinFail(status, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function pinRequest(status) { |
||||
return { |
||||
type: PIN_REQUEST, |
||||
status, |
||||
}; |
||||
}; |
||||
|
||||
export function pinSuccess(status, response) { |
||||
return { |
||||
type: PIN_SUCCESS, |
||||
status, |
||||
response, |
||||
}; |
||||
}; |
||||
|
||||
export function pinFail(status, error) { |
||||
return { |
||||
type: PIN_FAIL, |
||||
status, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function unpin (status) { |
||||
return (dispatch, getState) => { |
||||
dispatch(unpinRequest(status)); |
||||
|
||||
api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { |
||||
dispatch(unpinSuccess(status, response.data)); |
||||
}).catch(error => { |
||||
dispatch(unpinFail(status, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function unpinRequest(status) { |
||||
return { |
||||
type: UNPIN_REQUEST, |
||||
status, |
||||
}; |
||||
}; |
||||
|
||||
export function unpinSuccess(status, response) { |
||||
return { |
||||
type: UNPIN_SUCCESS, |
||||
status, |
||||
response, |
||||
}; |
||||
}; |
||||
|
||||
export function unpinFail(status, error) { |
||||
return { |
||||
type: UNPIN_FAIL, |
||||
status, |
||||
error, |
||||
}; |
||||
}; |
@ -0,0 +1,16 @@ |
||||
export const MODAL_OPEN = 'MODAL_OPEN'; |
||||
export const MODAL_CLOSE = 'MODAL_CLOSE'; |
||||
|
||||
export function openModal(type, props) { |
||||
return { |
||||
type: MODAL_OPEN, |
||||
modalType: type, |
||||
modalProps: props, |
||||
}; |
||||
}; |
||||
|
||||
export function closeModal() { |
||||
return { |
||||
type: MODAL_CLOSE, |
||||
}; |
||||
}; |
@ -0,0 +1,103 @@ |
||||
import api, { getLinks } from '../api'; |
||||
import { fetchRelationships } from './accounts'; |
||||
import { openModal } from '../../mastodon/actions/modal'; |
||||
|
||||
export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; |
||||
export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; |
||||
export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL'; |
||||
|
||||
export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; |
||||
export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; |
||||
export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; |
||||
|
||||
export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; |
||||
export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; |
||||
|
||||
export function fetchMutes() { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchMutesRequest()); |
||||
|
||||
api(getState).get('/api/v1/mutes').then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); |
||||
dispatch(fetchRelationships(response.data.map(item => item.id))); |
||||
}).catch(error => dispatch(fetchMutesFail(error))); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchMutesRequest() { |
||||
return { |
||||
type: MUTES_FETCH_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchMutesSuccess(accounts, next) { |
||||
return { |
||||
type: MUTES_FETCH_SUCCESS, |
||||
accounts, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchMutesFail(error) { |
||||
return { |
||||
type: MUTES_FETCH_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function expandMutes() { |
||||
return (dispatch, getState) => { |
||||
const url = getState().getIn(['user_lists', 'mutes', 'next']); |
||||
|
||||
if (url === null) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(expandMutesRequest()); |
||||
|
||||
api(getState).get(url).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); |
||||
dispatch(fetchRelationships(response.data.map(item => item.id))); |
||||
}).catch(error => dispatch(expandMutesFail(error))); |
||||
}; |
||||
}; |
||||
|
||||
export function expandMutesRequest() { |
||||
return { |
||||
type: MUTES_EXPAND_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function expandMutesSuccess(accounts, next) { |
||||
return { |
||||
type: MUTES_EXPAND_SUCCESS, |
||||
accounts, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function expandMutesFail(error) { |
||||
return { |
||||
type: MUTES_EXPAND_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function initMuteModal(account) { |
||||
return dispatch => { |
||||
dispatch({ |
||||
type: MUTES_INIT_MODAL, |
||||
account, |
||||
}); |
||||
|
||||
dispatch(openModal('MUTE')); |
||||
}; |
||||
} |
||||
|
||||
export function toggleHideNotifications() { |
||||
return dispatch => { |
||||
dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); |
||||
}; |
||||
} |
@ -0,0 +1,190 @@ |
||||
import api, { getLinks } from '../api'; |
||||
import { List as ImmutableList } from 'immutable'; |
||||
import IntlMessageFormat from 'intl-messageformat'; |
||||
import { fetchRelationships } from './accounts'; |
||||
import { defineMessages } from 'react-intl'; |
||||
|
||||
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; |
||||
|
||||
export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST'; |
||||
export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS'; |
||||
export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL'; |
||||
|
||||
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; |
||||
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; |
||||
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; |
||||
|
||||
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; |
||||
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; |
||||
|
||||
defineMessages({ |
||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, |
||||
}); |
||||
|
||||
const fetchRelatedRelationships = (dispatch, notifications) => { |
||||
const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); |
||||
|
||||
if (accountIds > 0) { |
||||
dispatch(fetchRelationships(accountIds)); |
||||
} |
||||
}; |
||||
|
||||
const unescapeHTML = (html) => { |
||||
const wrapper = document.createElement('div'); |
||||
html = html.replace(/<br \/>|<br>|\n/, ' '); |
||||
wrapper.innerHTML = html; |
||||
return wrapper.textContent; |
||||
}; |
||||
|
||||
export function updateNotifications(notification, intlMessages, intlLocale) { |
||||
return (dispatch, getState) => { |
||||
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); |
||||
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); |
||||
|
||||
dispatch({ |
||||
type: NOTIFICATIONS_UPDATE, |
||||
notification, |
||||
account: notification.account, |
||||
status: notification.status, |
||||
meta: playSound ? { sound: 'boop' } : undefined, |
||||
}); |
||||
|
||||
fetchRelatedRelationships(dispatch, [notification]); |
||||
|
||||
// Desktop notifications
|
||||
if (typeof window.Notification !== 'undefined' && showAlert) { |
||||
const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); |
||||
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); |
||||
|
||||
const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); |
||||
notify.addEventListener('click', () => { |
||||
window.focus(); |
||||
notify.close(); |
||||
}); |
||||
} |
||||
}; |
||||
}; |
||||
|
||||
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); |
||||
|
||||
export function refreshNotifications() { |
||||
return (dispatch, getState) => { |
||||
const params = {}; |
||||
const ids = getState().getIn(['notifications', 'items']); |
||||
|
||||
let skipLoading = false; |
||||
|
||||
if (ids.size > 0) { |
||||
params.since_id = ids.first().get('id'); |
||||
} |
||||
|
||||
if (getState().getIn(['notifications', 'loaded'])) { |
||||
skipLoading = true; |
||||
} |
||||
|
||||
params.exclude_types = excludeTypesFromSettings(getState()); |
||||
|
||||
dispatch(refreshNotificationsRequest(skipLoading)); |
||||
|
||||
api(getState).get('/api/v1/notifications', { params }).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
|
||||
dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null)); |
||||
fetchRelatedRelationships(dispatch, response.data); |
||||
}).catch(error => { |
||||
dispatch(refreshNotificationsFail(error, skipLoading)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function refreshNotificationsRequest(skipLoading) { |
||||
return { |
||||
type: NOTIFICATIONS_REFRESH_REQUEST, |
||||
skipLoading, |
||||
}; |
||||
}; |
||||
|
||||
export function refreshNotificationsSuccess(notifications, skipLoading, next) { |
||||
return { |
||||
type: NOTIFICATIONS_REFRESH_SUCCESS, |
||||
notifications, |
||||
accounts: notifications.map(item => item.account), |
||||
statuses: notifications.map(item => item.status).filter(status => !!status), |
||||
skipLoading, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function refreshNotificationsFail(error, skipLoading) { |
||||
return { |
||||
type: NOTIFICATIONS_REFRESH_FAIL, |
||||
error, |
||||
skipLoading, |
||||
}; |
||||
}; |
||||
|
||||
export function expandNotifications() { |
||||
return (dispatch, getState) => { |
||||
const items = getState().getIn(['notifications', 'items'], ImmutableList()); |
||||
|
||||
if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) { |
||||
return; |
||||
} |
||||
|
||||
const params = { |
||||
max_id: items.last().get('id'), |
||||
limit: 20, |
||||
exclude_types: excludeTypesFromSettings(getState()), |
||||
}; |
||||
|
||||
dispatch(expandNotificationsRequest()); |
||||
|
||||
api(getState).get('/api/v1/notifications', { params }).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); |
||||
fetchRelatedRelationships(dispatch, response.data); |
||||
}).catch(error => { |
||||
dispatch(expandNotificationsFail(error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function expandNotificationsRequest() { |
||||
return { |
||||
type: NOTIFICATIONS_EXPAND_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function expandNotificationsSuccess(notifications, next) { |
||||
return { |
||||
type: NOTIFICATIONS_EXPAND_SUCCESS, |
||||
notifications, |
||||
accounts: notifications.map(item => item.account), |
||||
statuses: notifications.map(item => item.status).filter(status => !!status), |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function expandNotificationsFail(error) { |
||||
return { |
||||
type: NOTIFICATIONS_EXPAND_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function clearNotifications() { |
||||
return (dispatch, getState) => { |
||||
dispatch({ |
||||
type: NOTIFICATIONS_CLEAR, |
||||
}); |
||||
|
||||
api(getState).post('/api/v1/notifications/clear'); |
||||
}; |
||||
}; |
||||
|
||||
export function scrollTopNotifications(top) { |
||||
return { |
||||
type: NOTIFICATIONS_SCROLL_TOP, |
||||
top, |
||||
}; |
||||
}; |
@ -0,0 +1,14 @@ |
||||
import { openModal } from './modal'; |
||||
import { changeSetting, saveSettings } from './settings'; |
||||
|
||||
export function showOnboardingOnce() { |
||||
return (dispatch, getState) => { |
||||
const alreadySeen = getState().getIn(['settings', 'onboarded']); |
||||
|
||||
if (!alreadySeen) { |
||||
dispatch(openModal('ONBOARDING')); |
||||
dispatch(changeSetting(['onboarded'], true)); |
||||
dispatch(saveSettings()); |
||||
} |
||||
}; |
||||
}; |
@ -0,0 +1,40 @@ |
||||
import api from '../api'; |
||||
|
||||
export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; |
||||
export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; |
||||
export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; |
||||
|
||||
import { me } from '../initial_state'; |
||||
|
||||
export function fetchPinnedStatuses() { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchPinnedStatusesRequest()); |
||||
|
||||
api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { |
||||
dispatch(fetchPinnedStatusesSuccess(response.data, null)); |
||||
}).catch(error => { |
||||
dispatch(fetchPinnedStatusesFail(error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchPinnedStatusesRequest() { |
||||
return { |
||||
type: PINNED_STATUSES_FETCH_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchPinnedStatusesSuccess(statuses, next) { |
||||
return { |
||||
type: PINNED_STATUSES_FETCH_SUCCESS, |
||||
statuses, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchPinnedStatusesFail(error) { |
||||
return { |
||||
type: PINNED_STATUSES_FETCH_FAIL, |
||||
error, |
||||
}; |
||||
}; |
@ -0,0 +1,52 @@ |
||||
import axios from 'axios'; |
||||
|
||||
export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; |
||||
export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; |
||||
export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; |
||||
export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE'; |
||||
|
||||
export function setBrowserSupport (value) { |
||||
return { |
||||
type: SET_BROWSER_SUPPORT, |
||||
value, |
||||
}; |
||||
} |
||||
|
||||
export function setSubscription (subscription) { |
||||
return { |
||||
type: SET_SUBSCRIPTION, |
||||
subscription, |
||||
}; |
||||
} |
||||
|
||||
export function clearSubscription () { |
||||
return { |
||||
type: CLEAR_SUBSCRIPTION, |
||||
}; |
||||
} |
||||
|
||||
export function changeAlerts(key, value) { |
||||
return dispatch => { |
||||
dispatch({ |
||||
type: ALERTS_CHANGE, |
||||
key, |
||||
value, |
||||
}); |
||||
|
||||
dispatch(saveSettings()); |
||||
}; |
||||
} |
||||
|
||||
export function saveSettings() { |
||||
return (_, getState) => { |
||||
const state = getState().get('push_notifications'); |
||||
const subscription = state.get('subscription'); |
||||
const alerts = state.get('alerts'); |
||||
|
||||
axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, { |
||||
data: { |
||||
alerts, |
||||
}, |
||||
}); |
||||
}; |
||||
} |
@ -0,0 +1,80 @@ |
||||
import api from '../api'; |
||||
import { openModal, closeModal } from './modal'; |
||||
|
||||
export const REPORT_INIT = 'REPORT_INIT'; |
||||
export const REPORT_CANCEL = 'REPORT_CANCEL'; |
||||
|
||||
export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; |
||||
export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; |
||||
export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; |
||||
|
||||
export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; |
||||
export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; |
||||
|
||||
export function initReport(account, status) { |
||||
return dispatch => { |
||||
dispatch({ |
||||
type: REPORT_INIT, |
||||
account, |
||||
status, |
||||
}); |
||||
|
||||
dispatch(openModal('REPORT')); |
||||
}; |
||||
}; |
||||
|
||||
export function cancelReport() { |
||||
return { |
||||
type: REPORT_CANCEL, |
||||
}; |
||||
}; |
||||
|
||||
export function toggleStatusReport(statusId, checked) { |
||||
return { |
||||
type: REPORT_STATUS_TOGGLE, |
||||
statusId, |
||||
checked, |
||||
}; |
||||
}; |
||||
|
||||
export function submitReport() { |
||||
return (dispatch, getState) => { |
||||
dispatch(submitReportRequest()); |
||||
|
||||
api(getState).post('/api/v1/reports', { |
||||
account_id: getState().getIn(['reports', 'new', 'account_id']), |
||||
status_ids: getState().getIn(['reports', 'new', 'status_ids']), |
||||
comment: getState().getIn(['reports', 'new', 'comment']), |
||||
}).then(response => { |
||||
dispatch(closeModal()); |
||||
dispatch(submitReportSuccess(response.data)); |
||||
}).catch(error => dispatch(submitReportFail(error))); |
||||
}; |
||||
}; |
||||
|
||||
export function submitReportRequest() { |
||||
return { |
||||
type: REPORT_SUBMIT_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function submitReportSuccess(report) { |
||||
return { |
||||
type: REPORT_SUBMIT_SUCCESS, |
||||
report, |
||||
}; |
||||
}; |
||||
|
||||
export function submitReportFail(error) { |
||||
return { |
||||
type: REPORT_SUBMIT_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function changeReportComment(comment) { |
||||
return { |
||||
type: REPORT_COMMENT_CHANGE, |
||||
comment, |
||||
}; |
||||
}; |
@ -0,0 +1,73 @@ |
||||
import api from '../api'; |
||||
|
||||
export const SEARCH_CHANGE = 'SEARCH_CHANGE'; |
||||
export const SEARCH_CLEAR = 'SEARCH_CLEAR'; |
||||
export const SEARCH_SHOW = 'SEARCH_SHOW'; |
||||
|
||||
export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; |
||||
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; |
||||
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; |
||||
|
||||
export function changeSearch(value) { |
||||
return { |
||||
type: SEARCH_CHANGE, |
||||
value, |
||||
}; |
||||
}; |
||||
|
||||
export function clearSearch() { |
||||
return { |
||||
type: SEARCH_CLEAR, |
||||
}; |
||||
}; |
||||
|
||||
export function submitSearch() { |
||||
return (dispatch, getState) => { |
||||
const value = getState().getIn(['search', 'value']); |
||||
|
||||
if (value.length === 0) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(fetchSearchRequest()); |
||||
|
||||
api(getState).get('/api/v1/search', { |
||||
params: { |
||||
q: value, |
||||
resolve: true, |
||||
}, |
||||
}).then(response => { |
||||
dispatch(fetchSearchSuccess(response.data)); |
||||
}).catch(error => { |
||||
dispatch(fetchSearchFail(error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchSearchRequest() { |
||||
return { |
||||
type: SEARCH_FETCH_REQUEST, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchSearchSuccess(results) { |
||||
return { |
||||
type: SEARCH_FETCH_SUCCESS, |
||||
results, |
||||
accounts: results.accounts, |
||||
statuses: results.statuses, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchSearchFail(error) { |
||||
return { |
||||
type: SEARCH_FETCH_FAIL, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function showSearch() { |
||||
return { |
||||
type: SEARCH_SHOW, |
||||
}; |
||||
}; |
@ -0,0 +1,31 @@ |
||||
import axios from 'axios'; |
||||
import { debounce } from 'lodash'; |
||||
|
||||
export const SETTING_CHANGE = 'SETTING_CHANGE'; |
||||
export const SETTING_SAVE = 'SETTING_SAVE'; |
||||
|
||||
export function changeSetting(key, value) { |
||||
return dispatch => { |
||||
dispatch({ |
||||
type: SETTING_CHANGE, |
||||
key, |
||||
value, |
||||
}); |
||||
|
||||
dispatch(saveSettings()); |
||||
}; |
||||
}; |
||||
|
||||
const debouncedSave = debounce((dispatch, getState) => { |
||||
if (getState().getIn(['settings', 'saved'])) { |
||||
return; |
||||
} |
||||
|
||||
const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS(); |
||||
|
||||
axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE })); |
||||
}, 5000, { trailing: true }); |
||||
|
||||
export function saveSettings() { |
||||
return (dispatch, getState) => debouncedSave(dispatch, getState); |
||||
}; |
@ -0,0 +1,217 @@ |
||||
import api from '../api'; |
||||
|
||||
import { deleteFromTimelines } from './timelines'; |
||||
import { fetchStatusCard } from './cards'; |
||||
|
||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; |
||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; |
||||
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; |
||||
|
||||
export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; |
||||
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; |
||||
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; |
||||
|
||||
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; |
||||
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; |
||||
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; |
||||
|
||||
export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST'; |
||||
export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS'; |
||||
export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL'; |
||||
|
||||
export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; |
||||
export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; |
||||
export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; |
||||
|
||||
export function fetchStatusRequest(id, skipLoading) { |
||||
return { |
||||
type: STATUS_FETCH_REQUEST, |
||||
id, |
||||
skipLoading, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchStatus(id) { |
||||
return (dispatch, getState) => { |
||||
const skipLoading = getState().getIn(['statuses', id], null) !== null; |
||||
|
||||
dispatch(fetchContext(id)); |
||||
dispatch(fetchStatusCard(id)); |
||||
|
||||
if (skipLoading) { |
||||
return; |
||||
} |
||||
|
||||
dispatch(fetchStatusRequest(id, skipLoading)); |
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}`).then(response => { |
||||
dispatch(fetchStatusSuccess(response.data, skipLoading)); |
||||
}).catch(error => { |
||||
dispatch(fetchStatusFail(id, error, skipLoading)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchStatusSuccess(status, skipLoading) { |
||||
return { |
||||
type: STATUS_FETCH_SUCCESS, |
||||
status, |
||||
skipLoading, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchStatusFail(id, error, skipLoading) { |
||||
return { |
||||
type: STATUS_FETCH_FAIL, |
||||
id, |
||||
error, |
||||
skipLoading, |
||||
skipAlert: true, |
||||
}; |
||||
}; |
||||
|
||||
export function deleteStatus(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(deleteStatusRequest(id)); |
||||
|
||||
api(getState).delete(`/api/v1/statuses/${id}`).then(() => { |
||||
dispatch(deleteStatusSuccess(id)); |
||||
dispatch(deleteFromTimelines(id)); |
||||
}).catch(error => { |
||||
dispatch(deleteStatusFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function deleteStatusRequest(id) { |
||||
return { |
||||
type: STATUS_DELETE_REQUEST, |
||||
id: id, |
||||
}; |
||||
}; |
||||
|
||||
export function deleteStatusSuccess(id) { |
||||
return { |
||||
type: STATUS_DELETE_SUCCESS, |
||||
id: id, |
||||
}; |
||||
}; |
||||
|
||||
export function deleteStatusFail(id, error) { |
||||
return { |
||||
type: STATUS_DELETE_FAIL, |
||||
id: id, |
||||
error: error, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchContext(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(fetchContextRequest(id)); |
||||
|
||||
api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { |
||||
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); |
||||
|
||||
}).catch(error => { |
||||
if (error.response && error.response.status === 404) { |
||||
dispatch(deleteFromTimelines(id)); |
||||
} |
||||
|
||||
dispatch(fetchContextFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function fetchContextRequest(id) { |
||||
return { |
||||
type: CONTEXT_FETCH_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function fetchContextSuccess(id, ancestors, descendants) { |
||||
return { |
||||
type: CONTEXT_FETCH_SUCCESS, |
||||
id, |
||||
ancestors, |
||||
descendants, |
||||
statuses: ancestors.concat(descendants), |
||||
}; |
||||
}; |
||||
|
||||
export function fetchContextFail(id, error) { |
||||
return { |
||||
type: CONTEXT_FETCH_FAIL, |
||||
id, |
||||
error, |
||||
skipAlert: true, |
||||
}; |
||||
}; |
||||
|
||||
export function muteStatus(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(muteStatusRequest(id)); |
||||
|
||||
api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { |
||||
dispatch(muteStatusSuccess(id)); |
||||
}).catch(error => { |
||||
dispatch(muteStatusFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function muteStatusRequest(id) { |
||||
return { |
||||
type: STATUS_MUTE_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function muteStatusSuccess(id) { |
||||
return { |
||||
type: STATUS_MUTE_SUCCESS, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function muteStatusFail(id, error) { |
||||
return { |
||||
type: STATUS_MUTE_FAIL, |
||||
id, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function unmuteStatus(id) { |
||||
return (dispatch, getState) => { |
||||
dispatch(unmuteStatusRequest(id)); |
||||
|
||||
api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => { |
||||
dispatch(unmuteStatusSuccess(id)); |
||||
}).catch(error => { |
||||
dispatch(unmuteStatusFail(id, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function unmuteStatusRequest(id) { |
||||
return { |
||||
type: STATUS_UNMUTE_REQUEST, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function unmuteStatusSuccess(id) { |
||||
return { |
||||
type: STATUS_UNMUTE_SUCCESS, |
||||
id, |
||||
}; |
||||
}; |
||||
|
||||
export function unmuteStatusFail(id, error) { |
||||
return { |
||||
type: STATUS_UNMUTE_FAIL, |
||||
id, |
||||
error, |
||||
}; |
||||
}; |
@ -0,0 +1,17 @@ |
||||
import { Iterable, fromJS } from 'immutable'; |
||||
|
||||
export const STORE_HYDRATE = 'STORE_HYDRATE'; |
||||
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; |
||||
|
||||
const convertState = rawState => |
||||
fromJS(rawState, (k, v) => |
||||
Iterable.isIndexed(v) ? v.toList() : v.toMap()); |
||||
|
||||
export function hydrateStore(rawState) { |
||||
const state = convertState(rawState); |
||||
|
||||
return { |
||||
type: STORE_HYDRATE, |
||||
state, |
||||
}; |
||||
}; |
@ -0,0 +1,53 @@ |
||||
import { connectStream } from '../stream'; |
||||
import { |
||||
updateTimeline, |
||||
deleteFromTimelines, |
||||
refreshHomeTimeline, |
||||
connectTimeline, |
||||
disconnectTimeline, |
||||
} from './timelines'; |
||||
import { updateNotifications, refreshNotifications } from './notifications'; |
||||
import { getLocale } from '../locales'; |
||||
|
||||
const { messages } = getLocale(); |
||||
|
||||
export function connectTimelineStream (timelineId, path, pollingRefresh = null) { |
||||
|
||||
return connectStream (path, pollingRefresh, (dispatch, getState) => { |
||||
const locale = getState().getIn(['meta', 'locale']); |
||||
return { |
||||
onConnect() { |
||||
dispatch(connectTimeline(timelineId)); |
||||
}, |
||||
|
||||
onDisconnect() { |
||||
dispatch(disconnectTimeline(timelineId)); |
||||
}, |
||||
|
||||
onReceive (data) { |
||||
switch(data.event) { |
||||
case 'update': |
||||
dispatch(updateTimeline(timelineId, JSON.parse(data.payload))); |
||||
break; |
||||
case 'delete': |
||||
dispatch(deleteFromTimelines(data.payload)); |
||||
break; |
||||
case 'notification': |
||||
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); |
||||
break; |
||||
} |
||||
}, |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
function refreshHomeTimelineAndNotification (dispatch) { |
||||
dispatch(refreshHomeTimeline()); |
||||
dispatch(refreshNotifications()); |
||||
} |
||||
|
||||
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); |
||||
export const connectCommunityStream = () => connectTimelineStream('community', 'public:local'); |
||||
export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); |
||||
export const connectPublicStream = () => connectTimelineStream('public', 'public'); |
||||
export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); |
@ -0,0 +1,206 @@ |
||||
import api, { getLinks } from '../api'; |
||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; |
||||
|
||||
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; |
||||
export const TIMELINE_DELETE = 'TIMELINE_DELETE'; |
||||
|
||||
export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST'; |
||||
export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS'; |
||||
export const TIMELINE_REFRESH_FAIL = 'TIMELINE_REFRESH_FAIL'; |
||||
|
||||
export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; |
||||
export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; |
||||
export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; |
||||
|
||||
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; |
||||
|
||||
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; |
||||
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; |
||||
|
||||
export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; |
||||
|
||||
export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { |
||||
return { |
||||
type: TIMELINE_REFRESH_SUCCESS, |
||||
timeline, |
||||
statuses, |
||||
skipLoading, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function updateTimeline(timeline, status) { |
||||
return (dispatch, getState) => { |
||||
const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; |
||||
const parents = []; |
||||
|
||||
if (status.in_reply_to_id) { |
||||
let parent = getState().getIn(['statuses', status.in_reply_to_id]); |
||||
|
||||
while (parent && parent.get('in_reply_to_id')) { |
||||
parents.push(parent.get('id')); |
||||
parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]); |
||||
} |
||||
} |
||||
|
||||
dispatch({ |
||||
type: TIMELINE_UPDATE, |
||||
timeline, |
||||
status, |
||||
references, |
||||
}); |
||||
|
||||
if (parents.length > 0) { |
||||
dispatch({ |
||||
type: TIMELINE_CONTEXT_UPDATE, |
||||
status, |
||||
references: parents, |
||||
}); |
||||
} |
||||
}; |
||||
}; |
||||
|
||||
export function deleteFromTimelines(id) { |
||||
return (dispatch, getState) => { |
||||
const accountId = getState().getIn(['statuses', id, 'account']); |
||||
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); |
||||
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); |
||||
|
||||
dispatch({ |
||||
type: TIMELINE_DELETE, |
||||
id, |
||||
accountId, |
||||
references, |
||||
reblogOf, |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export function refreshTimelineRequest(timeline, skipLoading) { |
||||
return { |
||||
type: TIMELINE_REFRESH_REQUEST, |
||||
timeline, |
||||
skipLoading, |
||||
}; |
||||
}; |
||||
|
||||
export function refreshTimeline(timelineId, path, params = {}) { |
||||
return function (dispatch, getState) { |
||||
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); |
||||
|
||||
if (timeline.get('isLoading') || timeline.get('online')) { |
||||
return; |
||||
} |
||||
|
||||
const ids = timeline.get('items', ImmutableList()); |
||||
const newestId = ids.size > 0 ? ids.first() : null; |
||||
|
||||
let skipLoading = timeline.get('loaded'); |
||||
|
||||
if (newestId !== null) { |
||||
params.since_id = newestId; |
||||
} |
||||
|
||||
dispatch(refreshTimelineRequest(timelineId, skipLoading)); |
||||
|
||||
api(getState).get(path, { params }).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null)); |
||||
}).catch(error => { |
||||
dispatch(refreshTimelineFail(timelineId, error, skipLoading)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); |
||||
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); |
||||
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); |
||||
export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); |
||||
export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); |
||||
export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); |
||||
|
||||
export function refreshTimelineFail(timeline, error, skipLoading) { |
||||
return { |
||||
type: TIMELINE_REFRESH_FAIL, |
||||
timeline, |
||||
error, |
||||
skipLoading, |
||||
skipAlert: error.response && error.response.status === 404, |
||||
}; |
||||
}; |
||||
|
||||
export function expandTimeline(timelineId, path, params = {}) { |
||||
return (dispatch, getState) => { |
||||
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); |
||||
const ids = timeline.get('items', ImmutableList()); |
||||
|
||||
if (timeline.get('isLoading') || ids.size === 0) { |
||||
return; |
||||
} |
||||
|
||||
params.max_id = ids.last(); |
||||
params.limit = 10; |
||||
|
||||
dispatch(expandTimelineRequest(timelineId)); |
||||
|
||||
api(getState).get(path, { params }).then(response => { |
||||
const next = getLinks(response).refs.find(link => link.rel === 'next'); |
||||
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null)); |
||||
}).catch(error => { |
||||
dispatch(expandTimelineFail(timelineId, error)); |
||||
}); |
||||
}; |
||||
}; |
||||
|
||||
export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home'); |
||||
export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public'); |
||||
export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); |
||||
export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); |
||||
export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); |
||||
export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); |
||||
|
||||
export function expandTimelineRequest(timeline) { |
||||
return { |
||||
type: TIMELINE_EXPAND_REQUEST, |
||||
timeline, |
||||
}; |
||||
}; |
||||
|
||||
export function expandTimelineSuccess(timeline, statuses, next) { |
||||
return { |
||||
type: TIMELINE_EXPAND_SUCCESS, |
||||
timeline, |
||||
statuses, |
||||
next, |
||||
}; |
||||
}; |
||||
|
||||
export function expandTimelineFail(timeline, error) { |
||||
return { |
||||
type: TIMELINE_EXPAND_FAIL, |
||||
timeline, |
||||
error, |
||||
}; |
||||
}; |
||||
|
||||
export function scrollTopTimeline(timeline, top) { |
||||
return { |
||||
type: TIMELINE_SCROLL_TOP, |
||||
timeline, |
||||
top, |
||||
}; |
||||
}; |
||||
|
||||
export function connectTimeline(timeline) { |
||||
return { |
||||
type: TIMELINE_CONNECT, |
||||
timeline, |
||||
}; |
||||
}; |
||||
|
||||
export function disconnectTimeline(timeline) { |
||||
return { |
||||
type: TIMELINE_DISCONNECT, |
||||
timeline, |
||||
}; |
||||
}; |
@ -0,0 +1,26 @@ |
||||
import axios from 'axios'; |
||||
import LinkHeader from './link_header'; |
||||
|
||||
export const getLinks = response => { |
||||
const value = response.headers.link; |
||||
|
||||
if (!value) { |
||||
return { refs: [] }; |
||||
} |
||||
|
||||
return LinkHeader.parse(value); |
||||
}; |
||||
|
||||
export default getState => axios.create({ |
||||
headers: { |
||||
'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`, |
||||
}, |
||||
|
||||
transformResponse: [function (data) { |
||||
try { |
||||
return JSON.parse(data); |
||||
} catch(Exception) { |
||||
return data; |
||||
} |
||||
}], |
||||
}); |
@ -0,0 +1,18 @@ |
||||
import 'intl'; |
||||
import 'intl/locale-data/jsonp/en'; |
||||
import 'es6-symbol/implement'; |
||||
import includes from 'array-includes'; |
||||
import assign from 'object-assign'; |
||||
import isNaN from 'is-nan'; |
||||
|
||||
if (!Array.prototype.includes) { |
||||
includes.shim(); |
||||
} |
||||
|
||||
if (!Object.assign) { |
||||
Object.assign = assign; |
||||
} |
||||
|
||||
if (!Number.isNaN) { |
||||
Number.isNaN = isNaN; |
||||
} |
@ -0,0 +1,33 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`<Avatar /> Autoplay renders a animated avatar 1`] = ` |
||||
<div |
||||
className="account__avatar" |
||||
onMouseEnter={[Function]} |
||||
onMouseLeave={[Function]} |
||||
style={ |
||||
Object { |
||||
"backgroundImage": "url(/animated/alice.gif)", |
||||
"backgroundSize": "100px 100px", |
||||
"height": "100px", |
||||
"width": "100px", |
||||
} |
||||
} |
||||
/> |
||||
`; |
||||
|
||||
exports[`<Avatar /> Still renders a still avatar 1`] = ` |
||||
<div |
||||
className="account__avatar" |
||||
onMouseEnter={[Function]} |
||||
onMouseLeave={[Function]} |
||||
style={ |
||||
Object { |
||||
"backgroundImage": "url(/static/alice.jpg)", |
||||
"backgroundSize": "100px 100px", |
||||
"height": "100px", |
||||
"width": "100px", |
||||
} |
||||
} |
||||
/> |
||||
`; |
@ -0,0 +1,24 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`<AvatarOverlay renders a overlay avatar 1`] = ` |
||||
<div |
||||
className="account__avatar-overlay" |
||||
> |
||||
<div |
||||
className="account__avatar-overlay-base" |
||||
style={ |
||||
Object { |
||||
"backgroundImage": "url(/static/alice.jpg)", |
||||
} |
||||
} |
||||
/> |
||||
<div |
||||
className="account__avatar-overlay-overlay" |
||||
style={ |
||||
Object { |
||||
"backgroundImage": "url(/static/eve.jpg)", |
||||
} |
||||
} |
||||
/> |
||||
</div> |
||||
`; |
@ -0,0 +1,114 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = ` |
||||
<button |
||||
className="button button-secondary" |
||||
disabled={undefined} |
||||
onClick={[Function]} |
||||
style={ |
||||
Object { |
||||
"height": "36px", |
||||
"lineHeight": "36px", |
||||
"padding": "0 16px", |
||||
} |
||||
} |
||||
/> |
||||
`; |
||||
|
||||
exports[`<Button /> renders a button element 1`] = ` |
||||
<button |
||||
className="button" |
||||
disabled={undefined} |
||||
onClick={[Function]} |
||||
style={ |
||||
Object { |
||||
"height": "36px", |
||||
"lineHeight": "36px", |
||||
"padding": "0 16px", |
||||
} |
||||
} |
||||
/> |
||||
`; |
||||
|
||||
exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = ` |
||||
<button |
||||
className="button" |
||||
disabled={true} |
||||
onClick={[Function]} |
||||
style={ |
||||
Object { |
||||
"height": "36px", |
||||
"lineHeight": "36px", |
||||
"padding": "0 16px", |
||||
} |
||||
} |
||||
/> |
||||
`; |
||||
|
||||
exports[`<Button /> renders class="button--block" if props.block given 1`] = ` |
||||
<button |
||||
className="button button--block" |
||||
disabled={undefined} |
||||
onClick={[Function]} |
||||
style={ |
||||
Object { |
||||
"height": "36px", |
||||
"lineHeight": "36px", |
||||
"padding": "0 16px", |
||||
} |
||||
} |
||||
/> |
||||
`; |
||||
|
||||
exports[`<Button /> renders the children 1`] = ` |
||||
<button |
||||
className="button" |
||||
disabled={undefined} |
||||
onClick={[Function]} |
||||
style={ |
||||
Object { |
||||
"height": "36px", |
||||
"lineHeight": "36px", |
||||
"padding": "0 16px", |
||||
} |
||||
} |
||||
> |
||||
<p> |
||||
children |
||||
</p> |
||||
</button> |
||||
`; |
||||
|
||||
exports[`<Button /> renders the given text 1`] = ` |
||||
<button |
||||
className="button" |
||||
disabled={undefined} |
||||
onClick={[Function]} |
||||
style={ |
||||
Object { |
||||
"height": "36px", |
||||
"lineHeight": "36px", |
||||
"padding": "0 16px", |
||||
} |
||||
} |
||||
> |
||||
foo |
||||
</button> |
||||
`; |
||||
|
||||
exports[`<Button /> renders the props.text instead of children 1`] = ` |
||||
<button |
||||
className="button" |
||||
disabled={undefined} |
||||
onClick={[Function]} |
||||
style={ |
||||
Object { |
||||
"height": "36px", |
||||
"lineHeight": "36px", |
||||
"padding": "0 16px", |
||||
} |
||||
} |
||||
> |
||||
foo |
||||
</button> |
||||
`; |
@ -0,0 +1,23 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`<DisplayName /> renders display name + account name 1`] = ` |
||||
<span |
||||
className="display-name" |
||||
> |
||||
<strong |
||||
className="display-name__html" |
||||
dangerouslySetInnerHTML={ |
||||
Object { |
||||
"__html": "<p>Foo</p>", |
||||
} |
||||
} |
||||
/> |
||||
|
||||
<span |
||||
className="display-name__account" |
||||
> |
||||
@ |
||||
bar@baz |
||||
</span> |
||||
</span> |
||||
`; |
@ -0,0 +1,36 @@ |
||||
import React from 'react'; |
||||
import renderer from 'react-test-renderer'; |
||||
import { fromJS } from 'immutable'; |
||||
import Avatar from '../avatar'; |
||||
|
||||
describe('<Avatar />', () => { |
||||
const account = fromJS({ |
||||
username: 'alice', |
||||
acct: 'alice', |
||||
display_name: 'Alice', |
||||
avatar: '/animated/alice.gif', |
||||
avatar_static: '/static/alice.jpg', |
||||
}); |
||||
|
||||
const size = 100; |
||||
|
||||
describe('Autoplay', () => { |
||||
it('renders a animated avatar', () => { |
||||
const component = renderer.create(<Avatar account={account} animate size={size} />); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
}); |
||||
|
||||
describe('Still', () => { |
||||
it('renders a still avatar', () => { |
||||
const component = renderer.create(<Avatar account={account} size={size} />); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
}); |
||||
|
||||
// TODO add autoplay test if possible
|
||||
}); |
@ -0,0 +1,29 @@ |
||||
import React from 'react'; |
||||
import renderer from 'react-test-renderer'; |
||||
import { fromJS } from 'immutable'; |
||||
import AvatarOverlay from '../avatar_overlay'; |
||||
|
||||
describe('<AvatarOverlay', () => { |
||||
const account = fromJS({ |
||||
username: 'alice', |
||||
acct: 'alice', |
||||
display_name: 'Alice', |
||||
avatar: '/animated/alice.gif', |
||||
avatar_static: '/static/alice.jpg', |
||||
}); |
||||
|
||||
const friend = fromJS({ |
||||
username: 'eve', |
||||
acct: 'eve@blackhat.lair', |
||||
display_name: 'Evelyn', |
||||
avatar: '/animated/eve.gif', |
||||
avatar_static: '/static/eve.jpg', |
||||
}); |
||||
|
||||
it('renders a overlay avatar', () => { |
||||
const component = renderer.create(<AvatarOverlay account={account} friend={friend} />); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
}); |
@ -0,0 +1,75 @@ |
||||
import { shallow } from 'enzyme'; |
||||
import React from 'react'; |
||||
import renderer from 'react-test-renderer'; |
||||
import Button from '../button'; |
||||
|
||||
describe('<Button />', () => { |
||||
it('renders a button element', () => { |
||||
const component = renderer.create(<Button />); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('renders the given text', () => { |
||||
const text = 'foo'; |
||||
const component = renderer.create(<Button text={text} />); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('handles click events using the given handler', () => { |
||||
const handler = jest.fn(); |
||||
const button = shallow(<Button onClick={handler} />); |
||||
button.find('button').simulate('click'); |
||||
|
||||
expect(handler.mock.calls.length).toEqual(1); |
||||
}); |
||||
|
||||
it('does not handle click events if props.disabled given', () => { |
||||
const handler = jest.fn(); |
||||
const button = shallow(<Button onClick={handler} disabled />); |
||||
button.find('button').simulate('click'); |
||||
|
||||
expect(handler.mock.calls.length).toEqual(0); |
||||
}); |
||||
|
||||
it('renders a disabled attribute if props.disabled given', () => { |
||||
const component = renderer.create(<Button disabled />); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('renders the children', () => { |
||||
const children = <p>children</p>; |
||||
const component = renderer.create(<Button>{children}</Button>); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('renders the props.text instead of children', () => { |
||||
const text = 'foo'; |
||||
const children = <p>children</p>; |
||||
const component = renderer.create(<Button text={text}>{children}</Button>); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('renders class="button--block" if props.block given', () => { |
||||
const component = renderer.create(<Button block />); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('adds class "button-secondary" if props.secondary given', () => { |
||||
const component = renderer.create(<Button secondary />); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
}); |
@ -0,0 +1,18 @@ |
||||
import React from 'react'; |
||||
import renderer from 'react-test-renderer'; |
||||
import { fromJS } from 'immutable'; |
||||
import DisplayName from '../display_name'; |
||||
|
||||
describe('<DisplayName />', () => { |
||||
it('renders display name + account name', () => { |
||||
const account = fromJS({ |
||||
username: 'bar', |
||||
acct: 'bar@baz', |
||||
display_name_html: '<p>Foo</p>', |
||||
}); |
||||
const component = renderer.create(<DisplayName account={account} />); |
||||
const tree = component.toJSON(); |
||||
|
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
}); |
@ -0,0 +1,116 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import Avatar from './avatar'; |
||||
import DisplayName from './display_name'; |
||||
import Permalink from './permalink'; |
||||
import IconButton from './icon_button'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { me } from '../initial_state'; |
||||
|
||||
const messages = defineMessages({ |
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' }, |
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, |
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, |
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, |
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, |
||||
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, |
||||
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class Account extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map.isRequired, |
||||
onFollow: PropTypes.func.isRequired, |
||||
onBlock: PropTypes.func.isRequired, |
||||
onMute: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
hidden: PropTypes.bool, |
||||
}; |
||||
|
||||
handleFollow = () => { |
||||
this.props.onFollow(this.props.account); |
||||
} |
||||
|
||||
handleBlock = () => { |
||||
this.props.onBlock(this.props.account); |
||||
} |
||||
|
||||
handleMute = () => { |
||||
this.props.onMute(this.props.account); |
||||
} |
||||
|
||||
handleMuteNotifications = () => { |
||||
this.props.onMuteNotifications(this.props.account, true); |
||||
} |
||||
|
||||
handleUnmuteNotifications = () => { |
||||
this.props.onMuteNotifications(this.props.account, false); |
||||
} |
||||
|
||||
render () { |
||||
const { account, intl, hidden } = this.props; |
||||
|
||||
if (!account) { |
||||
return <div />; |
||||
} |
||||
|
||||
if (hidden) { |
||||
return ( |
||||
<div> |
||||
{account.get('display_name')} |
||||
{account.get('username')} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
let buttons; |
||||
|
||||
if (account.get('id') !== me && account.get('relationship', null) !== null) { |
||||
const following = account.getIn(['relationship', 'following']); |
||||
const requested = account.getIn(['relationship', 'requested']); |
||||
const blocking = account.getIn(['relationship', 'blocking']); |
||||
const muting = account.getIn(['relationship', 'muting']); |
||||
|
||||
if (requested) { |
||||
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; |
||||
} else if (blocking) { |
||||
buttons = <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; |
||||
} else if (muting) { |
||||
let hidingNotificationsButton; |
||||
if (muting.get('notifications')) { |
||||
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />; |
||||
} else { |
||||
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />; |
||||
} |
||||
buttons = ( |
||||
<div> |
||||
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} /> |
||||
{hidingNotificationsButton} |
||||
</div> |
||||
); |
||||
} else { |
||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className='account'> |
||||
<div className='account__wrapper'> |
||||
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> |
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> |
||||
<DisplayName account={account} /> |
||||
</Permalink> |
||||
|
||||
<div className='account__relationship'> |
||||
{buttons} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,33 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
|
||||
const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; |
||||
|
||||
export default class AttachmentList extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
media: ImmutablePropTypes.list.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { media } = this.props; |
||||
|
||||
return ( |
||||
<div className='attachment-list'> |
||||
<div className='attachment-list__icon'> |
||||
<i className='fa fa-link' /> |
||||
</div> |
||||
|
||||
<ul className='attachment-list__list'> |
||||
{media.map(attachment => |
||||
<li key={attachment.get('id')}> |
||||
<a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a> |
||||
</li> |
||||
)} |
||||
</ul> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,42 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; |
||||
|
||||
const assetHost = process.env.CDN_HOST || ''; |
||||
|
||||
export default class AutosuggestEmoji extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
emoji: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { emoji } = this.props; |
||||
let url; |
||||
|
||||
if (emoji.custom) { |
||||
url = emoji.imageUrl; |
||||
} else { |
||||
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; |
||||
|
||||
if (!mapping) { |
||||
return null; |
||||
} |
||||
|
||||
url = `${assetHost}/emoji/${mapping.filename}.svg`; |
||||
} |
||||
|
||||
return ( |
||||
<div className='autosuggest-emoji'> |
||||
<img |
||||
className='emojione' |
||||
src={url} |
||||
alt={emoji.native || emoji.colons} |
||||
/> |
||||
|
||||
{emoji.colons} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,222 @@ |
||||
import React from 'react'; |
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; |
||||
import AutosuggestEmoji from './autosuggest_emoji'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import { isRtl } from '../rtl'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import Textarea from 'react-textarea-autosize'; |
||||
import classNames from 'classnames'; |
||||
|
||||
const textAtCursorMatchesToken = (str, caretPosition) => { |
||||
let word; |
||||
|
||||
let left = str.slice(0, caretPosition).search(/\S+$/); |
||||
let right = str.slice(caretPosition).search(/\s/); |
||||
|
||||
if (right < 0) { |
||||
word = str.slice(left); |
||||
} else { |
||||
word = str.slice(left, right + caretPosition); |
||||
} |
||||
|
||||
if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) { |
||||
return [null, null]; |
||||
} |
||||
|
||||
word = word.trim().toLowerCase(); |
||||
|
||||
if (word.length > 0) { |
||||
return [left + 1, word]; |
||||
} else { |
||||
return [null, null]; |
||||
} |
||||
}; |
||||
|
||||
export default class AutosuggestTextarea extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
value: PropTypes.string, |
||||
suggestions: ImmutablePropTypes.list, |
||||
disabled: PropTypes.bool, |
||||
placeholder: PropTypes.string, |
||||
onSuggestionSelected: PropTypes.func.isRequired, |
||||
onSuggestionsClearRequested: PropTypes.func.isRequired, |
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired, |
||||
onChange: PropTypes.func.isRequired, |
||||
onKeyUp: PropTypes.func, |
||||
onKeyDown: PropTypes.func, |
||||
onPaste: PropTypes.func.isRequired, |
||||
autoFocus: PropTypes.bool, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
autoFocus: true, |
||||
}; |
||||
|
||||
state = { |
||||
suggestionsHidden: false, |
||||
selectedSuggestion: 0, |
||||
lastToken: null, |
||||
tokenStart: 0, |
||||
}; |
||||
|
||||
onChange = (e) => { |
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); |
||||
|
||||
if (token !== null && this.state.lastToken !== token) { |
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); |
||||
this.props.onSuggestionsFetchRequested(token); |
||||
} else if (token === null) { |
||||
this.setState({ lastToken: null }); |
||||
this.props.onSuggestionsClearRequested(); |
||||
} |
||||
|
||||
this.props.onChange(e); |
||||
} |
||||
|
||||
onKeyDown = (e) => { |
||||
const { suggestions, disabled } = this.props; |
||||
const { selectedSuggestion, suggestionsHidden } = this.state; |
||||
|
||||
if (disabled) { |
||||
e.preventDefault(); |
||||
return; |
||||
} |
||||
|
||||
switch(e.key) { |
||||
case 'Escape': |
||||
if (!suggestionsHidden) { |
||||
e.preventDefault(); |
||||
this.setState({ suggestionsHidden: true }); |
||||
} |
||||
|
||||
break; |
||||
case 'ArrowDown': |
||||
if (suggestions.size > 0 && !suggestionsHidden) { |
||||
e.preventDefault(); |
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); |
||||
} |
||||
|
||||
break; |
||||
case 'ArrowUp': |
||||
if (suggestions.size > 0 && !suggestionsHidden) { |
||||
e.preventDefault(); |
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); |
||||
} |
||||
|
||||
break; |
||||
case 'Enter': |
||||
case 'Tab': |
||||
// Select suggestion
|
||||
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { |
||||
e.preventDefault(); |
||||
e.stopPropagation(); |
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); |
||||
} |
||||
|
||||
break; |
||||
} |
||||
|
||||
if (e.defaultPrevented || !this.props.onKeyDown) { |
||||
return; |
||||
} |
||||
|
||||
this.props.onKeyDown(e); |
||||
} |
||||
|
||||
onKeyUp = e => { |
||||
if (e.key === 'Escape' && this.state.suggestionsHidden) { |
||||
document.querySelector('.ui').parentElement.focus(); |
||||
} |
||||
|
||||
if (this.props.onKeyUp) { |
||||
this.props.onKeyUp(e); |
||||
} |
||||
} |
||||
|
||||
onBlur = () => { |
||||
this.setState({ suggestionsHidden: true }); |
||||
} |
||||
|
||||
onSuggestionClick = (e) => { |
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); |
||||
e.preventDefault(); |
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); |
||||
this.textarea.focus(); |
||||
} |
||||
|
||||
componentWillReceiveProps (nextProps) { |
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { |
||||
this.setState({ suggestionsHidden: false }); |
||||
} |
||||
} |
||||
|
||||
setTextarea = (c) => { |
||||
this.textarea = c; |
||||
} |
||||
|
||||
onPaste = (e) => { |
||||
if (e.clipboardData && e.clipboardData.files.length === 1) { |
||||
this.props.onPaste(e.clipboardData.files); |
||||
e.preventDefault(); |
||||
} |
||||
} |
||||
|
||||
renderSuggestion = (suggestion, i) => { |
||||
const { selectedSuggestion } = this.state; |
||||
let inner, key; |
||||
|
||||
if (typeof suggestion === 'object') { |
||||
inner = <AutosuggestEmoji emoji={suggestion} />; |
||||
key = suggestion.id; |
||||
} else { |
||||
inner = <AutosuggestAccountContainer id={suggestion} />; |
||||
key = suggestion; |
||||
} |
||||
|
||||
return ( |
||||
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> |
||||
{inner} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
render () { |
||||
const { value, suggestions, disabled, placeholder, autoFocus } = this.props; |
||||
const { suggestionsHidden } = this.state; |
||||
const style = { direction: 'ltr' }; |
||||
|
||||
if (isRtl(value)) { |
||||
style.direction = 'rtl'; |
||||
} |
||||
|
||||
return ( |
||||
<div className='autosuggest-textarea'> |
||||
<label> |
||||
<span style={{ display: 'none' }}>{placeholder}</span> |
||||
|
||||
<Textarea |
||||
inputRef={this.setTextarea} |
||||
className='autosuggest-textarea__textarea' |
||||
disabled={disabled} |
||||
placeholder={placeholder} |
||||
autoFocus={autoFocus} |
||||
value={value} |
||||
onChange={this.onChange} |
||||
onKeyDown={this.onKeyDown} |
||||
onKeyUp={this.onKeyUp} |
||||
onBlur={this.onBlur} |
||||
onPaste={this.onPaste} |
||||
style={style} |
||||
/> |
||||
</label> |
||||
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> |
||||
{suggestions.map(this.renderSuggestion)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,71 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
|
||||
export default class Avatar extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map.isRequired, |
||||
size: PropTypes.number.isRequired, |
||||
style: PropTypes.object, |
||||
animate: PropTypes.bool, |
||||
inline: PropTypes.bool, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
animate: false, |
||||
size: 20, |
||||
inline: false, |
||||
}; |
||||
|
||||
state = { |
||||
hovering: false, |
||||
}; |
||||
|
||||
handleMouseEnter = () => { |
||||
if (this.props.animate) return; |
||||
this.setState({ hovering: true }); |
||||
} |
||||
|
||||
handleMouseLeave = () => { |
||||
if (this.props.animate) return; |
||||
this.setState({ hovering: false }); |
||||
} |
||||
|
||||
render () { |
||||
const { account, size, animate, inline } = this.props; |
||||
const { hovering } = this.state; |
||||
|
||||
const src = account.get('avatar'); |
||||
const staticSrc = account.get('avatar_static'); |
||||
|
||||
let className = 'account__avatar'; |
||||
|
||||
if (inline) { |
||||
className = className + ' account__avatar-inline'; |
||||
} |
||||
|
||||
const style = { |
||||
...this.props.style, |
||||
width: `${size}px`, |
||||
height: `${size}px`, |
||||
backgroundSize: `${size}px ${size}px`, |
||||
}; |
||||
|
||||
if (hovering || animate) { |
||||
style.backgroundImage = `url(${src})`; |
||||
} else { |
||||
style.backgroundImage = `url(${staticSrc})`; |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
className={className} |
||||
onMouseEnter={this.handleMouseEnter} |
||||
onMouseLeave={this.handleMouseLeave} |
||||
style={style} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,30 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
|
||||
export default class AvatarOverlay extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map.isRequired, |
||||
friend: ImmutablePropTypes.map.isRequired, |
||||
}; |
||||
|
||||
render() { |
||||
const { account, friend } = this.props; |
||||
|
||||
const baseStyle = { |
||||
backgroundImage: `url(${account.get('avatar_static')})`, |
||||
}; |
||||
|
||||
const overlayStyle = { |
||||
backgroundImage: `url(${friend.get('avatar_static')})`, |
||||
}; |
||||
|
||||
return ( |
||||
<div className='account__avatar-overlay'> |
||||
<div className='account__avatar-overlay-base' style={baseStyle} /> |
||||
<div className='account__avatar-overlay-overlay' style={overlayStyle} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,63 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import classNames from 'classnames'; |
||||
|
||||
export default class Button extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
text: PropTypes.node, |
||||
onClick: PropTypes.func, |
||||
disabled: PropTypes.bool, |
||||
block: PropTypes.bool, |
||||
secondary: PropTypes.bool, |
||||
size: PropTypes.number, |
||||
className: PropTypes.string, |
||||
style: PropTypes.object, |
||||
children: PropTypes.node, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
size: 36, |
||||
}; |
||||
|
||||
handleClick = (e) => { |
||||
if (!this.props.disabled) { |
||||
this.props.onClick(e); |
||||
} |
||||
} |
||||
|
||||
setRef = (c) => { |
||||
this.node = c; |
||||
} |
||||
|
||||
focus() { |
||||
this.node.focus(); |
||||
} |
||||
|
||||
render () { |
||||
const style = { |
||||
padding: `0 ${this.props.size / 2.25}px`, |
||||
height: `${this.props.size}px`, |
||||
lineHeight: `${this.props.size}px`, |
||||
...this.props.style, |
||||
}; |
||||
|
||||
const className = classNames('button', this.props.className, { |
||||
'button-secondary': this.props.secondary, |
||||
'button--block': this.props.block, |
||||
}); |
||||
|
||||
return ( |
||||
<button |
||||
className={className} |
||||
disabled={this.props.disabled} |
||||
onClick={this.handleClick} |
||||
ref={this.setRef} |
||||
style={style} |
||||
> |
||||
{this.props.text || this.props.children} |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,22 @@ |
||||
import React from 'react'; |
||||
import Motion from '../features/ui/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import PropTypes from 'prop-types'; |
||||
|
||||
const Collapsable = ({ fullHeight, isVisible, children }) => ( |
||||
<Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}> |
||||
{({ opacity, height }) => |
||||
<div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}> |
||||
{children} |
||||
</div> |
||||
} |
||||
</Motion> |
||||
); |
||||
|
||||
Collapsable.propTypes = { |
||||
fullHeight: PropTypes.number.isRequired, |
||||
isVisible: PropTypes.bool.isRequired, |
||||
children: PropTypes.node.isRequired, |
||||
}; |
||||
|
||||
export default Collapsable; |
@ -0,0 +1,52 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import detectPassiveEvents from 'detect-passive-events'; |
||||
import { scrollTop } from '../scroll'; |
||||
|
||||
export default class Column extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
children: PropTypes.node, |
||||
}; |
||||
|
||||
scrollTop () { |
||||
const scrollable = this.node.querySelector('.scrollable'); |
||||
|
||||
if (!scrollable) { |
||||
return; |
||||
} |
||||
|
||||
this._interruptScrollAnimation = scrollTop(scrollable); |
||||
} |
||||
|
||||
handleWheel = () => { |
||||
if (typeof this._interruptScrollAnimation !== 'function') { |
||||
return; |
||||
} |
||||
|
||||
this._interruptScrollAnimation(); |
||||
} |
||||
|
||||
setRef = c => { |
||||
this.node = c; |
||||
} |
||||
|
||||
componentDidMount () { |
||||
this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
this.node.removeEventListener('wheel', this.handleWheel); |
||||
} |
||||
|
||||
render () { |
||||
const { children } = this.props; |
||||
|
||||
return ( |
||||
<div role='region' className='column' ref={this.setRef}> |
||||
{children} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,28 @@ |
||||
import React from 'react'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import PropTypes from 'prop-types'; |
||||
|
||||
export default class ColumnBackButton extends React.PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
handleClick = () => { |
||||
if (window.history && window.history.length === 1) { |
||||
this.context.router.history.push('/'); |
||||
} else { |
||||
this.context.router.history.goBack(); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
return ( |
||||
<button onClick={this.handleClick} className='column-back-button'> |
||||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' /> |
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,27 @@ |
||||
import React from 'react'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import PropTypes from 'prop-types'; |
||||
|
||||
export default class ColumnBackButtonSlim extends React.PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
handleClick = () => { |
||||
if (window.history && window.history.length === 1) this.context.router.history.push('/'); |
||||
else this.context.router.history.goBack(); |
||||
} |
||||
|
||||
render () { |
||||
return ( |
||||
<div className='column-back-button--slim'> |
||||
<div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'> |
||||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' /> |
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,159 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import classNames from 'classnames'; |
||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; |
||||
|
||||
const messages = defineMessages({ |
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, |
||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, |
||||
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, |
||||
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class ColumnHeader extends React.PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
intl: PropTypes.object.isRequired, |
||||
title: PropTypes.node.isRequired, |
||||
icon: PropTypes.string.isRequired, |
||||
active: PropTypes.bool, |
||||
multiColumn: PropTypes.bool, |
||||
focusable: PropTypes.bool, |
||||
showBackButton: PropTypes.bool, |
||||
children: PropTypes.node, |
||||
pinned: PropTypes.bool, |
||||
onPin: PropTypes.func, |
||||
onMove: PropTypes.func, |
||||
onClick: PropTypes.func, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
focusable: true, |
||||
} |
||||
|
||||
state = { |
||||
collapsed: true, |
||||
animating: false, |
||||
}; |
||||
|
||||
handleToggleClick = (e) => { |
||||
e.stopPropagation(); |
||||
this.setState({ collapsed: !this.state.collapsed, animating: true }); |
||||
} |
||||
|
||||
handleTitleClick = () => { |
||||
this.props.onClick(); |
||||
} |
||||
|
||||
handleMoveLeft = () => { |
||||
this.props.onMove(-1); |
||||
} |
||||
|
||||
handleMoveRight = () => { |
||||
this.props.onMove(1); |
||||
} |
||||
|
||||
handleBackClick = () => { |
||||
if (window.history && window.history.length === 1) this.context.router.history.push('/'); |
||||
else this.context.router.history.goBack(); |
||||
} |
||||
|
||||
handleTransitionEnd = () => { |
||||
this.setState({ animating: false }); |
||||
} |
||||
|
||||
render () { |
||||
const { title, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage } } = this.props; |
||||
const { collapsed, animating } = this.state; |
||||
|
||||
const wrapperClassName = classNames('column-header__wrapper', { |
||||
'active': active, |
||||
}); |
||||
|
||||
const buttonClassName = classNames('column-header', { |
||||
'active': active, |
||||
}); |
||||
|
||||
const collapsibleClassName = classNames('column-header__collapsible', { |
||||
'collapsed': collapsed, |
||||
'animating': animating, |
||||
}); |
||||
|
||||
const collapsibleButtonClassName = classNames('column-header__button', { |
||||
'active': !collapsed, |
||||
}); |
||||
|
||||
let extraContent, pinButton, moveButtons, backButton, collapseButton; |
||||
|
||||
if (children) { |
||||
extraContent = ( |
||||
<div key='extra-content' className='column-header__collapsible__extra'> |
||||
{children} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (multiColumn && pinned) { |
||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; |
||||
|
||||
moveButtons = ( |
||||
<div key='move-buttons' className='column-header__setting-arrows'> |
||||
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button> |
||||
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button> |
||||
</div> |
||||
); |
||||
} else if (multiColumn) { |
||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; |
||||
} |
||||
|
||||
if (!pinned && (multiColumn || showBackButton)) { |
||||
backButton = ( |
||||
<button onClick={this.handleBackClick} className='column-header__back-button'> |
||||
<i className='fa fa-fw fa-chevron-left column-back-button__icon' /> |
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' /> |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
const collapsedContent = [ |
||||
extraContent, |
||||
]; |
||||
|
||||
if (multiColumn) { |
||||
collapsedContent.push(moveButtons); |
||||
collapsedContent.push(pinButton); |
||||
} |
||||
|
||||
if (children || multiColumn) { |
||||
collapseButton = <button className={collapsibleButtonClassName} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>; |
||||
} |
||||
|
||||
return ( |
||||
<div className={wrapperClassName}> |
||||
<h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}> |
||||
<i className={`fa fa-fw fa-${icon} column-header__icon`} /> |
||||
<span className='column-header__title'> |
||||
{title} |
||||
</span> |
||||
|
||||
<div className='column-header__buttons'> |
||||
{backButton} |
||||
{collapseButton} |
||||
</div> |
||||
</h1> |
||||
|
||||
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> |
||||
<div className='column-header__collapsible-inner'> |
||||
{(!collapsed || animating) && collapsedContent} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,20 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
|
||||
export default class DisplayName extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const displayNameHtml = { __html: this.props.account.get('display_name_html') }; |
||||
|
||||
return ( |
||||
<span className='display-name'> |
||||
<strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span> |
||||
</span> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,211 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import IconButton from './icon_button'; |
||||
import Overlay from 'react-overlays/lib/Overlay'; |
||||
import Motion from '../features/ui/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import detectPassiveEvents from 'detect-passive-events'; |
||||
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; |
||||
|
||||
class DropdownMenu extends React.PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
items: PropTypes.array.isRequired, |
||||
onClose: PropTypes.func.isRequired, |
||||
style: PropTypes.object, |
||||
placement: PropTypes.string, |
||||
arrowOffsetLeft: PropTypes.string, |
||||
arrowOffsetTop: PropTypes.string, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
style: {}, |
||||
placement: 'bottom', |
||||
}; |
||||
|
||||
handleDocumentClick = e => { |
||||
if (this.node && !this.node.contains(e.target)) { |
||||
this.props.onClose(); |
||||
} |
||||
} |
||||
|
||||
componentDidMount () { |
||||
document.addEventListener('click', this.handleDocumentClick, false); |
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
document.removeEventListener('click', this.handleDocumentClick, false); |
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); |
||||
} |
||||
|
||||
setRef = c => { |
||||
this.node = c; |
||||
} |
||||
|
||||
handleClick = e => { |
||||
const i = Number(e.currentTarget.getAttribute('data-index')); |
||||
const { action, to } = this.props.items[i]; |
||||
|
||||
this.props.onClose(); |
||||
|
||||
if (typeof action === 'function') { |
||||
e.preventDefault(); |
||||
action(); |
||||
} else if (to) { |
||||
e.preventDefault(); |
||||
this.context.router.history.push(to); |
||||
} |
||||
} |
||||
|
||||
renderItem (option, i) { |
||||
if (option === null) { |
||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />; |
||||
} |
||||
|
||||
const { text, href = '#' } = option; |
||||
|
||||
return ( |
||||
<li className='dropdown-menu__item' key={`${text}-${i}`}> |
||||
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' autoFocus={i === 0} onClick={this.handleClick} data-index={i}> |
||||
{text} |
||||
</a> |
||||
</li> |
||||
); |
||||
} |
||||
|
||||
render () { |
||||
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; |
||||
|
||||
return ( |
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> |
||||
{({ opacity, scaleX, scaleY }) => ( |
||||
<div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> |
||||
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} /> |
||||
|
||||
<ul> |
||||
{items.map((option, i) => this.renderItem(option, i))} |
||||
</ul> |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
export default class Dropdown extends React.PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
icon: PropTypes.string.isRequired, |
||||
items: PropTypes.array.isRequired, |
||||
size: PropTypes.number.isRequired, |
||||
ariaLabel: PropTypes.string, |
||||
disabled: PropTypes.bool, |
||||
status: ImmutablePropTypes.map, |
||||
isUserTouching: PropTypes.func, |
||||
isModalOpen: PropTypes.bool.isRequired, |
||||
onModalOpen: PropTypes.func, |
||||
onModalClose: PropTypes.func, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
ariaLabel: 'Menu', |
||||
}; |
||||
|
||||
state = { |
||||
expanded: false, |
||||
}; |
||||
|
||||
handleClick = () => { |
||||
if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) { |
||||
const { status, items } = this.props; |
||||
|
||||
this.props.onModalOpen({ |
||||
status, |
||||
actions: items, |
||||
onClick: this.handleItemClick, |
||||
}); |
||||
|
||||
return; |
||||
} |
||||
|
||||
this.setState({ expanded: !this.state.expanded }); |
||||
} |
||||
|
||||
handleClose = () => { |
||||
if (this.props.onModalClose) { |
||||
this.props.onModalClose(); |
||||
} |
||||
|
||||
this.setState({ expanded: false }); |
||||
} |
||||
|
||||
handleKeyDown = e => { |
||||
switch(e.key) { |
||||
case 'Enter': |
||||
this.handleClick(); |
||||
break; |
||||
case 'Escape': |
||||
this.handleClose(); |
||||
break; |
||||
} |
||||
} |
||||
|
||||
handleItemClick = e => { |
||||
const i = Number(e.currentTarget.getAttribute('data-index')); |
||||
const { action, to } = this.props.items[i]; |
||||
|
||||
this.handleClose(); |
||||
|
||||
if (typeof action === 'function') { |
||||
e.preventDefault(); |
||||
action(); |
||||
} else if (to) { |
||||
e.preventDefault(); |
||||
this.context.router.history.push(to); |
||||
} |
||||
} |
||||
|
||||
setTargetRef = c => { |
||||
this.target = c; |
||||
} |
||||
|
||||
findTarget = () => { |
||||
return this.target; |
||||
} |
||||
|
||||
render () { |
||||
const { icon, items, size, ariaLabel, disabled } = this.props; |
||||
const { expanded } = this.state; |
||||
|
||||
return ( |
||||
<div onKeyDown={this.handleKeyDown}> |
||||
<IconButton |
||||
icon={icon} |
||||
title={ariaLabel} |
||||
active={expanded} |
||||
disabled={disabled} |
||||
size={size} |
||||
ref={this.setTargetRef} |
||||
onClick={this.handleClick} |
||||
/> |
||||
|
||||
<Overlay show={expanded} placement='bottom' target={this.findTarget}> |
||||
<DropdownMenu items={items} onClose={this.handleClose} /> |
||||
</Overlay> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,54 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
|
||||
export default class ExtendedVideoPlayer extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
src: PropTypes.string.isRequired, |
||||
alt: PropTypes.string, |
||||
width: PropTypes.number, |
||||
height: PropTypes.number, |
||||
time: PropTypes.number, |
||||
controls: PropTypes.bool.isRequired, |
||||
muted: PropTypes.bool.isRequired, |
||||
}; |
||||
|
||||
handleLoadedData = () => { |
||||
if (this.props.time) { |
||||
this.video.currentTime = this.props.time; |
||||
} |
||||
} |
||||
|
||||
componentDidMount () { |
||||
this.video.addEventListener('loadeddata', this.handleLoadedData); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
this.video.removeEventListener('loadeddata', this.handleLoadedData); |
||||
} |
||||
|
||||
setRef = (c) => { |
||||
this.video = c; |
||||
} |
||||
|
||||
render () { |
||||
const { src, muted, controls, alt } = this.props; |
||||
|
||||
return ( |
||||
<div className='extended-video-player'> |
||||
<video |
||||
ref={this.setRef} |
||||
src={src} |
||||
autoPlay |
||||
role='button' |
||||
tabIndex='0' |
||||
aria-label={alt} |
||||
muted={muted} |
||||
controls={controls} |
||||
loop={!controls} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,114 @@ |
||||
import React from 'react'; |
||||
import Motion from '../features/ui/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import PropTypes from 'prop-types'; |
||||
import classNames from 'classnames'; |
||||
|
||||
export default class IconButton extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
className: PropTypes.string, |
||||
title: PropTypes.string.isRequired, |
||||
icon: PropTypes.string.isRequired, |
||||
onClick: PropTypes.func, |
||||
size: PropTypes.number, |
||||
active: PropTypes.bool, |
||||
pressed: PropTypes.bool, |
||||
expanded: PropTypes.bool, |
||||
style: PropTypes.object, |
||||
activeStyle: PropTypes.object, |
||||
disabled: PropTypes.bool, |
||||
inverted: PropTypes.bool, |
||||
animate: PropTypes.bool, |
||||
overlay: PropTypes.bool, |
||||
tabIndex: PropTypes.string, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
size: 18, |
||||
active: false, |
||||
disabled: false, |
||||
animate: false, |
||||
overlay: false, |
||||
tabIndex: '0', |
||||
}; |
||||
|
||||
handleClick = (e) => { |
||||
e.preventDefault(); |
||||
|
||||
if (!this.props.disabled) { |
||||
this.props.onClick(e); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const style = { |
||||
fontSize: `${this.props.size}px`, |
||||
width: `${this.props.size * 1.28571429}px`, |
||||
height: `${this.props.size * 1.28571429}px`, |
||||
lineHeight: `${this.props.size}px`, |
||||
...this.props.style, |
||||
...(this.props.active ? this.props.activeStyle : {}), |
||||
}; |
||||
|
||||
const { |
||||
active, |
||||
animate, |
||||
className, |
||||
disabled, |
||||
expanded, |
||||
icon, |
||||
inverted, |
||||
overlay, |
||||
pressed, |
||||
tabIndex, |
||||
title, |
||||
} = this.props; |
||||
|
||||
const classes = classNames(className, 'icon-button', { |
||||
active, |
||||
disabled, |
||||
inverted, |
||||
overlayed: overlay, |
||||
}); |
||||
|
||||
if (!animate) { |
||||
// Perf optimization: avoid unnecessary <Motion> components unless
|
||||
// we actually need to animate.
|
||||
return ( |
||||
<button |
||||
aria-label={title} |
||||
aria-pressed={pressed} |
||||
aria-expanded={expanded} |
||||
title={title} |
||||
className={classes} |
||||
onClick={this.handleClick} |
||||
style={style} |
||||
tabIndex={tabIndex} |
||||
> |
||||
<i className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> |
||||
{({ rotate }) => |
||||
<button |
||||
aria-label={title} |
||||
aria-pressed={pressed} |
||||
aria-expanded={expanded} |
||||
title={title} |
||||
className={classes} |
||||
onClick={this.handleClick} |
||||
style={style} |
||||
tabIndex={tabIndex} |
||||
> |
||||
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' /> |
||||
</button> |
||||
} |
||||
</Motion> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,130 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; |
||||
import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; |
||||
import { is } from 'immutable'; |
||||
|
||||
// Diff these props in the "rendered" state
|
||||
const updateOnPropsForRendered = ['id', 'index', 'listLength']; |
||||
// Diff these props in the "unrendered" state
|
||||
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; |
||||
|
||||
export default class IntersectionObserverArticle extends React.Component { |
||||
|
||||
static propTypes = { |
||||
intersectionObserverWrapper: PropTypes.object.isRequired, |
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |
||||
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |
||||
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), |
||||
saveHeightKey: PropTypes.string, |
||||
cachedHeight: PropTypes.number, |
||||
onHeightChange: PropTypes.func, |
||||
children: PropTypes.node, |
||||
}; |
||||
|
||||
state = { |
||||
isHidden: false, // set to true in requestIdleCallback to trigger un-render
|
||||
} |
||||
|
||||
shouldComponentUpdate (nextProps, nextState) { |
||||
const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight); |
||||
const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight); |
||||
if (!!isUnrendered !== !!willBeUnrendered) { |
||||
// If we're going from rendered to unrendered (or vice versa) then update
|
||||
return true; |
||||
} |
||||
// Otherwise, diff based on props
|
||||
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered; |
||||
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop])); |
||||
} |
||||
|
||||
componentDidMount () { |
||||
const { intersectionObserverWrapper, id } = this.props; |
||||
|
||||
intersectionObserverWrapper.observe( |
||||
id, |
||||
this.node, |
||||
this.handleIntersection |
||||
); |
||||
|
||||
this.componentMounted = true; |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
const { intersectionObserverWrapper, id } = this.props; |
||||
intersectionObserverWrapper.unobserve(id, this.node); |
||||
|
||||
this.componentMounted = false; |
||||
} |
||||
|
||||
handleIntersection = (entry) => { |
||||
this.entry = entry; |
||||
|
||||
scheduleIdleTask(this.calculateHeight); |
||||
this.setState(this.updateStateAfterIntersection); |
||||
} |
||||
|
||||
updateStateAfterIntersection = (prevState) => { |
||||
if (prevState.isIntersecting && !this.entry.isIntersecting) { |
||||
scheduleIdleTask(this.hideIfNotIntersecting); |
||||
} |
||||
return { |
||||
isIntersecting: this.entry.isIntersecting, |
||||
isHidden: false, |
||||
}; |
||||
} |
||||
|
||||
calculateHeight = () => { |
||||
const { onHeightChange, saveHeightKey, id } = this.props; |
||||
// save the height of the fully-rendered element (this is expensive
|
||||
// on Chrome, where we need to fall back to getBoundingClientRect)
|
||||
this.height = getRectFromEntry(this.entry).height; |
||||
|
||||
if (onHeightChange && saveHeightKey) { |
||||
onHeightChange(saveHeightKey, id, this.height); |
||||
} |
||||
} |
||||
|
||||
hideIfNotIntersecting = () => { |
||||
if (!this.componentMounted) { |
||||
return; |
||||
} |
||||
|
||||
// When the browser gets a chance, test if we're still not intersecting,
|
||||
// and if so, set our isHidden to true to trigger an unrender. The point of
|
||||
// this is to save DOM nodes and avoid using up too much memory.
|
||||
// See: https://github.com/tootsuite/mastodon/issues/2900
|
||||
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); |
||||
} |
||||
|
||||
handleRef = (node) => { |
||||
this.node = node; |
||||
} |
||||
|
||||
render () { |
||||
const { children, id, index, listLength, cachedHeight } = this.props; |
||||
const { isIntersecting, isHidden } = this.state; |
||||
|
||||
if (!isIntersecting && (isHidden || cachedHeight)) { |
||||
return ( |
||||
<article |
||||
ref={this.handleRef} |
||||
aria-posinset={index} |
||||
aria-setsize={listLength} |
||||
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }} |
||||
data-id={id} |
||||
tabIndex='0' |
||||
> |
||||
{children && React.cloneElement(children, { hidden: true })} |
||||
</article> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<article ref={this.handleRef} aria-posinset={index} aria-setsize={listLength} data-id={id} tabIndex='0'> |
||||
{children && React.cloneElement(children, { hidden: false })} |
||||
</article> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,26 @@ |
||||
import React from 'react'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import PropTypes from 'prop-types'; |
||||
|
||||
export default class LoadMore extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
onClick: PropTypes.func, |
||||
visible: PropTypes.bool, |
||||
} |
||||
|
||||
static defaultProps = { |
||||
visible: true, |
||||
} |
||||
|
||||
render() { |
||||
const { visible } = this.props; |
||||
|
||||
return ( |
||||
<button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}> |
||||
<FormattedMessage id='status.load_more' defaultMessage='Load more' /> |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,11 @@ |
||||
import React from 'react'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
|
||||
const LoadingIndicator = () => ( |
||||
<div className='loading-indicator'> |
||||
<div className='loading-indicator__figure' /> |
||||
<FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> |
||||
</div> |
||||
); |
||||
|
||||
export default LoadingIndicator; |
@ -0,0 +1,278 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import { is } from 'immutable'; |
||||
import IconButton from './icon_button'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import { isIOS } from '../is_mobile'; |
||||
import classNames from 'classnames'; |
||||
import { autoPlayGif } from '../initial_state'; |
||||
|
||||
const messages = defineMessages({ |
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, |
||||
}); |
||||
|
||||
class Item extends React.PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
attachment: ImmutablePropTypes.map.isRequired, |
||||
standalone: PropTypes.bool, |
||||
index: PropTypes.number.isRequired, |
||||
size: PropTypes.number.isRequired, |
||||
onClick: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
standalone: false, |
||||
index: 0, |
||||
size: 1, |
||||
}; |
||||
|
||||
handleMouseEnter = (e) => { |
||||
if (this.hoverToPlay()) { |
||||
e.target.play(); |
||||
} |
||||
} |
||||
|
||||
handleMouseLeave = (e) => { |
||||
if (this.hoverToPlay()) { |
||||
e.target.pause(); |
||||
e.target.currentTime = 0; |
||||
} |
||||
} |
||||
|
||||
hoverToPlay () { |
||||
const { attachment } = this.props; |
||||
return !autoPlayGif && attachment.get('type') === 'gifv'; |
||||
} |
||||
|
||||
handleClick = (e) => { |
||||
const { index, onClick } = this.props; |
||||
|
||||
if (this.context.router && e.button === 0) { |
||||
e.preventDefault(); |
||||
onClick(index); |
||||
} |
||||
|
||||
e.stopPropagation(); |
||||
} |
||||
|
||||
render () { |
||||
const { attachment, index, size, standalone } = this.props; |
||||
|
||||
let width = 50; |
||||
let height = 100; |
||||
let top = 'auto'; |
||||
let left = 'auto'; |
||||
let bottom = 'auto'; |
||||
let right = 'auto'; |
||||
|
||||
if (size === 1) { |
||||
width = 100; |
||||
} |
||||
|
||||
if (size === 4 || (size === 3 && index > 0)) { |
||||
height = 50; |
||||
} |
||||
|
||||
if (size === 2) { |
||||
if (index === 0) { |
||||
right = '2px'; |
||||
} else { |
||||
left = '2px'; |
||||
} |
||||
} else if (size === 3) { |
||||
if (index === 0) { |
||||
right = '2px'; |
||||
} else if (index > 0) { |
||||
left = '2px'; |
||||
} |
||||
|
||||
if (index === 1) { |
||||
bottom = '2px'; |
||||
} else if (index > 1) { |
||||
top = '2px'; |
||||
} |
||||
} else if (size === 4) { |
||||
if (index === 0 || index === 2) { |
||||
right = '2px'; |
||||
} |
||||
|
||||
if (index === 1 || index === 3) { |
||||
left = '2px'; |
||||
} |
||||
|
||||
if (index < 2) { |
||||
bottom = '2px'; |
||||
} else { |
||||
top = '2px'; |
||||
} |
||||
} |
||||
|
||||
let thumbnail = ''; |
||||
|
||||
if (attachment.get('type') === 'image') { |
||||
const previewUrl = attachment.get('preview_url'); |
||||
const previewWidth = attachment.getIn(['meta', 'small', 'width']); |
||||
|
||||
const originalUrl = attachment.get('url'); |
||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']); |
||||
|
||||
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; |
||||
|
||||
const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; |
||||
const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; |
||||
|
||||
thumbnail = ( |
||||
<a |
||||
className='media-gallery__item-thumbnail' |
||||
href={attachment.get('remote_url') || originalUrl} |
||||
onClick={this.handleClick} |
||||
target='_blank' |
||||
> |
||||
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} /> |
||||
</a> |
||||
); |
||||
} else if (attachment.get('type') === 'gifv') { |
||||
const autoPlay = !isIOS() && autoPlayGif; |
||||
|
||||
thumbnail = ( |
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> |
||||
<video |
||||
className='media-gallery__item-gifv-thumbnail' |
||||
aria-label={attachment.get('description')} |
||||
role='application' |
||||
src={attachment.get('url')} |
||||
onClick={this.handleClick} |
||||
onMouseEnter={this.handleMouseEnter} |
||||
onMouseLeave={this.handleMouseLeave} |
||||
autoPlay={autoPlay} |
||||
loop |
||||
muted |
||||
/> |
||||
|
||||
<span className='media-gallery__gifv__label'>GIF</span> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> |
||||
{thumbnail} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
@injectIntl |
||||
export default class MediaGallery extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
sensitive: PropTypes.bool, |
||||
standalone: PropTypes.bool, |
||||
media: ImmutablePropTypes.list.isRequired, |
||||
size: PropTypes.object, |
||||
height: PropTypes.number.isRequired, |
||||
onOpenMedia: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
standalone: false, |
||||
}; |
||||
|
||||
state = { |
||||
visible: !this.props.sensitive, |
||||
}; |
||||
|
||||
componentWillReceiveProps (nextProps) { |
||||
if (!is(nextProps.media, this.props.media)) { |
||||
this.setState({ visible: !nextProps.sensitive }); |
||||
} |
||||
} |
||||
|
||||
handleOpen = () => { |
||||
this.setState({ visible: !this.state.visible }); |
||||
} |
||||
|
||||
handleClick = (index) => { |
||||
this.props.onOpenMedia(this.props.media, index); |
||||
} |
||||
|
||||
handleRef = (node) => { |
||||
if (node && this.isStandaloneEligible()) { |
||||
// offsetWidth triggers a layout, so only calculate when we need to
|
||||
this.setState({ |
||||
width: node.offsetWidth, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
isStandaloneEligible() { |
||||
const { media, standalone } = this.props; |
||||
return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); |
||||
} |
||||
|
||||
render () { |
||||
const { media, intl, sensitive, height } = this.props; |
||||
const { width, visible } = this.state; |
||||
|
||||
let children; |
||||
|
||||
const style = {}; |
||||
|
||||
if (this.isStandaloneEligible()) { |
||||
if (!visible && width) { |
||||
// only need to forcibly set the height in "sensitive" mode
|
||||
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); |
||||
} else { |
||||
// layout automatically, using image's natural aspect ratio
|
||||
style.height = ''; |
||||
} |
||||
} else { |
||||
// crop the image
|
||||
style.height = height; |
||||
} |
||||
|
||||
if (!visible) { |
||||
let warning; |
||||
|
||||
if (sensitive) { |
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; |
||||
} else { |
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; |
||||
} |
||||
|
||||
children = ( |
||||
<button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}> |
||||
<span className='media-spoiler__warning'>{warning}</span> |
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> |
||||
</button> |
||||
); |
||||
} else { |
||||
const size = media.take(4).size; |
||||
|
||||
if (this.isStandaloneEligible()) { |
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />; |
||||
} else { |
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />); |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className='media-gallery' style={style}> |
||||
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}> |
||||
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> |
||||
</div> |
||||
|
||||
{children} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,12 @@ |
||||
import React from 'react'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
|
||||
const MissingIndicator = () => ( |
||||
<div className='missing-indicator'> |
||||
<div> |
||||
<FormattedMessage id='missing_indicator.label' defaultMessage='Not found' /> |
||||
</div> |
||||
</div> |
||||
); |
||||
|
||||
export default MissingIndicator; |
@ -0,0 +1,34 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
|
||||
export default class Permalink extends React.PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
className: PropTypes.string, |
||||
href: PropTypes.string.isRequired, |
||||
to: PropTypes.string.isRequired, |
||||
children: PropTypes.node, |
||||
}; |
||||
|
||||
handleClick = (e) => { |
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { |
||||
e.preventDefault(); |
||||
this.context.router.history.push(this.props.to); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { href, children, className, ...other } = this.props; |
||||
|
||||
return ( |
||||
<a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}> |
||||
{children} |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,147 @@ |
||||
import React from 'react'; |
||||
import { injectIntl, defineMessages } from 'react-intl'; |
||||
import PropTypes from 'prop-types'; |
||||
|
||||
const messages = defineMessages({ |
||||
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, |
||||
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, |
||||
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, |
||||
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, |
||||
days: { id: 'relative_time.days', defaultMessage: '{number}d' }, |
||||
}); |
||||
|
||||
const dateFormatOptions = { |
||||
hour12: false, |
||||
year: 'numeric', |
||||
month: 'short', |
||||
day: '2-digit', |
||||
hour: '2-digit', |
||||
minute: '2-digit', |
||||
}; |
||||
|
||||
const shortDateFormatOptions = { |
||||
month: 'numeric', |
||||
day: 'numeric', |
||||
}; |
||||
|
||||
const SECOND = 1000; |
||||
const MINUTE = 1000 * 60; |
||||
const HOUR = 1000 * 60 * 60; |
||||
const DAY = 1000 * 60 * 60 * 24; |
||||
|
||||
const MAX_DELAY = 2147483647; |
||||
|
||||
const selectUnits = delta => { |
||||
const absDelta = Math.abs(delta); |
||||
|
||||
if (absDelta < MINUTE) { |
||||
return 'second'; |
||||
} else if (absDelta < HOUR) { |
||||
return 'minute'; |
||||
} else if (absDelta < DAY) { |
||||
return 'hour'; |
||||
} |
||||
|
||||
return 'day'; |
||||
}; |
||||
|
||||
const getUnitDelay = units => { |
||||
switch (units) { |
||||
case 'second': |
||||
return SECOND; |
||||
case 'minute': |
||||
return MINUTE; |
||||
case 'hour': |
||||
return HOUR; |
||||
case 'day': |
||||
return DAY; |
||||
default: |
||||
return MAX_DELAY; |
||||
} |
||||
}; |
||||
|
||||
@injectIntl |
||||
export default class RelativeTimestamp extends React.Component { |
||||
|
||||
static propTypes = { |
||||
intl: PropTypes.object.isRequired, |
||||
timestamp: PropTypes.string.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
now: this.props.intl.now(), |
||||
}; |
||||
|
||||
shouldComponentUpdate (nextProps, nextState) { |
||||
// As of right now the locale doesn't change without a new page load,
|
||||
// but we might as well check in case that ever changes.
|
||||
return this.props.timestamp !== nextProps.timestamp || |
||||
this.props.intl.locale !== nextProps.intl.locale || |
||||
this.state.now !== nextState.now; |
||||
} |
||||
|
||||
componentWillReceiveProps (nextProps) { |
||||
if (this.props.timestamp !== nextProps.timestamp) { |
||||
this.setState({ now: this.props.intl.now() }); |
||||
} |
||||
} |
||||
|
||||
componentDidMount () { |
||||
this._scheduleNextUpdate(this.props, this.state); |
||||
} |
||||
|
||||
componentWillUpdate (nextProps, nextState) { |
||||
this._scheduleNextUpdate(nextProps, nextState); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
clearTimeout(this._timer); |
||||
} |
||||
|
||||
_scheduleNextUpdate (props, state) { |
||||
clearTimeout(this._timer); |
||||
|
||||
const { timestamp } = props; |
||||
const delta = (new Date(timestamp)).getTime() - state.now; |
||||
const unitDelay = getUnitDelay(selectUnits(delta)); |
||||
const unitRemainder = Math.abs(delta % unitDelay); |
||||
const updateInterval = 1000 * 10; |
||||
const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder); |
||||
|
||||
this._timer = setTimeout(() => { |
||||
this.setState({ now: this.props.intl.now() }); |
||||
}, delay); |
||||
} |
||||
|
||||
render () { |
||||
const { timestamp, intl } = this.props; |
||||
|
||||
const date = new Date(timestamp); |
||||
const delta = this.state.now - date.getTime(); |
||||
|
||||
let relativeTime; |
||||
|
||||
if (delta < 10 * SECOND) { |
||||
relativeTime = intl.formatMessage(messages.just_now); |
||||
} else if (delta < 3 * DAY) { |
||||
if (delta < MINUTE) { |
||||
relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); |
||||
} else if (delta < HOUR) { |
||||
relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); |
||||
} else if (delta < DAY) { |
||||
relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); |
||||
} else { |
||||
relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); |
||||
} |
||||
} else { |
||||
relativeTime = intl.formatDate(date, shortDateFormatOptions); |
||||
} |
||||
|
||||
return ( |
||||
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> |
||||
{relativeTime} |
||||
</time> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,198 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
import { ScrollContainer } from 'react-router-scroll-4'; |
||||
import PropTypes from 'prop-types'; |
||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; |
||||
import LoadMore from './load_more'; |
||||
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; |
||||
import { throttle } from 'lodash'; |
||||
import { List as ImmutableList } from 'immutable'; |
||||
import classNames from 'classnames'; |
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; |
||||
|
||||
export default class ScrollableList extends PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
scrollKey: PropTypes.string.isRequired, |
||||
onScrollToBottom: PropTypes.func, |
||||
onScrollToTop: PropTypes.func, |
||||
onScroll: PropTypes.func, |
||||
trackScroll: PropTypes.bool, |
||||
shouldUpdateScroll: PropTypes.func, |
||||
isLoading: PropTypes.bool, |
||||
hasMore: PropTypes.bool, |
||||
prepend: PropTypes.node, |
||||
emptyMessage: PropTypes.node, |
||||
children: PropTypes.node, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
trackScroll: true, |
||||
}; |
||||
|
||||
state = { |
||||
lastMouseMove: null, |
||||
}; |
||||
|
||||
intersectionObserverWrapper = new IntersectionObserverWrapper(); |
||||
|
||||
handleScroll = throttle(() => { |
||||
if (this.node) { |
||||
const { scrollTop, scrollHeight, clientHeight } = this.node; |
||||
const offset = scrollHeight - scrollTop - clientHeight; |
||||
this._oldScrollPosition = scrollHeight - scrollTop; |
||||
|
||||
if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { |
||||
this.props.onScrollToBottom(); |
||||
} else if (scrollTop < 100 && this.props.onScrollToTop) { |
||||
this.props.onScrollToTop(); |
||||
} else if (this.props.onScroll) { |
||||
this.props.onScroll(); |
||||
} |
||||
} |
||||
}, 150, { |
||||
trailing: true, |
||||
}); |
||||
|
||||
handleMouseMove = throttle(() => { |
||||
this._lastMouseMove = new Date(); |
||||
}, 300); |
||||
|
||||
handleMouseLeave = () => { |
||||
this._lastMouseMove = null; |
||||
} |
||||
|
||||
componentDidMount () { |
||||
this.attachScrollListener(); |
||||
this.attachIntersectionObserver(); |
||||
attachFullscreenListener(this.onFullScreenChange); |
||||
|
||||
// Handle initial scroll posiiton
|
||||
this.handleScroll(); |
||||
} |
||||
|
||||
componentDidUpdate (prevProps) { |
||||
const someItemInserted = React.Children.count(prevProps.children) > 0 && |
||||
React.Children.count(prevProps.children) < React.Children.count(this.props.children) && |
||||
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); |
||||
|
||||
// Reset the scroll position when a new child comes in in order not to
|
||||
// jerk the scrollbar around if you're already scrolled down the page.
|
||||
if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) { |
||||
const newScrollTop = this.node.scrollHeight - this._oldScrollPosition; |
||||
|
||||
if (this.node.scrollTop !== newScrollTop) { |
||||
this.node.scrollTop = newScrollTop; |
||||
} |
||||
} else { |
||||
this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; |
||||
} |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
this.detachScrollListener(); |
||||
this.detachIntersectionObserver(); |
||||
detachFullscreenListener(this.onFullScreenChange); |
||||
} |
||||
|
||||
onFullScreenChange = () => { |
||||
this.setState({ fullscreen: isFullscreen() }); |
||||
} |
||||
|
||||
attachIntersectionObserver () { |
||||
this.intersectionObserverWrapper.connect({ |
||||
root: this.node, |
||||
rootMargin: '300% 0px', |
||||
}); |
||||
} |
||||
|
||||
detachIntersectionObserver () { |
||||
this.intersectionObserverWrapper.disconnect(); |
||||
} |
||||
|
||||
attachScrollListener () { |
||||
this.node.addEventListener('scroll', this.handleScroll); |
||||
} |
||||
|
||||
detachScrollListener () { |
||||
this.node.removeEventListener('scroll', this.handleScroll); |
||||
} |
||||
|
||||
getFirstChildKey (props) { |
||||
const { children } = props; |
||||
let firstChild = children; |
||||
if (children instanceof ImmutableList) { |
||||
firstChild = children.get(0); |
||||
} else if (Array.isArray(children)) { |
||||
firstChild = children[0]; |
||||
} |
||||
return firstChild && firstChild.key; |
||||
} |
||||
|
||||
setRef = (c) => { |
||||
this.node = c; |
||||
} |
||||
|
||||
handleLoadMore = (e) => { |
||||
e.preventDefault(); |
||||
this.props.onScrollToBottom(); |
||||
} |
||||
|
||||
_recentlyMoved () { |
||||
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); |
||||
} |
||||
|
||||
render () { |
||||
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; |
||||
const { fullscreen } = this.state; |
||||
const childrenCount = React.Children.count(children); |
||||
|
||||
const loadMore = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; |
||||
let scrollableArea = null; |
||||
|
||||
if (isLoading || childrenCount > 0 || !emptyMessage) { |
||||
scrollableArea = ( |
||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}> |
||||
<div role='feed' className='item-list'> |
||||
{prepend} |
||||
|
||||
{React.Children.map(this.props.children, (child, index) => ( |
||||
<IntersectionObserverArticleContainer |
||||
key={child.key} |
||||
id={child.key} |
||||
index={index} |
||||
listLength={childrenCount} |
||||
intersectionObserverWrapper={this.intersectionObserverWrapper} |
||||
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} |
||||
> |
||||
{child} |
||||
</IntersectionObserverArticleContainer> |
||||
))} |
||||
|
||||
{loadMore} |
||||
</div> |
||||
</div> |
||||
); |
||||
} else { |
||||
scrollableArea = ( |
||||
<div className='empty-column-indicator' ref={this.setRef}> |
||||
{emptyMessage} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (trackScroll) { |
||||
return ( |
||||
<ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> |
||||
{scrollableArea} |
||||
</ScrollContainer> |
||||
); |
||||
} else { |
||||
return scrollableArea; |
||||
} |
||||
} |
||||
|
||||
} |
@ -0,0 +1,34 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
|
||||
export default class SettingText extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
settings: ImmutablePropTypes.map.isRequired, |
||||
settingKey: PropTypes.array.isRequired, |
||||
label: PropTypes.string.isRequired, |
||||
onChange: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
handleChange = (e) => { |
||||
this.props.onChange(this.props.settingKey, e.target.value); |
||||
} |
||||
|
||||
render () { |
||||
const { settings, settingKey, label } = this.props; |
||||
|
||||
return ( |
||||
<label> |
||||
<span style={{ display: 'none' }}>{label}</span> |
||||
<input |
||||
className='setting-text' |
||||
value={settings.getIn(settingKey)} |
||||
onChange={this.handleChange} |
||||
placeholder={label} |
||||
/> |
||||
</label> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,246 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import Avatar from './avatar'; |
||||
import AvatarOverlay from './avatar_overlay'; |
||||
import RelativeTimestamp from './relative_timestamp'; |
||||
import DisplayName from './display_name'; |
||||
import StatusContent from './status_content'; |
||||
import StatusActionBar from './status_action_bar'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { MediaGallery, Video } from '../features/ui/util/async-components'; |
||||
import { HotKeys } from 'react-hotkeys'; |
||||
import classNames from 'classnames'; |
||||
|
||||
// We use the component (and not the container) since we do not want
|
||||
// to use the progress bar to show download progress
|
||||
import Bundle from '../features/ui/components/bundle'; |
||||
|
||||
export default class Status extends ImmutablePureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
status: ImmutablePropTypes.map, |
||||
account: ImmutablePropTypes.map, |
||||
onReply: PropTypes.func, |
||||
onFavourite: PropTypes.func, |
||||
onReblog: PropTypes.func, |
||||
onDelete: PropTypes.func, |
||||
onPin: PropTypes.func, |
||||
onOpenMedia: PropTypes.func, |
||||
onOpenVideo: PropTypes.func, |
||||
onBlock: PropTypes.func, |
||||
onEmbed: PropTypes.func, |
||||
onHeightChange: PropTypes.func, |
||||
muted: PropTypes.bool, |
||||
hidden: PropTypes.bool, |
||||
onMoveUp: PropTypes.func, |
||||
onMoveDown: PropTypes.func, |
||||
}; |
||||
|
||||
state = { |
||||
isExpanded: false, |
||||
} |
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
updateOnProps = [ |
||||
'status', |
||||
'account', |
||||
'muted', |
||||
'hidden', |
||||
] |
||||
|
||||
updateOnStates = ['isExpanded'] |
||||
|
||||
handleClick = () => { |
||||
if (!this.context.router) { |
||||
return; |
||||
} |
||||
|
||||
const { status } = this.props; |
||||
this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); |
||||
} |
||||
|
||||
handleAccountClick = (e) => { |
||||
if (this.context.router && e.button === 0) { |
||||
const id = e.currentTarget.getAttribute('data-id'); |
||||
e.preventDefault(); |
||||
this.context.router.history.push(`/accounts/${id}`); |
||||
} |
||||
} |
||||
|
||||
handleExpandedToggle = () => { |
||||
this.setState({ isExpanded: !this.state.isExpanded }); |
||||
}; |
||||
|
||||
renderLoadingMediaGallery () { |
||||
return <div className='media_gallery' style={{ height: '110px' }} />; |
||||
} |
||||
|
||||
renderLoadingVideoPlayer () { |
||||
return <div className='media-spoiler-video' style={{ height: '110px' }} />; |
||||
} |
||||
|
||||
handleOpenVideo = startTime => { |
||||
this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime); |
||||
} |
||||
|
||||
handleHotkeyReply = e => { |
||||
e.preventDefault(); |
||||
this.props.onReply(this._properStatus(), this.context.router.history); |
||||
} |
||||
|
||||
handleHotkeyFavourite = () => { |
||||
this.props.onFavourite(this._properStatus()); |
||||
} |
||||
|
||||
handleHotkeyBoost = e => { |
||||
this.props.onReblog(this._properStatus(), e); |
||||
} |
||||
|
||||
handleHotkeyMention = e => { |
||||
e.preventDefault(); |
||||
this.props.onMention(this._properStatus().get('account'), this.context.router.history); |
||||
} |
||||
|
||||
handleHotkeyOpen = () => { |
||||
this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`); |
||||
} |
||||
|
||||
handleHotkeyOpenProfile = () => { |
||||
this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`); |
||||
} |
||||
|
||||
handleHotkeyMoveUp = () => { |
||||
this.props.onMoveUp(this.props.status.get('id')); |
||||
} |
||||
|
||||
handleHotkeyMoveDown = () => { |
||||
this.props.onMoveDown(this.props.status.get('id')); |
||||
} |
||||
|
||||
_properStatus () { |
||||
const { status } = this.props; |
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { |
||||
return status.get('reblog'); |
||||
} else { |
||||
return status; |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
let media = null; |
||||
let statusAvatar, prepend; |
||||
|
||||
const { hidden } = this.props; |
||||
const { isExpanded } = this.state; |
||||
|
||||
let { status, account, ...other } = this.props; |
||||
|
||||
if (status === null) { |
||||
return null; |
||||
} |
||||
|
||||
if (hidden) { |
||||
return ( |
||||
<div> |
||||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} |
||||
{status.get('content')} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { |
||||
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; |
||||
|
||||
prepend = ( |
||||
<div className='status__prepend'> |
||||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> |
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} /> |
||||
</div> |
||||
); |
||||
|
||||
account = status.get('account'); |
||||
status = status.get('reblog'); |
||||
} |
||||
|
||||
if (status.get('media_attachments').size > 0 && !this.props.muted) { |
||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { |
||||
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { |
||||
const video = status.getIn(['media_attachments', 0]); |
||||
|
||||
media = ( |
||||
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > |
||||
{Component => <Component |
||||
preview={video.get('preview_url')} |
||||
src={video.get('url')} |
||||
width={239} |
||||
height={110} |
||||
sensitive={status.get('sensitive')} |
||||
onOpenVideo={this.handleOpenVideo} |
||||
/>} |
||||
</Bundle> |
||||
); |
||||
} else { |
||||
media = ( |
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} > |
||||
{Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />} |
||||
</Bundle> |
||||
); |
||||
} |
||||
} |
||||
|
||||
if (account === undefined || account === null) { |
||||
statusAvatar = <Avatar account={status.get('account')} size={48} />; |
||||
}else{ |
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />; |
||||
} |
||||
|
||||
const handlers = this.props.muted ? {} : { |
||||
reply: this.handleHotkeyReply, |
||||
favourite: this.handleHotkeyFavourite, |
||||
boost: this.handleHotkeyBoost, |
||||
mention: this.handleHotkeyMention, |
||||
open: this.handleHotkeyOpen, |
||||
openProfile: this.handleHotkeyOpenProfile, |
||||
moveUp: this.handleHotkeyMoveUp, |
||||
moveDown: this.handleHotkeyMoveDown, |
||||
}; |
||||
|
||||
return ( |
||||
<HotKeys handlers={handlers}> |
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}> |
||||
{prepend} |
||||
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}> |
||||
<div className='status__info'> |
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> |
||||
|
||||
<a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'> |
||||
<div className='status__avatar'> |
||||
{statusAvatar} |
||||
</div> |
||||
|
||||
<DisplayName account={status.get('account')} /> |
||||
</a> |
||||
</div> |
||||
|
||||
<StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} /> |
||||
|
||||
{media} |
||||
|
||||
<StatusActionBar status={status} account={account} {...other} /> |
||||
</div> |
||||
</div> |
||||
</HotKeys> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,188 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import IconButton from './icon_button'; |
||||
import DropdownMenuContainer from '../containers/dropdown_menu_container'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { me } from '../initial_state'; |
||||
|
||||
const messages = defineMessages({ |
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' }, |
||||
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, |
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, |
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, |
||||
reply: { id: 'status.reply', defaultMessage: 'Reply' }, |
||||
share: { id: 'status.share', defaultMessage: 'Share' }, |
||||
more: { id: 'status.more', defaultMessage: 'More' }, |
||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, |
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, |
||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, |
||||
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, |
||||
open: { id: 'status.open', defaultMessage: 'Expand this status' }, |
||||
report: { id: 'status.report', defaultMessage: 'Report @{name}' }, |
||||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, |
||||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, |
||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, |
||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, |
||||
embed: { id: 'status.embed', defaultMessage: 'Embed' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class StatusActionBar extends ImmutablePureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
status: ImmutablePropTypes.map.isRequired, |
||||
onReply: PropTypes.func, |
||||
onFavourite: PropTypes.func, |
||||
onReblog: PropTypes.func, |
||||
onDelete: PropTypes.func, |
||||
onMention: PropTypes.func, |
||||
onMute: PropTypes.func, |
||||
onBlock: PropTypes.func, |
||||
onReport: PropTypes.func, |
||||
onEmbed: PropTypes.func, |
||||
onMuteConversation: PropTypes.func, |
||||
onPin: PropTypes.func, |
||||
withDismiss: PropTypes.bool, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
// evaluate to false. See react-immutable-pure-component for usage.
|
||||
updateOnProps = [ |
||||
'status', |
||||
'withDismiss', |
||||
] |
||||
|
||||
handleReplyClick = () => { |
||||
this.props.onReply(this.props.status, this.context.router.history); |
||||
} |
||||
|
||||
handleShareClick = () => { |
||||
navigator.share({ |
||||
text: this.props.status.get('search_index'), |
||||
url: this.props.status.get('url'), |
||||
}); |
||||
} |
||||
|
||||
handleFavouriteClick = () => { |
||||
this.props.onFavourite(this.props.status); |
||||
} |
||||
|
||||
handleReblogClick = (e) => { |
||||
this.props.onReblog(this.props.status, e); |
||||
} |
||||
|
||||
handleDeleteClick = () => { |
||||
this.props.onDelete(this.props.status); |
||||
} |
||||
|
||||
handlePinClick = () => { |
||||
this.props.onPin(this.props.status); |
||||
} |
||||
|
||||
handleMentionClick = () => { |
||||
this.props.onMention(this.props.status.get('account'), this.context.router.history); |
||||
} |
||||
|
||||
handleMuteClick = () => { |
||||
this.props.onMute(this.props.status.get('account')); |
||||
} |
||||
|
||||
handleBlockClick = () => { |
||||
this.props.onBlock(this.props.status.get('account')); |
||||
} |
||||
|
||||
handleOpen = () => { |
||||
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); |
||||
} |
||||
|
||||
handleEmbed = () => { |
||||
this.props.onEmbed(this.props.status); |
||||
} |
||||
|
||||
handleReport = () => { |
||||
this.props.onReport(this.props.status); |
||||
} |
||||
|
||||
handleConversationMuteClick = () => { |
||||
this.props.onMuteConversation(this.props.status); |
||||
} |
||||
|
||||
render () { |
||||
const { status, intl, withDismiss } = this.props; |
||||
|
||||
const mutingConversation = status.get('muted'); |
||||
const anonymousAccess = !me; |
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); |
||||
|
||||
let menu = []; |
||||
let reblogIcon = 'retweet'; |
||||
let replyIcon; |
||||
let replyTitle; |
||||
|
||||
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); |
||||
|
||||
if (publicStatus) { |
||||
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); |
||||
} |
||||
|
||||
menu.push(null); |
||||
|
||||
if (status.getIn(['account', 'id']) === me || withDismiss) { |
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); |
||||
menu.push(null); |
||||
} |
||||
|
||||
if (status.getIn(['account', 'id']) === me) { |
||||
if (publicStatus) { |
||||
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); |
||||
} |
||||
|
||||
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); |
||||
} else { |
||||
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); |
||||
menu.push(null); |
||||
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); |
||||
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); |
||||
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); |
||||
} |
||||
|
||||
if (status.get('visibility') === 'direct') { |
||||
reblogIcon = 'envelope'; |
||||
} else if (status.get('visibility') === 'private') { |
||||
reblogIcon = 'lock'; |
||||
} |
||||
|
||||
if (status.get('in_reply_to_id', null) === null) { |
||||
replyIcon = 'reply'; |
||||
replyTitle = intl.formatMessage(messages.reply); |
||||
} else { |
||||
replyIcon = 'reply-all'; |
||||
replyTitle = intl.formatMessage(messages.replyAll); |
||||
} |
||||
|
||||
const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( |
||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} /> |
||||
); |
||||
|
||||
return ( |
||||
<div className='status__action-bar'> |
||||
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /> |
||||
<IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /> |
||||
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> |
||||
{shareButton} |
||||
|
||||
<div className='status__action-bar-dropdown'> |
||||
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,185 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import { isRtl } from '../rtl'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import Permalink from './permalink'; |
||||
import classnames from 'classnames'; |
||||
|
||||
export default class StatusContent extends React.PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
status: ImmutablePropTypes.map.isRequired, |
||||
expanded: PropTypes.bool, |
||||
onExpandedToggle: PropTypes.func, |
||||
onClick: PropTypes.func, |
||||
}; |
||||
|
||||
state = { |
||||
hidden: true, |
||||
}; |
||||
|
||||
_updateStatusLinks () { |
||||
const node = this.node; |
||||
const links = node.querySelectorAll('a'); |
||||
|
||||
for (var i = 0; i < links.length; ++i) { |
||||
let link = links[i]; |
||||
if (link.classList.contains('status-link')) { |
||||
continue; |
||||
} |
||||
link.classList.add('status-link'); |
||||
|
||||
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); |
||||
|
||||
if (mention) { |
||||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false); |
||||
link.setAttribute('title', mention.get('acct')); |
||||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { |
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); |
||||
} else { |
||||
link.setAttribute('title', link.href); |
||||
} |
||||
|
||||
link.setAttribute('target', '_blank'); |
||||
link.setAttribute('rel', 'noopener'); |
||||
} |
||||
} |
||||
|
||||
componentDidMount () { |
||||
this._updateStatusLinks(); |
||||
} |
||||
|
||||
componentDidUpdate () { |
||||
this._updateStatusLinks(); |
||||
} |
||||
|
||||
onMentionClick = (mention, e) => { |
||||
if (this.context.router && e.button === 0) { |
||||
e.preventDefault(); |
||||
this.context.router.history.push(`/accounts/${mention.get('id')}`); |
||||
} |
||||
} |
||||
|
||||
onHashtagClick = (hashtag, e) => { |
||||
hashtag = hashtag.replace(/^#/, '').toLowerCase(); |
||||
|
||||
if (this.context.router && e.button === 0) { |
||||
e.preventDefault(); |
||||
this.context.router.history.push(`/timelines/tag/${hashtag}`); |
||||
} |
||||
} |
||||
|
||||
handleMouseDown = (e) => { |
||||
this.startXY = [e.clientX, e.clientY]; |
||||
} |
||||
|
||||
handleMouseUp = (e) => { |
||||
if (!this.startXY) { |
||||
return; |
||||
} |
||||
|
||||
const [ startX, startY ] = this.startXY; |
||||
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; |
||||
|
||||
if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { |
||||
return; |
||||
} |
||||
|
||||
if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) { |
||||
this.props.onClick(); |
||||
} |
||||
|
||||
this.startXY = null; |
||||
} |
||||
|
||||
handleSpoilerClick = (e) => { |
||||
e.preventDefault(); |
||||
|
||||
if (this.props.onExpandedToggle) { |
||||
// The parent manages the state
|
||||
this.props.onExpandedToggle(); |
||||
} else { |
||||
this.setState({ hidden: !this.state.hidden }); |
||||
} |
||||
} |
||||
|
||||
setRef = (c) => { |
||||
this.node = c; |
||||
} |
||||
|
||||
render () { |
||||
const { status } = this.props; |
||||
|
||||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; |
||||
|
||||
const content = { __html: status.get('contentHtml') }; |
||||
const spoilerContent = { __html: status.get('spoilerHtml') }; |
||||
const directionStyle = { direction: 'ltr' }; |
||||
const classNames = classnames('status__content', { |
||||
'status__content--with-action': this.props.onClick && this.context.router, |
||||
'status__content--with-spoiler': status.get('spoiler_text').length > 0, |
||||
}); |
||||
|
||||
if (isRtl(status.get('search_index'))) { |
||||
directionStyle.direction = 'rtl'; |
||||
} |
||||
|
||||
if (status.get('spoiler_text').length > 0) { |
||||
let mentionsPlaceholder = ''; |
||||
|
||||
const mentionLinks = status.get('mentions').map(item => ( |
||||
<Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'> |
||||
@<span>{item.get('username')}</span> |
||||
</Permalink> |
||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], []); |
||||
|
||||
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />; |
||||
|
||||
if (hidden) { |
||||
mentionsPlaceholder = <div>{mentionLinks}</div>; |
||||
} |
||||
|
||||
return ( |
||||
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> |
||||
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> |
||||
<span dangerouslySetInnerHTML={spoilerContent} /> |
||||
{' '} |
||||
<button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>{toggleText}</button> |
||||
</p> |
||||
|
||||
{mentionsPlaceholder} |
||||
|
||||
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> |
||||
</div> |
||||
); |
||||
} else if (this.props.onClick) { |
||||
return ( |
||||
<div |
||||
ref={this.setRef} |
||||
tabIndex='0' |
||||
className={classNames} |
||||
style={directionStyle} |
||||
onMouseDown={this.handleMouseDown} |
||||
onMouseUp={this.handleMouseUp} |
||||
dangerouslySetInnerHTML={content} |
||||
/> |
||||
); |
||||
} else { |
||||
return ( |
||||
<div |
||||
tabIndex='0' |
||||
ref={this.setRef} |
||||
className='status__content' |
||||
style={directionStyle} |
||||
dangerouslySetInnerHTML={content} |
||||
/> |
||||
); |
||||
} |
||||
} |
||||
|
||||
} |
@ -0,0 +1,72 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import StatusContainer from '../containers/status_container'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import ScrollableList from './scrollable_list'; |
||||
|
||||
export default class StatusList extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
scrollKey: PropTypes.string.isRequired, |
||||
statusIds: ImmutablePropTypes.list.isRequired, |
||||
onScrollToBottom: PropTypes.func, |
||||
onScrollToTop: PropTypes.func, |
||||
onScroll: PropTypes.func, |
||||
trackScroll: PropTypes.bool, |
||||
shouldUpdateScroll: PropTypes.func, |
||||
isLoading: PropTypes.bool, |
||||
hasMore: PropTypes.bool, |
||||
prepend: PropTypes.node, |
||||
emptyMessage: PropTypes.node, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
trackScroll: true, |
||||
}; |
||||
|
||||
handleMoveUp = id => { |
||||
const elementIndex = this.props.statusIds.indexOf(id) - 1; |
||||
this._selectChild(elementIndex); |
||||
} |
||||
|
||||
handleMoveDown = id => { |
||||
const elementIndex = this.props.statusIds.indexOf(id) + 1; |
||||
this._selectChild(elementIndex); |
||||
} |
||||
|
||||
_selectChild (index) { |
||||
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); |
||||
|
||||
if (element) { |
||||
element.focus(); |
||||
} |
||||
} |
||||
|
||||
setRef = c => { |
||||
this.node = c; |
||||
} |
||||
|
||||
render () { |
||||
const { statusIds, ...other } = this.props; |
||||
const { isLoading } = other; |
||||
|
||||
const scrollableContent = (isLoading || statusIds.size > 0) ? ( |
||||
statusIds.map((statusId) => ( |
||||
<StatusContainer |
||||
key={statusId} |
||||
id={statusId} |
||||
onMoveUp={this.handleMoveUp} |
||||
onMoveDown={this.handleMoveDown} |
||||
/> |
||||
)) |
||||
) : null; |
||||
|
||||
return ( |
||||
<ScrollableList {...other} ref={this.setRef}> |
||||
{scrollableContent} |
||||
</ScrollableList> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,72 @@ |
||||
import React from 'react'; |
||||
import { connect } from 'react-redux'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import { makeGetAccount } from '../selectors'; |
||||
import Account from '../components/account'; |
||||
import { |
||||
followAccount, |
||||
unfollowAccount, |
||||
blockAccount, |
||||
unblockAccount, |
||||
muteAccount, |
||||
unmuteAccount, |
||||
} from '../actions/accounts'; |
||||
import { openModal } from '../actions/modal'; |
||||
import { initMuteModal } from '../actions/mutes'; |
||||
import { unfollowModal } from '../initial_state'; |
||||
|
||||
const messages = defineMessages({ |
||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, |
||||
}); |
||||
|
||||
const makeMapStateToProps = () => { |
||||
const getAccount = makeGetAccount(); |
||||
|
||||
const mapStateToProps = (state, props) => ({ |
||||
account: getAccount(state, props.id), |
||||
}); |
||||
|
||||
return mapStateToProps; |
||||
}; |
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({ |
||||
|
||||
onFollow (account) { |
||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { |
||||
if (unfollowModal) { |
||||
dispatch(openModal('CONFIRM', { |
||||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, |
||||
confirm: intl.formatMessage(messages.unfollowConfirm), |
||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))), |
||||
})); |
||||
} else { |
||||
dispatch(unfollowAccount(account.get('id'))); |
||||
} |
||||
} else { |
||||
dispatch(followAccount(account.get('id'))); |
||||
} |
||||
}, |
||||
|
||||
onBlock (account) { |
||||
if (account.getIn(['relationship', 'blocking'])) { |
||||
dispatch(unblockAccount(account.get('id'))); |
||||
} else { |
||||
dispatch(blockAccount(account.get('id'))); |
||||
} |
||||
}, |
||||
|
||||
onMute (account) { |
||||
if (account.getIn(['relationship', 'muting'])) { |
||||
dispatch(unmuteAccount(account.get('id'))); |
||||
} else { |
||||
dispatch(initMuteModal(account)); |
||||
} |
||||
}, |
||||
|
||||
|
||||
onMuteNotifications (account, notifications) { |
||||
dispatch(muteAccount(account.get('id'), notifications)); |
||||
}, |
||||
}); |
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); |
@ -0,0 +1,18 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import Card from '../features/status/components/card'; |
||||
import { fromJS } from 'immutable'; |
||||
|
||||
export default class CardContainer extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
locale: PropTypes.string, |
||||
card: PropTypes.array.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { card, ...props } = this.props; |
||||
return <Card card={fromJS(card)} {...props} />; |
||||
} |
||||
|
||||
} |
@ -0,0 +1,38 @@ |
||||
import React from 'react'; |
||||
import { Provider } from 'react-redux'; |
||||
import PropTypes from 'prop-types'; |
||||
import configureStore from '../store/configureStore'; |
||||
import { hydrateStore } from '../actions/store'; |
||||
import { IntlProvider, addLocaleData } from 'react-intl'; |
||||
import { getLocale } from '../locales'; |
||||
import Compose from '../features/standalone/compose'; |
||||
import initialState from '../initial_state'; |
||||
|
||||
const { localeData, messages } = getLocale(); |
||||
addLocaleData(localeData); |
||||
|
||||
const store = configureStore(); |
||||
|
||||
if (initialState) { |
||||
store.dispatch(hydrateStore(initialState)); |
||||
} |
||||
|
||||
export default class TimelineContainer extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
locale: PropTypes.string.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { locale } = this.props; |
||||
|
||||
return ( |
||||
<IntlProvider locale={locale} messages={messages}> |
||||
<Provider store={store}> |
||||
<Compose /> |
||||
</Provider> |
||||
</IntlProvider> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,16 @@ |
||||
import { openModal, closeModal } from '../actions/modal'; |
||||
import { connect } from 'react-redux'; |
||||
import DropdownMenu from '../components/dropdown_menu'; |
||||
import { isUserTouching } from '../is_mobile'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
isModalOpen: state.get('modal').modalType === 'ACTIONS', |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
isUserTouching, |
||||
onModalOpen: props => dispatch(openModal('ACTIONS', props)), |
||||
onModalClose: () => dispatch(closeModal()), |
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); |
@ -0,0 +1,17 @@ |
||||
import { connect } from 'react-redux'; |
||||
import IntersectionObserverArticle from '../components/intersection_observer_article'; |
||||
import { setHeight } from '../actions/height_cache'; |
||||
|
||||
const makeMapStateToProps = (state, props) => ({ |
||||
cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), |
||||
}); |
||||
|
||||
const mapDispatchToProps = (dispatch) => ({ |
||||
|
||||
onHeightChange (key, id, height) { |
||||
dispatch(setHeight(key, id, height)); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle); |
@ -0,0 +1,70 @@ |
||||
import React from 'react'; |
||||
import { Provider } from 'react-redux'; |
||||
import PropTypes from 'prop-types'; |
||||
import configureStore from '../store/configureStore'; |
||||
import { showOnboardingOnce } from '../actions/onboarding'; |
||||
import { BrowserRouter, Route } from 'react-router-dom'; |
||||
import { ScrollContext } from 'react-router-scroll-4'; |
||||
import UI from '../features/ui'; |
||||
import { hydrateStore } from '../actions/store'; |
||||
import { connectUserStream } from '../actions/streaming'; |
||||
import { IntlProvider, addLocaleData } from 'react-intl'; |
||||
import { getLocale } from '../locales'; |
||||
import initialState from '../initial_state'; |
||||
|
||||
const { localeData, messages } = getLocale(); |
||||
addLocaleData(localeData); |
||||
|
||||
export const store = configureStore(); |
||||
const hydrateAction = hydrateStore(initialState); |
||||
store.dispatch(hydrateAction); |
||||
|
||||
export default class Mastodon extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
locale: PropTypes.string.isRequired, |
||||
}; |
||||
|
||||
componentDidMount() { |
||||
this.disconnect = store.dispatch(connectUserStream()); |
||||
|
||||
// Desktop notifications
|
||||
// Ask after 1 minute
|
||||
if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { |
||||
window.setTimeout(() => Notification.requestPermission(), 60 * 1000); |
||||
} |
||||
|
||||
// Protocol handler
|
||||
// Ask after 5 minutes
|
||||
if (typeof navigator.registerProtocolHandler !== 'undefined') { |
||||
const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s'; |
||||
window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000); |
||||
} |
||||
|
||||
store.dispatch(showOnboardingOnce()); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
if (this.disconnect) { |
||||
this.disconnect(); |
||||
this.disconnect = null; |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { locale } = this.props; |
||||
|
||||
return ( |
||||
<IntlProvider locale={locale} messages={messages}> |
||||
<Provider store={store}> |
||||
<BrowserRouter basename='/web'> |
||||
<ScrollContext> |
||||
<Route path='/' component={UI} /> |
||||
</ScrollContext> |
||||
</BrowserRouter> |
||||
</Provider> |
||||
</IntlProvider> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,34 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { IntlProvider, addLocaleData } from 'react-intl'; |
||||
import { getLocale } from '../locales'; |
||||
import MediaGallery from '../components/media_gallery'; |
||||
import { fromJS } from 'immutable'; |
||||
|
||||
const { localeData, messages } = getLocale(); |
||||
addLocaleData(localeData); |
||||
|
||||
export default class MediaGalleryContainer extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
locale: PropTypes.string.isRequired, |
||||
media: PropTypes.array.isRequired, |
||||
}; |
||||
|
||||
handleOpenMedia = () => {} |
||||
|
||||
render () { |
||||
const { locale, media, ...props } = this.props; |
||||
|
||||
return ( |
||||
<IntlProvider locale={locale} messages={messages}> |
||||
<MediaGallery |
||||
{...props} |
||||
media={fromJS(media)} |
||||
onOpenMedia={this.handleOpenMedia} |
||||
/> |
||||
</IntlProvider> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,133 @@ |
||||
import React from 'react'; |
||||
import { connect } from 'react-redux'; |
||||
import Status from '../components/status'; |
||||
import { makeGetStatus } from '../selectors'; |
||||
import { |
||||
replyCompose, |
||||
mentionCompose, |
||||
} from '../actions/compose'; |
||||
import { |
||||
reblog, |
||||
favourite, |
||||
unreblog, |
||||
unfavourite, |
||||
pin, |
||||
unpin, |
||||
} from '../actions/interactions'; |
||||
import { blockAccount } from '../actions/accounts'; |
||||
import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; |
||||
import { initMuteModal } from '../actions/mutes'; |
||||
import { initReport } from '../actions/reports'; |
||||
import { openModal } from '../actions/modal'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import { boostModal, deleteModal } from '../initial_state'; |
||||
|
||||
const messages = defineMessages({ |
||||
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, |
||||
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, |
||||
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, |
||||
}); |
||||
|
||||
const makeMapStateToProps = () => { |
||||
const getStatus = makeGetStatus(); |
||||
|
||||
const mapStateToProps = (state, props) => ({ |
||||
status: getStatus(state, props.id), |
||||
}); |
||||
|
||||
return mapStateToProps; |
||||
}; |
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({ |
||||
|
||||
onReply (status, router) { |
||||
dispatch(replyCompose(status, router)); |
||||
}, |
||||
|
||||
onModalReblog (status) { |
||||
dispatch(reblog(status)); |
||||
}, |
||||
|
||||
onReblog (status, e) { |
||||
if (status.get('reblogged')) { |
||||
dispatch(unreblog(status)); |
||||
} else { |
||||
if (e.shiftKey || !boostModal) { |
||||
this.onModalReblog(status); |
||||
} else { |
||||
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); |
||||
} |
||||
} |
||||
}, |
||||
|
||||
onFavourite (status) { |
||||
if (status.get('favourited')) { |
||||
dispatch(unfavourite(status)); |
||||
} else { |
||||
dispatch(favourite(status)); |
||||
} |
||||
}, |
||||
|
||||
onPin (status) { |
||||
if (status.get('pinned')) { |
||||
dispatch(unpin(status)); |
||||
} else { |
||||
dispatch(pin(status)); |
||||
} |
||||
}, |
||||
|
||||
onEmbed (status) { |
||||
dispatch(openModal('EMBED', { url: status.get('url') })); |
||||
}, |
||||
|
||||
onDelete (status) { |
||||
if (!deleteModal) { |
||||
dispatch(deleteStatus(status.get('id'))); |
||||
} else { |
||||
dispatch(openModal('CONFIRM', { |
||||
message: intl.formatMessage(messages.deleteMessage), |
||||
confirm: intl.formatMessage(messages.deleteConfirm), |
||||
onConfirm: () => dispatch(deleteStatus(status.get('id'))), |
||||
})); |
||||
} |
||||
}, |
||||
|
||||
onMention (account, router) { |
||||
dispatch(mentionCompose(account, router)); |
||||
}, |
||||
|
||||
onOpenMedia (media, index) { |
||||
dispatch(openModal('MEDIA', { media, index })); |
||||
}, |
||||
|
||||
onOpenVideo (media, time) { |
||||
dispatch(openModal('VIDEO', { media, time })); |
||||
}, |
||||
|
||||
onBlock (account) { |
||||
dispatch(openModal('CONFIRM', { |
||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, |
||||
confirm: intl.formatMessage(messages.blockConfirm), |
||||
onConfirm: () => dispatch(blockAccount(account.get('id'))), |
||||
})); |
||||
}, |
||||
|
||||
onReport (status) { |
||||
dispatch(initReport(status.get('account'), status)); |
||||
}, |
||||
|
||||
onMute (account) { |
||||
dispatch(initMuteModal(account)); |
||||
}, |
||||
|
||||
onMuteConversation (status) { |
||||
if (status.get('muted')) { |
||||
dispatch(unmuteStatus(status.get('id'))); |
||||
} else { |
||||
dispatch(muteStatus(status.get('id'))); |
||||
} |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); |
@ -0,0 +1,48 @@ |
||||
import React from 'react'; |
||||
import { Provider } from 'react-redux'; |
||||
import PropTypes from 'prop-types'; |
||||
import configureStore from '../store/configureStore'; |
||||
import { hydrateStore } from '../actions/store'; |
||||
import { IntlProvider, addLocaleData } from 'react-intl'; |
||||
import { getLocale } from '../locales'; |
||||
import PublicTimeline from '../features/standalone/public_timeline'; |
||||
import HashtagTimeline from '../features/standalone/hashtag_timeline'; |
||||
import initialState from '../initial_state'; |
||||
|
||||
const { localeData, messages } = getLocale(); |
||||
addLocaleData(localeData); |
||||
|
||||
const store = configureStore(); |
||||
|
||||
if (initialState) { |
||||
store.dispatch(hydrateStore(initialState)); |
||||
} |
||||
|
||||
export default class TimelineContainer extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
locale: PropTypes.string.isRequired, |
||||
hashtag: PropTypes.string, |
||||
}; |
||||
|
||||
render () { |
||||
const { locale, hashtag } = this.props; |
||||
|
||||
let timeline; |
||||
|
||||
if (hashtag) { |
||||
timeline = <HashtagTimeline hashtag={hashtag} />; |
||||
} else { |
||||
timeline = <PublicTimeline />; |
||||
} |
||||
|
||||
return ( |
||||
<IntlProvider locale={locale} messages={messages}> |
||||
<Provider store={store}> |
||||
{timeline} |
||||
</Provider> |
||||
</IntlProvider> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,26 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { IntlProvider, addLocaleData } from 'react-intl'; |
||||
import { getLocale } from '../locales'; |
||||
import Video from '../features/video'; |
||||
|
||||
const { localeData, messages } = getLocale(); |
||||
addLocaleData(localeData); |
||||
|
||||
export default class VideoContainer extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
locale: PropTypes.string.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { locale, ...props } = this.props; |
||||
|
||||
return ( |
||||
<IntlProvider locale={locale} messages={messages}> |
||||
<Video {...props} /> |
||||
</IntlProvider> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,5 @@ |
||||
import 'intersection-observer'; |
||||
import 'requestidlecallback'; |
||||
import objectFitImages from 'object-fit-images'; |
||||
|
||||
objectFitImages(); |
@ -0,0 +1,133 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; |
||||
import { Link } from 'react-router-dom'; |
||||
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; |
||||
import { me } from '../../../initial_state'; |
||||
|
||||
const messages = defineMessages({ |
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, |
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, |
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, |
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, |
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, |
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, |
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, |
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' }, |
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' }, |
||||
share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, |
||||
media: { id: 'account.media', defaultMessage: 'Media' }, |
||||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, |
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class ActionBar extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map.isRequired, |
||||
onFollow: PropTypes.func, |
||||
onBlock: PropTypes.func.isRequired, |
||||
onMention: PropTypes.func.isRequired, |
||||
onReport: PropTypes.func.isRequired, |
||||
onMute: PropTypes.func.isRequired, |
||||
onBlockDomain: PropTypes.func.isRequired, |
||||
onUnblockDomain: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
handleShare = () => { |
||||
navigator.share({ |
||||
url: this.props.account.get('url'), |
||||
}); |
||||
} |
||||
|
||||
render () { |
||||
const { account, intl } = this.props; |
||||
|
||||
let menu = []; |
||||
let extraInfo = ''; |
||||
|
||||
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); |
||||
if ('share' in navigator) { |
||||
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); |
||||
} |
||||
menu.push(null); |
||||
menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` }); |
||||
menu.push(null); |
||||
|
||||
if (account.get('id') === me) { |
||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); |
||||
} else { |
||||
if (account.getIn(['relationship', 'muting'])) { |
||||
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); |
||||
} else { |
||||
menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute }); |
||||
} |
||||
|
||||
if (account.getIn(['relationship', 'blocking'])) { |
||||
menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock }); |
||||
} else { |
||||
menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); |
||||
} |
||||
|
||||
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport }); |
||||
} |
||||
|
||||
if (account.get('acct') !== account.get('username')) { |
||||
const domain = account.get('acct').split('@')[1]; |
||||
|
||||
extraInfo = ( |
||||
<div className='account__disclaimer'> |
||||
<FormattedMessage |
||||
id='account.disclaimer_full' |
||||
defaultMessage="Information below may reflect the user's profile incompletely." |
||||
/> |
||||
{' '} |
||||
<a target='_blank' rel='noopener' href={account.get('url')}> |
||||
<FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' /> |
||||
</a> |
||||
</div> |
||||
); |
||||
|
||||
menu.push(null); |
||||
|
||||
if (account.getIn(['relationship', 'domain_blocking'])) { |
||||
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain }); |
||||
} else { |
||||
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain }); |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
{extraInfo} |
||||
|
||||
<div className='account__action-bar'> |
||||
<div className='account__action-bar-dropdown'> |
||||
<DropdownMenuContainer items={menu} icon='bars' size={24} direction='right' /> |
||||
</div> |
||||
|
||||
<div className='account__action-bar-links'> |
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> |
||||
<span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span> |
||||
<strong><FormattedNumber value={account.get('statuses_count')} /></strong> |
||||
</Link> |
||||
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> |
||||
<span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span> |
||||
<strong><FormattedNumber value={account.get('following_count')} /></strong> |
||||
</Link> |
||||
|
||||
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> |
||||
<span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span> |
||||
<strong><FormattedNumber value={account.get('followers_count')} /></strong> |
||||
</Link> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,128 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import IconButton from '../../../components/icon_button'; |
||||
import Motion from '../../ui/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { autoPlayGif, me } from '../../../initial_state'; |
||||
|
||||
const messages = defineMessages({ |
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, |
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' }, |
||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, |
||||
}); |
||||
|
||||
class Avatar extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
isHovered: false, |
||||
}; |
||||
|
||||
handleMouseOver = () => { |
||||
if (this.state.isHovered) return; |
||||
this.setState({ isHovered: true }); |
||||
} |
||||
|
||||
handleMouseOut = () => { |
||||
if (!this.state.isHovered) return; |
||||
this.setState({ isHovered: false }); |
||||
} |
||||
|
||||
render () { |
||||
const { account } = this.props; |
||||
const { isHovered } = this.state; |
||||
|
||||
return ( |
||||
<Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}> |
||||
{({ radius }) => |
||||
<a |
||||
href={account.get('url')} |
||||
className='account__header__avatar' |
||||
role='presentation' |
||||
target='_blank' |
||||
rel='noopener' |
||||
style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }} |
||||
onMouseOver={this.handleMouseOver} |
||||
onMouseOut={this.handleMouseOut} |
||||
onFocus={this.handleMouseOver} |
||||
onBlur={this.handleMouseOut} |
||||
> |
||||
<span style={{ display: 'none' }}>{account.get('acct')}</span> |
||||
</a> |
||||
} |
||||
</Motion> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
@injectIntl |
||||
export default class Header extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map, |
||||
onFollow: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { account, intl } = this.props; |
||||
|
||||
if (!account) { |
||||
return null; |
||||
} |
||||
|
||||
let info = ''; |
||||
let actionBtn = ''; |
||||
let lockedIcon = ''; |
||||
|
||||
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { |
||||
info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>; |
||||
} |
||||
|
||||
if (me !== account.get('id')) { |
||||
if (account.getIn(['relationship', 'requested'])) { |
||||
actionBtn = ( |
||||
<div className='account--action-button'> |
||||
<IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} /> |
||||
</div> |
||||
); |
||||
} else if (!account.getIn(['relationship', 'blocking'])) { |
||||
actionBtn = ( |
||||
<div className='account--action-button'> |
||||
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
if (account.get('locked')) { |
||||
lockedIcon = <i className='fa fa-lock' />; |
||||
} |
||||
|
||||
const content = { __html: account.get('note_emojified') }; |
||||
const displayNameHtml = { __html: account.get('display_name_html') }; |
||||
|
||||
return ( |
||||
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> |
||||
<div> |
||||
<Avatar account={account} /> |
||||
|
||||
<span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} /> |
||||
<span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span> |
||||
<div className='account__header__content' dangerouslySetInnerHTML={content} /> |
||||
|
||||
{info} |
||||
{actionBtn} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,39 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import Permalink from '../../../components/permalink'; |
||||
|
||||
export default class MediaItem extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
media: ImmutablePropTypes.map.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { media } = this.props; |
||||
const status = media.get('status'); |
||||
|
||||
let content, style; |
||||
|
||||
if (media.get('type') === 'gifv') { |
||||
content = <span className='media-gallery__gifv__label'>GIF</span>; |
||||
} |
||||
|
||||
if (!status.get('sensitive')) { |
||||
style = { backgroundImage: `url(${media.get('preview_url')})` }; |
||||
} |
||||
|
||||
return ( |
||||
<div className='account-gallery__item'> |
||||
<Permalink |
||||
to={`/statuses/${status.get('id')}`} |
||||
href={status.get('url')} |
||||
style={style} |
||||
> |
||||
{content} |
||||
</Permalink> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,111 @@ |
||||
import React from 'react'; |
||||
import { connect } from 'react-redux'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import { fetchAccount } from '../../actions/accounts'; |
||||
import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from '../../actions/timelines'; |
||||
import LoadingIndicator from '../../components/loading_indicator'; |
||||
import Column from '../ui/components/column'; |
||||
import ColumnBackButton from '../../components/column_back_button'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { getAccountGallery } from '../../selectors'; |
||||
import MediaItem from './components/media_item'; |
||||
import HeaderContainer from '../account_timeline/containers/header_container'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import { ScrollContainer } from 'react-router-scroll-4'; |
||||
import LoadMore from '../../components/load_more'; |
||||
|
||||
const mapStateToProps = (state, props) => ({ |
||||
medias: getAccountGallery(state, props.params.accountId), |
||||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), |
||||
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']), |
||||
}); |
||||
|
||||
@connect(mapStateToProps) |
||||
export default class AccountGallery extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
params: PropTypes.object.isRequired, |
||||
dispatch: PropTypes.func.isRequired, |
||||
medias: ImmutablePropTypes.list.isRequired, |
||||
isLoading: PropTypes.bool, |
||||
hasMore: PropTypes.bool, |
||||
}; |
||||
|
||||
componentDidMount () { |
||||
this.props.dispatch(fetchAccount(this.props.params.accountId)); |
||||
this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); |
||||
} |
||||
|
||||
componentWillReceiveProps (nextProps) { |
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { |
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId)); |
||||
this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId)); |
||||
} |
||||
} |
||||
|
||||
handleScrollToBottom = () => { |
||||
if (this.props.hasMore) { |
||||
this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId)); |
||||
} |
||||
} |
||||
|
||||
handleScroll = (e) => { |
||||
const { scrollTop, scrollHeight, clientHeight } = e.target; |
||||
const offset = scrollHeight - scrollTop - clientHeight; |
||||
|
||||
if (150 > offset && !this.props.isLoading) { |
||||
this.handleScrollToBottom(); |
||||
} |
||||
} |
||||
|
||||
handleLoadMore = (e) => { |
||||
e.preventDefault(); |
||||
this.handleScrollToBottom(); |
||||
} |
||||
|
||||
render () { |
||||
const { medias, isLoading, hasMore } = this.props; |
||||
|
||||
let loadMore = null; |
||||
|
||||
if (!medias && isLoading) { |
||||
return ( |
||||
<Column> |
||||
<LoadingIndicator /> |
||||
</Column> |
||||
); |
||||
} |
||||
|
||||
if (!isLoading && medias.size > 0 && hasMore) { |
||||
loadMore = <LoadMore onClick={this.handleLoadMore} />; |
||||
} |
||||
|
||||
return ( |
||||
<Column> |
||||
<ColumnBackButton /> |
||||
|
||||
<ScrollContainer scrollKey='account_gallery'> |
||||
<div className='scrollable' onScroll={this.handleScroll}> |
||||
<HeaderContainer accountId={this.props.params.accountId} /> |
||||
|
||||
<div className='account-section-headline'> |
||||
<FormattedMessage id='account.media' defaultMessage='Media' /> |
||||
</div> |
||||
|
||||
<div className='account-gallery__container'> |
||||
{medias.map(media => |
||||
<MediaItem |
||||
key={media.get('id')} |
||||
media={media} |
||||
/> |
||||
)} |
||||
{loadMore} |
||||
</div> |
||||
</div> |
||||
</ScrollContainer> |
||||
</Column> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,89 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import InnerHeader from '../../account/components/header'; |
||||
import ActionBar from '../../account/components/action_bar'; |
||||
import MissingIndicator from '../../../components/missing_indicator'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
|
||||
export default class Header extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map, |
||||
onFollow: PropTypes.func.isRequired, |
||||
onBlock: PropTypes.func.isRequired, |
||||
onMention: PropTypes.func.isRequired, |
||||
onReport: PropTypes.func.isRequired, |
||||
onMute: PropTypes.func.isRequired, |
||||
onBlockDomain: PropTypes.func.isRequired, |
||||
onUnblockDomain: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
handleFollow = () => { |
||||
this.props.onFollow(this.props.account); |
||||
} |
||||
|
||||
handleBlock = () => { |
||||
this.props.onBlock(this.props.account); |
||||
} |
||||
|
||||
handleMention = () => { |
||||
this.props.onMention(this.props.account, this.context.router.history); |
||||
} |
||||
|
||||
handleReport = () => { |
||||
this.props.onReport(this.props.account); |
||||
} |
||||
|
||||
handleMute = () => { |
||||
this.props.onMute(this.props.account); |
||||
} |
||||
|
||||
handleBlockDomain = () => { |
||||
const domain = this.props.account.get('acct').split('@')[1]; |
||||
|
||||
if (!domain) return; |
||||
|
||||
this.props.onBlockDomain(domain, this.props.account.get('id')); |
||||
} |
||||
|
||||
handleUnblockDomain = () => { |
||||
const domain = this.props.account.get('acct').split('@')[1]; |
||||
|
||||
if (!domain) return; |
||||
|
||||
this.props.onUnblockDomain(domain, this.props.account.get('id')); |
||||
} |
||||
|
||||
render () { |
||||
const { account } = this.props; |
||||
|
||||
if (account === null) { |
||||
return <MissingIndicator />; |
||||
} |
||||
|
||||
return ( |
||||
<div className='account-timeline__header'> |
||||
<InnerHeader |
||||
account={account} |
||||
onFollow={this.handleFollow} |
||||
/> |
||||
|
||||
<ActionBar |
||||
account={account} |
||||
onBlock={this.handleBlock} |
||||
onMention={this.handleMention} |
||||
onReport={this.handleReport} |
||||
onMute={this.handleMute} |
||||
onBlockDomain={this.handleBlockDomain} |
||||
onUnblockDomain={this.handleUnblockDomain} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,96 @@ |
||||
import React from 'react'; |
||||
import { connect } from 'react-redux'; |
||||
import { makeGetAccount } from '../../../selectors'; |
||||
import Header from '../components/header'; |
||||
import { |
||||
followAccount, |
||||
unfollowAccount, |
||||
blockAccount, |
||||
unblockAccount, |
||||
unmuteAccount, |
||||
} from '../../../actions/accounts'; |
||||
import { mentionCompose } from '../../../actions/compose'; |
||||
import { initMuteModal } from '../../../actions/mutes'; |
||||
import { initReport } from '../../../actions/reports'; |
||||
import { openModal } from '../../../actions/modal'; |
||||
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import { unfollowModal } from '../../../initial_state'; |
||||
|
||||
const messages = defineMessages({ |
||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, |
||||
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, |
||||
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, |
||||
}); |
||||
|
||||
const makeMapStateToProps = () => { |
||||
const getAccount = makeGetAccount(); |
||||
|
||||
const mapStateToProps = (state, { accountId }) => ({ |
||||
account: getAccount(state, accountId), |
||||
}); |
||||
|
||||
return mapStateToProps; |
||||
}; |
||||
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({ |
||||
|
||||
onFollow (account) { |
||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { |
||||
if (unfollowModal) { |
||||
dispatch(openModal('CONFIRM', { |
||||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, |
||||
confirm: intl.formatMessage(messages.unfollowConfirm), |
||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))), |
||||
})); |
||||
} else { |
||||
dispatch(unfollowAccount(account.get('id'))); |
||||
} |
||||
} else { |
||||
dispatch(followAccount(account.get('id'))); |
||||
} |
||||
}, |
||||
|
||||
onBlock (account) { |
||||
if (account.getIn(['relationship', 'blocking'])) { |
||||
dispatch(unblockAccount(account.get('id'))); |
||||
} else { |
||||
dispatch(openModal('CONFIRM', { |
||||
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, |
||||
confirm: intl.formatMessage(messages.blockConfirm), |
||||
onConfirm: () => dispatch(blockAccount(account.get('id'))), |
||||
})); |
||||
} |
||||
}, |
||||
|
||||
onMention (account, router) { |
||||
dispatch(mentionCompose(account, router)); |
||||
}, |
||||
|
||||
onReport (account) { |
||||
dispatch(initReport(account)); |
||||
}, |
||||
|
||||
onMute (account) { |
||||
if (account.getIn(['relationship', 'muting'])) { |
||||
dispatch(unmuteAccount(account.get('id'))); |
||||
} else { |
||||
dispatch(initMuteModal(account)); |
||||
} |
||||
}, |
||||
|
||||
onBlockDomain (domain, accountId) { |
||||
dispatch(openModal('CONFIRM', { |
||||
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />, |
||||
confirm: intl.formatMessage(messages.blockDomainConfirm), |
||||
onConfirm: () => dispatch(blockDomain(domain, accountId)), |
||||
})); |
||||
}, |
||||
|
||||
onUnblockDomain (domain, accountId) { |
||||
dispatch(unblockDomain(domain, accountId)); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); |
@ -0,0 +1,77 @@ |
||||
import React from 'react'; |
||||
import { connect } from 'react-redux'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import { fetchAccount } from '../../actions/accounts'; |
||||
import { refreshAccountTimeline, expandAccountTimeline } from '../../actions/timelines'; |
||||
import StatusList from '../../components/status_list'; |
||||
import LoadingIndicator from '../../components/loading_indicator'; |
||||
import Column from '../ui/components/column'; |
||||
import HeaderContainer from './containers/header_container'; |
||||
import ColumnBackButton from '../../components/column_back_button'; |
||||
import { List as ImmutableList } from 'immutable'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
|
||||
const mapStateToProps = (state, props) => ({ |
||||
statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()), |
||||
isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']), |
||||
hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']), |
||||
}); |
||||
|
||||
@connect(mapStateToProps) |
||||
export default class AccountTimeline extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
params: PropTypes.object.isRequired, |
||||
dispatch: PropTypes.func.isRequired, |
||||
statusIds: ImmutablePropTypes.list, |
||||
isLoading: PropTypes.bool, |
||||
hasMore: PropTypes.bool, |
||||
}; |
||||
|
||||
componentWillMount () { |
||||
this.props.dispatch(fetchAccount(this.props.params.accountId)); |
||||
this.props.dispatch(refreshAccountTimeline(this.props.params.accountId)); |
||||
} |
||||
|
||||
componentWillReceiveProps (nextProps) { |
||||
if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { |
||||
this.props.dispatch(fetchAccount(nextProps.params.accountId)); |
||||
this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId)); |
||||
} |
||||
} |
||||
|
||||
handleScrollToBottom = () => { |
||||
if (!this.props.isLoading && this.props.hasMore) { |
||||
this.props.dispatch(expandAccountTimeline(this.props.params.accountId)); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { statusIds, isLoading, hasMore } = this.props; |
||||
|
||||
if (!statusIds && isLoading) { |
||||
return ( |
||||
<Column> |
||||
<LoadingIndicator /> |
||||
</Column> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<Column> |
||||
<ColumnBackButton /> |
||||
|
||||
<StatusList |
||||
prepend={<HeaderContainer accountId={this.props.params.accountId} />} |
||||
scrollKey='account_timeline' |
||||
statusIds={statusIds} |
||||
isLoading={isLoading} |
||||
hasMore={hasMore} |
||||
onScrollToBottom={this.handleScrollToBottom} |
||||
/> |
||||
</Column> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,70 @@ |
||||
import React from 'react'; |
||||
import { connect } from 'react-redux'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import LoadingIndicator from '../../components/loading_indicator'; |
||||
import { ScrollContainer } from 'react-router-scroll-4'; |
||||
import Column from '../ui/components/column'; |
||||
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; |
||||
import AccountContainer from '../../containers/account_container'; |
||||
import { fetchBlocks, expandBlocks } from '../../actions/blocks'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
|
||||
const messages = defineMessages({ |
||||
heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, |
||||
}); |
||||
|
||||
const mapStateToProps = state => ({ |
||||
accountIds: state.getIn(['user_lists', 'blocks', 'items']), |
||||
}); |
||||
|
||||
@connect(mapStateToProps) |
||||
@injectIntl |
||||
export default class Blocks extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
params: PropTypes.object.isRequired, |
||||
dispatch: PropTypes.func.isRequired, |
||||
accountIds: ImmutablePropTypes.list, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
componentWillMount () { |
||||
this.props.dispatch(fetchBlocks()); |
||||
} |
||||
|
||||
handleScroll = (e) => { |
||||
const { scrollTop, scrollHeight, clientHeight } = e.target; |
||||
|
||||
if (scrollTop === scrollHeight - clientHeight) { |
||||
this.props.dispatch(expandBlocks()); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { intl, accountIds } = this.props; |
||||
|
||||
if (!accountIds) { |
||||
return ( |
||||
<Column> |
||||
<LoadingIndicator /> |
||||
</Column> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<Column icon='ban' heading={intl.formatMessage(messages.heading)}> |
||||
<ColumnBackButtonSlim /> |
||||
<ScrollContainer scrollKey='blocks'> |
||||
<div className='scrollable' onScroll={this.handleScroll}> |
||||
{accountIds.map(id => |
||||
<AccountContainer key={id} id={id} /> |
||||
)} |
||||
</div> |
||||
</ScrollContainer> |
||||
</Column> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,35 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import SettingText from '../../../components/setting_text'; |
||||
|
||||
const messages = defineMessages({ |
||||
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, |
||||
settings: { id: 'home.settings', defaultMessage: 'Column settings' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class ColumnSettings extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
settings: ImmutablePropTypes.map.isRequired, |
||||
onChange: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { settings, onChange, intl } = this.props; |
||||
|
||||
return ( |
||||
<div> |
||||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> |
||||
|
||||
<div className='column-settings__row'> |
||||
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,17 @@ |
||||
import { connect } from 'react-redux'; |
||||
import ColumnSettings from '../components/column_settings'; |
||||
import { changeSetting } from '../../../actions/settings'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
settings: state.getIn(['settings', 'community']), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
|
||||
onChange (key, checked) { |
||||
dispatch(changeSetting(['community', ...key], checked)); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); |
@ -0,0 +1,107 @@ |
||||
import React from 'react'; |
||||
import { connect } from 'react-redux'; |
||||
import PropTypes from 'prop-types'; |
||||
import StatusListContainer from '../ui/containers/status_list_container'; |
||||
import Column from '../../components/column'; |
||||
import ColumnHeader from '../../components/column_header'; |
||||
import { |
||||
refreshCommunityTimeline, |
||||
expandCommunityTimeline, |
||||
} from '../../actions/timelines'; |
||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import ColumnSettingsContainer from './containers/column_settings_container'; |
||||
import { connectCommunityStream } from '../../actions/streaming'; |
||||
|
||||
const messages = defineMessages({ |
||||
title: { id: 'column.community', defaultMessage: 'Local timeline' }, |
||||
}); |
||||
|
||||
const mapStateToProps = state => ({ |
||||
hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, |
||||
}); |
||||
|
||||
@connect(mapStateToProps) |
||||
@injectIntl |
||||
export default class CommunityTimeline extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
dispatch: PropTypes.func.isRequired, |
||||
columnId: PropTypes.string, |
||||
intl: PropTypes.object.isRequired, |
||||
hasUnread: PropTypes.bool, |
||||
multiColumn: PropTypes.bool, |
||||
}; |
||||
|
||||
handlePin = () => { |
||||
const { columnId, dispatch } = this.props; |
||||
|
||||
if (columnId) { |
||||
dispatch(removeColumn(columnId)); |
||||
} else { |
||||
dispatch(addColumn('COMMUNITY', {})); |
||||
} |
||||
} |
||||
|
||||
handleMove = (dir) => { |
||||
const { columnId, dispatch } = this.props; |
||||
dispatch(moveColumn(columnId, dir)); |
||||
} |
||||
|
||||
handleHeaderClick = () => { |
||||
this.column.scrollTop(); |
||||
} |
||||
|
||||
componentDidMount () { |
||||
const { dispatch } = this.props; |
||||
|
||||
dispatch(refreshCommunityTimeline()); |
||||
this.disconnect = dispatch(connectCommunityStream()); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
if (this.disconnect) { |
||||
this.disconnect(); |
||||
this.disconnect = null; |
||||
} |
||||
} |
||||
|
||||
setRef = c => { |
||||
this.column = c; |
||||
} |
||||
|
||||
handleLoadMore = () => { |
||||
this.props.dispatch(expandCommunityTimeline()); |
||||
} |
||||
|
||||
render () { |
||||
const { intl, hasUnread, columnId, multiColumn } = this.props; |
||||
const pinned = !!columnId; |
||||
|
||||
return ( |
||||
<Column ref={this.setRef}> |
||||
<ColumnHeader |
||||
icon='users' |
||||
active={hasUnread} |
||||
title={intl.formatMessage(messages.title)} |
||||
onPin={this.handlePin} |
||||
onMove={this.handleMove} |
||||
onClick={this.handleHeaderClick} |
||||
pinned={pinned} |
||||
multiColumn={multiColumn} |
||||
> |
||||
<ColumnSettingsContainer /> |
||||
</ColumnHeader> |
||||
|
||||
<StatusListContainer |
||||
trackScroll={!pinned} |
||||
scrollKey={`community_timeline-${columnId}`} |
||||
timelineId='community' |
||||
loadMore={this.handleLoadMore} |
||||
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} |
||||
/> |
||||
</Column> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,24 @@ |
||||
import React from 'react'; |
||||
import Avatar from '../../../components/avatar'; |
||||
import DisplayName from '../../../components/display_name'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
|
||||
export default class AutosuggestAccount extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { account } = this.props; |
||||
|
||||
return ( |
||||
<div className='autosuggest-account'> |
||||
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div> |
||||
<DisplayName account={account} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,25 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { length } from 'stringz'; |
||||
|
||||
export default class CharacterCounter extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
text: PropTypes.string.isRequired, |
||||
max: PropTypes.number.isRequired, |
||||
}; |
||||
|
||||
checkRemainingText (diff) { |
||||
if (diff < 0) { |
||||
return <span className='character-counter character-counter--over'>{diff}</span>; |
||||
} |
||||
|
||||
return <span className='character-counter'>{diff}</span>; |
||||
} |
||||
|
||||
render () { |
||||
const diff = this.props.max - length(this.props.text); |
||||
return this.checkRemainingText(diff); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,212 @@ |
||||
import React from 'react'; |
||||
import CharacterCounter from './character_counter'; |
||||
import Button from '../../../components/button'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import ReplyIndicatorContainer from '../containers/reply_indicator_container'; |
||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea'; |
||||
import UploadButtonContainer from '../containers/upload_button_container'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import Collapsable from '../../../components/collapsable'; |
||||
import SpoilerButtonContainer from '../containers/spoiler_button_container'; |
||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; |
||||
import SensitiveButtonContainer from '../containers/sensitive_button_container'; |
||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; |
||||
import UploadFormContainer from '../containers/upload_form_container'; |
||||
import WarningContainer from '../containers/warning_container'; |
||||
import { isMobile } from '../../../is_mobile'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { length } from 'stringz'; |
||||
import { countableText } from '../util/counter'; |
||||
|
||||
const messages = defineMessages({ |
||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, |
||||
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, |
||||
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, |
||||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class ComposeForm extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
intl: PropTypes.object.isRequired, |
||||
text: PropTypes.string.isRequired, |
||||
suggestion_token: PropTypes.string, |
||||
suggestions: ImmutablePropTypes.list, |
||||
spoiler: PropTypes.bool, |
||||
privacy: PropTypes.string, |
||||
spoiler_text: PropTypes.string, |
||||
focusDate: PropTypes.instanceOf(Date), |
||||
preselectDate: PropTypes.instanceOf(Date), |
||||
is_submitting: PropTypes.bool, |
||||
is_uploading: PropTypes.bool, |
||||
onChange: PropTypes.func.isRequired, |
||||
onSubmit: PropTypes.func.isRequired, |
||||
onClearSuggestions: PropTypes.func.isRequired, |
||||
onFetchSuggestions: PropTypes.func.isRequired, |
||||
onSuggestionSelected: PropTypes.func.isRequired, |
||||
onChangeSpoilerText: PropTypes.func.isRequired, |
||||
onPaste: PropTypes.func.isRequired, |
||||
onPickEmoji: PropTypes.func.isRequired, |
||||
showSearch: PropTypes.bool, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
showSearch: false, |
||||
}; |
||||
|
||||
handleChange = (e) => { |
||||
this.props.onChange(e.target.value); |
||||
} |
||||
|
||||
handleKeyDown = (e) => { |
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { |
||||
this.handleSubmit(); |
||||
} |
||||
} |
||||
|
||||
handleSubmit = () => { |
||||
if (this.props.text !== this.autosuggestTextarea.textarea.value) { |
||||
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
|
||||
// Update the state to match the current text
|
||||
this.props.onChange(this.autosuggestTextarea.textarea.value); |
||||
} |
||||
|
||||
this.props.onSubmit(); |
||||
} |
||||
|
||||
onSuggestionsClearRequested = () => { |
||||
this.props.onClearSuggestions(); |
||||
} |
||||
|
||||
onSuggestionsFetchRequested = (token) => { |
||||
this.props.onFetchSuggestions(token); |
||||
} |
||||
|
||||
onSuggestionSelected = (tokenStart, token, value) => { |
||||
this._restoreCaret = null; |
||||
this.props.onSuggestionSelected(tokenStart, token, value); |
||||
} |
||||
|
||||
handleChangeSpoilerText = (e) => { |
||||
this.props.onChangeSpoilerText(e.target.value); |
||||
} |
||||
|
||||
componentWillReceiveProps (nextProps) { |
||||
// If this is the update where we've finished uploading,
|
||||
// save the last caret position so we can restore it below!
|
||||
if (!nextProps.is_uploading && this.props.is_uploading) { |
||||
this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart; |
||||
} |
||||
} |
||||
|
||||
componentDidUpdate (prevProps) { |
||||
// This statement does several things:
|
||||
// - If we're beginning a reply, and,
|
||||
// - Replying to zero or one users, places the cursor at the end of the textbox.
|
||||
// - Replying to more than one user, selects any usernames past the first;
|
||||
// this provides a convenient shortcut to drop everyone else from the conversation.
|
||||
// - If we've just finished uploading an image, and have a saved caret position,
|
||||
// restores the cursor to that position after the text changes!
|
||||
if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) { |
||||
let selectionEnd, selectionStart; |
||||
|
||||
if (this.props.preselectDate !== prevProps.preselectDate) { |
||||
selectionEnd = this.props.text.length; |
||||
selectionStart = this.props.text.search(/\s/) + 1; |
||||
} else if (typeof this._restoreCaret === 'number') { |
||||
selectionStart = this._restoreCaret; |
||||
selectionEnd = this._restoreCaret; |
||||
} else { |
||||
selectionEnd = this.props.text.length; |
||||
selectionStart = selectionEnd; |
||||
} |
||||
|
||||
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); |
||||
this.autosuggestTextarea.textarea.focus(); |
||||
} else if(prevProps.is_submitting && !this.props.is_submitting) { |
||||
this.autosuggestTextarea.textarea.focus(); |
||||
} |
||||
} |
||||
|
||||
setAutosuggestTextarea = (c) => { |
||||
this.autosuggestTextarea = c; |
||||
} |
||||
|
||||
handleEmojiPick = (data) => { |
||||
const position = this.autosuggestTextarea.textarea.selectionStart; |
||||
const emojiChar = data.native; |
||||
this._restoreCaret = position + emojiChar.length + 1; |
||||
this.props.onPickEmoji(position, data); |
||||
} |
||||
|
||||
render () { |
||||
const { intl, onPaste, showSearch } = this.props; |
||||
const disabled = this.props.is_submitting; |
||||
const text = [this.props.spoiler_text, countableText(this.props.text)].join(''); |
||||
|
||||
let publishText = ''; |
||||
|
||||
if (this.props.privacy === 'private' || this.props.privacy === 'direct') { |
||||
publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; |
||||
} else { |
||||
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); |
||||
} |
||||
|
||||
return ( |
||||
<div className='compose-form'> |
||||
<Collapsable isVisible={this.props.spoiler} fullHeight={50}> |
||||
<div className='spoiler-input'> |
||||
<label> |
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span> |
||||
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' /> |
||||
</label> |
||||
</div> |
||||
</Collapsable> |
||||
|
||||
<WarningContainer /> |
||||
|
||||
<ReplyIndicatorContainer /> |
||||
|
||||
<div className='compose-form__autosuggest-wrapper'> |
||||
<AutosuggestTextarea |
||||
ref={this.setAutosuggestTextarea} |
||||
placeholder={intl.formatMessage(messages.placeholder)} |
||||
disabled={disabled} |
||||
value={this.props.text} |
||||
onChange={this.handleChange} |
||||
suggestions={this.props.suggestions} |
||||
onKeyDown={this.handleKeyDown} |
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} |
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested} |
||||
onSuggestionSelected={this.onSuggestionSelected} |
||||
onPaste={onPaste} |
||||
autoFocus={!showSearch && !isMobile(window.innerWidth)} |
||||
/> |
||||
|
||||
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> |
||||
</div> |
||||
|
||||
<div className='compose-form__modifiers'> |
||||
<UploadFormContainer /> |
||||
</div> |
||||
|
||||
<div className='compose-form__buttons-wrapper'> |
||||
<div className='compose-form__buttons'> |
||||
<UploadButtonContainer /> |
||||
<PrivacyDropdownContainer /> |
||||
<SensitiveButtonContainer /> |
||||
<SpoilerButtonContainer /> |
||||
</div> |
||||
|
||||
<div className='compose-form__publish'> |
||||
<div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> |
||||
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0)} block /></div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,376 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; |
||||
import Overlay from 'react-overlays/lib/Overlay'; |
||||
import classNames from 'classnames'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import detectPassiveEvents from 'detect-passive-events'; |
||||
import { buildCustomEmojis } from '../../emoji/emoji'; |
||||
|
||||
const messages = defineMessages({ |
||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, |
||||
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, |
||||
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' }, |
||||
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, |
||||
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, |
||||
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, |
||||
people: { id: 'emoji_button.people', defaultMessage: 'People' }, |
||||
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, |
||||
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, |
||||
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, |
||||
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, |
||||
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, |
||||
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, |
||||
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, |
||||
}); |
||||
|
||||
const assetHost = process.env.CDN_HOST || ''; |
||||
let EmojiPicker, Emoji; // load asynchronously
|
||||
|
||||
const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`; |
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; |
||||
|
||||
const categoriesSort = [ |
||||
'recent', |
||||
'custom', |
||||
'people', |
||||
'nature', |
||||
'foods', |
||||
'activity', |
||||
'places', |
||||
'objects', |
||||
'symbols', |
||||
'flags', |
||||
]; |
||||
|
||||
class ModifierPickerMenu extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
active: PropTypes.bool, |
||||
onSelect: PropTypes.func.isRequired, |
||||
onClose: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
handleClick = e => { |
||||
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); |
||||
} |
||||
|
||||
componentWillReceiveProps (nextProps) { |
||||
if (nextProps.active) { |
||||
this.attachListeners(); |
||||
} else { |
||||
this.removeListeners(); |
||||
} |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
this.removeListeners(); |
||||
} |
||||
|
||||
handleDocumentClick = e => { |
||||
if (this.node && !this.node.contains(e.target)) { |
||||
this.props.onClose(); |
||||
} |
||||
} |
||||
|
||||
attachListeners () { |
||||
document.addEventListener('click', this.handleDocumentClick, false); |
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); |
||||
} |
||||
|
||||
removeListeners () { |
||||
document.removeEventListener('click', this.handleDocumentClick, false); |
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); |
||||
} |
||||
|
||||
setRef = c => { |
||||
this.node = c; |
||||
} |
||||
|
||||
render () { |
||||
const { active } = this.props; |
||||
|
||||
return ( |
||||
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> |
||||
<button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button> |
||||
<button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button> |
||||
<button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button> |
||||
<button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button> |
||||
<button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button> |
||||
<button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
class ModifierPicker extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
active: PropTypes.bool, |
||||
modifier: PropTypes.number, |
||||
onChange: PropTypes.func, |
||||
onClose: PropTypes.func, |
||||
onOpen: PropTypes.func, |
||||
}; |
||||
|
||||
handleClick = () => { |
||||
if (this.props.active) { |
||||
this.props.onClose(); |
||||
} else { |
||||
this.props.onOpen(); |
||||
} |
||||
} |
||||
|
||||
handleSelect = modifier => { |
||||
this.props.onChange(modifier); |
||||
this.props.onClose(); |
||||
} |
||||
|
||||
render () { |
||||
const { active, modifier } = this.props; |
||||
|
||||
return ( |
||||
<div className='emoji-picker-dropdown__modifiers'> |
||||
<Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} /> |
||||
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
@injectIntl |
||||
class EmojiPickerMenu extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
custom_emojis: ImmutablePropTypes.list, |
||||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), |
||||
loading: PropTypes.bool, |
||||
onClose: PropTypes.func.isRequired, |
||||
onPick: PropTypes.func.isRequired, |
||||
style: PropTypes.object, |
||||
placement: PropTypes.string, |
||||
arrowOffsetLeft: PropTypes.string, |
||||
arrowOffsetTop: PropTypes.string, |
||||
intl: PropTypes.object.isRequired, |
||||
skinTone: PropTypes.number.isRequired, |
||||
onSkinTone: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
style: {}, |
||||
loading: true, |
||||
placement: 'bottom', |
||||
frequentlyUsedEmojis: [], |
||||
}; |
||||
|
||||
state = { |
||||
modifierOpen: false, |
||||
}; |
||||
|
||||
handleDocumentClick = e => { |
||||
if (this.node && !this.node.contains(e.target)) { |
||||
this.props.onClose(); |
||||
} |
||||
} |
||||
|
||||
componentDidMount () { |
||||
document.addEventListener('click', this.handleDocumentClick, false); |
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
document.removeEventListener('click', this.handleDocumentClick, false); |
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); |
||||
} |
||||
|
||||
setRef = c => { |
||||
this.node = c; |
||||
} |
||||
|
||||
getI18n = () => { |
||||
const { intl } = this.props; |
||||
|
||||
return { |
||||
search: intl.formatMessage(messages.emoji_search), |
||||
notfound: intl.formatMessage(messages.emoji_not_found), |
||||
categories: { |
||||
search: intl.formatMessage(messages.search_results), |
||||
recent: intl.formatMessage(messages.recent), |
||||
people: intl.formatMessage(messages.people), |
||||
nature: intl.formatMessage(messages.nature), |
||||
foods: intl.formatMessage(messages.food), |
||||
activity: intl.formatMessage(messages.activity), |
||||
places: intl.formatMessage(messages.travel), |
||||
objects: intl.formatMessage(messages.objects), |
||||
symbols: intl.formatMessage(messages.symbols), |
||||
flags: intl.formatMessage(messages.flags), |
||||
custom: intl.formatMessage(messages.custom), |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
handleClick = emoji => { |
||||
if (!emoji.native) { |
||||
emoji.native = emoji.colons; |
||||
} |
||||
|
||||
this.props.onClose(); |
||||
this.props.onPick(emoji); |
||||
} |
||||
|
||||
handleModifierOpen = () => { |
||||
this.setState({ modifierOpen: true }); |
||||
} |
||||
|
||||
handleModifierClose = () => { |
||||
this.setState({ modifierOpen: false }); |
||||
} |
||||
|
||||
handleModifierChange = modifier => { |
||||
this.props.onSkinTone(modifier); |
||||
} |
||||
|
||||
render () { |
||||
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props; |
||||
|
||||
if (loading) { |
||||
return <div style={{ width: 299 }} />; |
||||
} |
||||
|
||||
const title = intl.formatMessage(messages.emoji); |
||||
const { modifierOpen } = this.state; |
||||
|
||||
return ( |
||||
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> |
||||
<EmojiPicker |
||||
perLine={8} |
||||
emojiSize={22} |
||||
sheetSize={32} |
||||
custom={buildCustomEmojis(custom_emojis)} |
||||
color='' |
||||
emoji='' |
||||
set='twitter' |
||||
title={title} |
||||
i18n={this.getI18n()} |
||||
onClick={this.handleClick} |
||||
include={categoriesSort} |
||||
recent={frequentlyUsedEmojis} |
||||
skin={skinTone} |
||||
showPreview={false} |
||||
backgroundImageFn={backgroundImageFn} |
||||
emojiTooltip |
||||
/> |
||||
|
||||
<ModifierPicker |
||||
active={modifierOpen} |
||||
modifier={skinTone} |
||||
onOpen={this.handleModifierOpen} |
||||
onClose={this.handleModifierClose} |
||||
onChange={this.handleModifierChange} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
@injectIntl |
||||
export default class EmojiPickerDropdown extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
custom_emojis: ImmutablePropTypes.list, |
||||
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), |
||||
intl: PropTypes.object.isRequired, |
||||
onPickEmoji: PropTypes.func.isRequired, |
||||
onSkinTone: PropTypes.func.isRequired, |
||||
skinTone: PropTypes.number.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
active: false, |
||||
loading: false, |
||||
}; |
||||
|
||||
setRef = (c) => { |
||||
this.dropdown = c; |
||||
} |
||||
|
||||
onShowDropdown = () => { |
||||
this.setState({ active: true }); |
||||
|
||||
if (!EmojiPicker) { |
||||
this.setState({ loading: true }); |
||||
|
||||
EmojiPickerAsync().then(EmojiMart => { |
||||
EmojiPicker = EmojiMart.Picker; |
||||
Emoji = EmojiMart.Emoji; |
||||
|
||||
this.setState({ loading: false }); |
||||
}).catch(() => { |
||||
this.setState({ loading: false }); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
onHideDropdown = () => { |
||||
this.setState({ active: false }); |
||||
} |
||||
|
||||
onToggle = (e) => { |
||||
if (!this.state.loading && (!e.key || e.key === 'Enter')) { |
||||
if (this.state.active) { |
||||
this.onHideDropdown(); |
||||
} else { |
||||
this.onShowDropdown(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
handleKeyDown = e => { |
||||
if (e.key === 'Escape') { |
||||
this.onHideDropdown(); |
||||
} |
||||
} |
||||
|
||||
setTargetRef = c => { |
||||
this.target = c; |
||||
} |
||||
|
||||
findTarget = () => { |
||||
return this.target; |
||||
} |
||||
|
||||
render () { |
||||
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; |
||||
const title = intl.formatMessage(messages.emoji); |
||||
const { active, loading } = this.state; |
||||
|
||||
return ( |
||||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> |
||||
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> |
||||
<img |
||||
className={classNames('emojione', { 'pulse-loading': active && loading })} |
||||
alt='🙂' |
||||
src={`${assetHost}/emoji/1f602.svg`} |
||||
/> |
||||
</div> |
||||
|
||||
<Overlay show={active} placement='bottom' target={this.findTarget}> |
||||
<EmojiPickerMenu |
||||
custom_emojis={this.props.custom_emojis} |
||||
loading={loading} |
||||
onClose={this.onHideDropdown} |
||||
onPick={onPickEmoji} |
||||
onSkinTone={onSkinTone} |
||||
skinTone={skinTone} |
||||
frequentlyUsedEmojis={frequentlyUsedEmojis} |
||||
/> |
||||
</Overlay> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,38 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import Avatar from '../../../components/avatar'; |
||||
import IconButton from '../../../components/icon_button'; |
||||
import Permalink from '../../../components/permalink'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
|
||||
export default class NavigationBar extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
account: ImmutablePropTypes.map.isRequired, |
||||
onClose: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
return ( |
||||
<div className='navigation-bar'> |
||||
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> |
||||
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> |
||||
<Avatar account={this.props.account} size={40} /> |
||||
</Permalink> |
||||
|
||||
<div className='navigation-bar__profile'> |
||||
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> |
||||
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong> |
||||
</Permalink> |
||||
|
||||
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> |
||||
</div> |
||||
|
||||
<IconButton title='' icon='close' onClick={this.props.onClose} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,200 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { injectIntl, defineMessages } from 'react-intl'; |
||||
import IconButton from '../../../components/icon_button'; |
||||
import Overlay from 'react-overlays/lib/Overlay'; |
||||
import Motion from '../../ui/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import detectPassiveEvents from 'detect-passive-events'; |
||||
import classNames from 'classnames'; |
||||
|
||||
const messages = defineMessages({ |
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, |
||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, |
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, |
||||
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, |
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, |
||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, |
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, |
||||
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, |
||||
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, |
||||
}); |
||||
|
||||
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; |
||||
|
||||
class PrivacyDropdownMenu extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
style: PropTypes.object, |
||||
items: PropTypes.array.isRequired, |
||||
value: PropTypes.string.isRequired, |
||||
onClose: PropTypes.func.isRequired, |
||||
onChange: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
handleDocumentClick = e => { |
||||
if (this.node && !this.node.contains(e.target)) { |
||||
this.props.onClose(); |
||||
} |
||||
} |
||||
|
||||
handleClick = e => { |
||||
if (e.key === 'Escape') { |
||||
this.props.onClose(); |
||||
} else if (!e.key || e.key === 'Enter') { |
||||
const value = e.currentTarget.getAttribute('data-index'); |
||||
|
||||
e.preventDefault(); |
||||
|
||||
this.props.onClose(); |
||||
this.props.onChange(value); |
||||
} |
||||
} |
||||
|
||||
componentDidMount () { |
||||
document.addEventListener('click', this.handleDocumentClick, false); |
||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
document.removeEventListener('click', this.handleDocumentClick, false); |
||||
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); |
||||
} |
||||
|
||||
setRef = c => { |
||||
this.node = c; |
||||
} |
||||
|
||||
render () { |
||||
const { style, items, value } = this.props; |
||||
|
||||
return ( |
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> |
||||
{({ opacity, scaleX, scaleY }) => ( |
||||
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> |
||||
{items.map(item => |
||||
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}> |
||||
<div className='privacy-dropdown__option__icon'> |
||||
<i className={`fa fa-fw fa-${item.icon}`} /> |
||||
</div> |
||||
|
||||
<div className='privacy-dropdown__option__content'> |
||||
<strong>{item.text}</strong> |
||||
{item.meta} |
||||
</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
@injectIntl |
||||
export default class PrivacyDropdown extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
isUserTouching: PropTypes.func, |
||||
isModalOpen: PropTypes.bool.isRequired, |
||||
onModalOpen: PropTypes.func, |
||||
onModalClose: PropTypes.func, |
||||
value: PropTypes.string.isRequired, |
||||
onChange: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
open: false, |
||||
}; |
||||
|
||||
handleToggle = () => { |
||||
if (this.props.isUserTouching()) { |
||||
if (this.state.open) { |
||||
this.props.onModalClose(); |
||||
} else { |
||||
this.props.onModalOpen({ |
||||
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), |
||||
onClick: this.handleModalActionClick, |
||||
}); |
||||
} |
||||
} else { |
||||
this.setState({ open: !this.state.open }); |
||||
} |
||||
} |
||||
|
||||
handleModalActionClick = (e) => { |
||||
e.preventDefault(); |
||||
|
||||
const { value } = this.options[e.currentTarget.getAttribute('data-index')]; |
||||
|
||||
this.props.onModalClose(); |
||||
this.props.onChange(value); |
||||
} |
||||
|
||||
handleKeyDown = e => { |
||||
switch(e.key) { |
||||
case 'Enter': |
||||
this.handleToggle(); |
||||
break; |
||||
case 'Escape': |
||||
this.handleClose(); |
||||
break; |
||||
} |
||||
} |
||||
|
||||
handleClose = () => { |
||||
this.setState({ open: false }); |
||||
} |
||||
|
||||
handleChange = value => { |
||||
this.props.onChange(value); |
||||
} |
||||
|
||||
componentWillMount () { |
||||
const { intl: { formatMessage } } = this.props; |
||||
|
||||
this.options = [ |
||||
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, |
||||
{ icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, |
||||
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, |
||||
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, |
||||
]; |
||||
} |
||||
|
||||
render () { |
||||
const { value, intl } = this.props; |
||||
const { open } = this.state; |
||||
|
||||
const valueOption = this.options.find(item => item.value === value); |
||||
|
||||
return ( |
||||
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}> |
||||
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}> |
||||
<IconButton |
||||
className='privacy-dropdown__value-icon' |
||||
icon={valueOption.icon} |
||||
title={intl.formatMessage(messages.change_privacy)} |
||||
size={18} |
||||
expanded={open} |
||||
active={open} |
||||
inverted |
||||
onClick={this.handleToggle} |
||||
style={{ height: null, lineHeight: '27px' }} |
||||
/> |
||||
</div> |
||||
|
||||
<Overlay show={open} placement='bottom' target={this}> |
||||
<PrivacyDropdownMenu |
||||
items={this.options} |
||||
value={value} |
||||
onClose={this.handleClose} |
||||
onChange={this.handleChange} |
||||
/> |
||||
</Overlay> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,63 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import Avatar from '../../../components/avatar'; |
||||
import IconButton from '../../../components/icon_button'; |
||||
import DisplayName from '../../../components/display_name'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
|
||||
const messages = defineMessages({ |
||||
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class ReplyIndicator extends ImmutablePureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
status: ImmutablePropTypes.map, |
||||
onCancel: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
handleClick = () => { |
||||
this.props.onCancel(); |
||||
} |
||||
|
||||
handleAccountClick = (e) => { |
||||
if (e.button === 0) { |
||||
e.preventDefault(); |
||||
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { status, intl } = this.props; |
||||
|
||||
if (!status) { |
||||
return null; |
||||
} |
||||
|
||||
const content = { __html: status.get('contentHtml') }; |
||||
|
||||
return ( |
||||
<div className='reply-indicator'> |
||||
<div className='reply-indicator__header'> |
||||
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> |
||||
|
||||
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'> |
||||
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div> |
||||
<DisplayName account={status.get('account')} /> |
||||
</a> |
||||
</div> |
||||
|
||||
<div className='reply-indicator__content' dangerouslySetInnerHTML={content} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,129 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import Overlay from 'react-overlays/lib/Overlay'; |
||||
import Motion from '../../ui/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
|
||||
const messages = defineMessages({ |
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, |
||||
}); |
||||
|
||||
class SearchPopout extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
style: PropTypes.object, |
||||
}; |
||||
|
||||
render () { |
||||
const { style } = this.props; |
||||
|
||||
return ( |
||||
<div style={{ ...style, position: 'absolute', width: 285 }}> |
||||
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> |
||||
{({ opacity, scaleX, scaleY }) => ( |
||||
<div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> |
||||
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4> |
||||
|
||||
<ul> |
||||
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li> |
||||
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> |
||||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> |
||||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li> |
||||
</ul> |
||||
|
||||
<FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' /> |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
@injectIntl |
||||
export default class Search extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
value: PropTypes.string.isRequired, |
||||
submitted: PropTypes.bool, |
||||
onChange: PropTypes.func.isRequired, |
||||
onSubmit: PropTypes.func.isRequired, |
||||
onClear: PropTypes.func.isRequired, |
||||
onShow: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
expanded: false, |
||||
}; |
||||
|
||||
handleChange = (e) => { |
||||
this.props.onChange(e.target.value); |
||||
} |
||||
|
||||
handleClear = (e) => { |
||||
e.preventDefault(); |
||||
|
||||
if (this.props.value.length > 0 || this.props.submitted) { |
||||
this.props.onClear(); |
||||
} |
||||
} |
||||
|
||||
handleKeyDown = (e) => { |
||||
if (e.key === 'Enter') { |
||||
e.preventDefault(); |
||||
this.props.onSubmit(); |
||||
} else if (e.key === 'Escape') { |
||||
document.querySelector('.ui').parentElement.focus(); |
||||
} |
||||
} |
||||
|
||||
noop () { |
||||
|
||||
} |
||||
|
||||
handleFocus = () => { |
||||
this.setState({ expanded: true }); |
||||
this.props.onShow(); |
||||
} |
||||
|
||||
handleBlur = () => { |
||||
this.setState({ expanded: false }); |
||||
} |
||||
|
||||
render () { |
||||
const { intl, value, submitted } = this.props; |
||||
const { expanded } = this.state; |
||||
const hasValue = value.length > 0 || submitted; |
||||
|
||||
return ( |
||||
<div className='search'> |
||||
<label> |
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span> |
||||
<input |
||||
className='search__input' |
||||
type='text' |
||||
placeholder={intl.formatMessage(messages.placeholder)} |
||||
value={value} |
||||
onChange={this.handleChange} |
||||
onKeyUp={this.handleKeyDown} |
||||
onFocus={this.handleFocus} |
||||
onBlur={this.handleBlur} |
||||
/> |
||||
</label> |
||||
|
||||
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}> |
||||
<i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> |
||||
<i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} /> |
||||
</div> |
||||
|
||||
<Overlay show={expanded && !hasValue} placement='bottom' target={this}> |
||||
<SearchPopout /> |
||||
</Overlay> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,65 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
import AccountContainer from '../../../containers/account_container'; |
||||
import StatusContainer from '../../../containers/status_container'; |
||||
import { Link } from 'react-router-dom'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
|
||||
export default class SearchResults extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
results: ImmutablePropTypes.map.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { results } = this.props; |
||||
|
||||
let accounts, statuses, hashtags; |
||||
let count = 0; |
||||
|
||||
if (results.get('accounts') && results.get('accounts').size > 0) { |
||||
count += results.get('accounts').size; |
||||
accounts = ( |
||||
<div className='search-results__section'> |
||||
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (results.get('statuses') && results.get('statuses').size > 0) { |
||||
count += results.get('statuses').size; |
||||
statuses = ( |
||||
<div className='search-results__section'> |
||||
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (results.get('hashtags') && results.get('hashtags').size > 0) { |
||||
count += results.get('hashtags').size; |
||||
hashtags = ( |
||||
<div className='search-results__section'> |
||||
{results.get('hashtags').map(hashtag => |
||||
<Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}> |
||||
#{hashtag} |
||||
</Link> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div className='search-results'> |
||||
<div className='search-results__header'> |
||||
<FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} /> |
||||
</div> |
||||
|
||||
{accounts} |
||||
{statuses} |
||||
{hashtags} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,29 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
|
||||
export default class TextIconButton extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
label: PropTypes.string.isRequired, |
||||
title: PropTypes.string, |
||||
active: PropTypes.bool, |
||||
onClick: PropTypes.func.isRequired, |
||||
ariaControls: PropTypes.string, |
||||
}; |
||||
|
||||
handleClick = (e) => { |
||||
e.preventDefault(); |
||||
this.props.onClick(); |
||||
} |
||||
|
||||
render () { |
||||
const { label, title, active, ariaControls } = this.props; |
||||
|
||||
return ( |
||||
<button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> |
||||
{label} |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,96 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import IconButton from '../../../components/icon_button'; |
||||
import Motion from '../../ui/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import classNames from 'classnames'; |
||||
|
||||
const messages = defineMessages({ |
||||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, |
||||
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class Upload extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
media: ImmutablePropTypes.map.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
onUndo: PropTypes.func.isRequired, |
||||
onDescriptionChange: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
hovered: false, |
||||
focused: false, |
||||
dirtyDescription: null, |
||||
}; |
||||
|
||||
handleUndoClick = () => { |
||||
this.props.onUndo(this.props.media.get('id')); |
||||
} |
||||
|
||||
handleInputChange = e => { |
||||
this.setState({ dirtyDescription: e.target.value }); |
||||
} |
||||
|
||||
handleMouseEnter = () => { |
||||
this.setState({ hovered: true }); |
||||
} |
||||
|
||||
handleMouseLeave = () => { |
||||
this.setState({ hovered: false }); |
||||
} |
||||
|
||||
handleInputFocus = () => { |
||||
this.setState({ focused: true }); |
||||
} |
||||
|
||||
handleInputBlur = () => { |
||||
const { dirtyDescription } = this.state; |
||||
|
||||
this.setState({ focused: false, dirtyDescription: null }); |
||||
|
||||
if (dirtyDescription !== null) { |
||||
this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { intl, media } = this.props; |
||||
const active = this.state.hovered || this.state.focused; |
||||
const description = this.state.dirtyDescription || media.get('description') || ''; |
||||
|
||||
return ( |
||||
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> |
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> |
||||
{({ scale }) => ( |
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}> |
||||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} /> |
||||
|
||||
<div className={classNames('compose-form__upload-description', { active })}> |
||||
<label> |
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> |
||||
|
||||
<input |
||||
placeholder={intl.formatMessage(messages.description)} |
||||
type='text' |
||||
value={description} |
||||
maxLength={420} |
||||
onFocus={this.handleInputFocus} |
||||
onChange={this.handleInputChange} |
||||
onBlur={this.handleInputBlur} |
||||
/> |
||||
</label> |
||||
</div> |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,77 @@ |
||||
import React from 'react'; |
||||
import IconButton from '../../../components/icon_button'; |
||||
import PropTypes from 'prop-types'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import { connect } from 'react-redux'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
|
||||
const messages = defineMessages({ |
||||
upload: { id: 'upload_button.label', defaultMessage: 'Add media' }, |
||||
}); |
||||
|
||||
const makeMapStateToProps = () => { |
||||
const mapStateToProps = state => ({ |
||||
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), |
||||
}); |
||||
|
||||
return mapStateToProps; |
||||
}; |
||||
|
||||
const iconStyle = { |
||||
height: null, |
||||
lineHeight: '27px', |
||||
}; |
||||
|
||||
@connect(makeMapStateToProps) |
||||
@injectIntl |
||||
export default class UploadButton extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
disabled: PropTypes.bool, |
||||
onSelectFile: PropTypes.func.isRequired, |
||||
style: PropTypes.object, |
||||
resetFileKey: PropTypes.number, |
||||
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
handleChange = (e) => { |
||||
if (e.target.files.length > 0) { |
||||
this.props.onSelectFile(e.target.files); |
||||
} |
||||
} |
||||
|
||||
handleClick = () => { |
||||
this.fileElement.click(); |
||||
} |
||||
|
||||
setRef = (c) => { |
||||
this.fileElement = c; |
||||
} |
||||
|
||||
render () { |
||||
|
||||
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; |
||||
|
||||
return ( |
||||
<div className='compose-form__upload-button'> |
||||
<IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} /> |
||||
<label> |
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span> |
||||
<input |
||||
key={resetFileKey} |
||||
ref={this.setRef} |
||||
type='file' |
||||
multiple={false} |
||||
accept={acceptContentTypes.toArray().join(',')} |
||||
onChange={this.handleChange} |
||||
disabled={disabled} |
||||
style={{ display: 'none' }} |
||||
/> |
||||
</label> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,29 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import UploadProgressContainer from '../containers/upload_progress_container'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import UploadContainer from '../containers/upload_container'; |
||||
|
||||
export default class UploadForm extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
mediaIds: ImmutablePropTypes.list.isRequired, |
||||
}; |
||||
|
||||
render () { |
||||
const { mediaIds } = this.props; |
||||
|
||||
return ( |
||||
<div className='compose-form__upload-wrapper'> |
||||
<UploadProgressContainer /> |
||||
|
||||
<div className='compose-form__uploads-wrapper'> |
||||
{mediaIds.map(id => ( |
||||
<UploadContainer id={id} key={id} /> |
||||
))} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,42 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import Motion from '../../ui/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import { FormattedMessage } from 'react-intl'; |
||||
|
||||
export default class UploadProgress extends React.PureComponent { |
||||
|
||||
static propTypes = { |
||||
active: PropTypes.bool, |
||||
progress: PropTypes.number, |
||||
}; |
||||
|
||||
render () { |
||||
const { active, progress } = this.props; |
||||
|
||||
if (!active) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<div className='upload-progress'> |
||||
<div className='upload-progress__icon'> |
||||
<i className='fa fa-upload' /> |
||||
</div> |
||||
|
||||
<div className='upload-progress__message'> |
||||
<FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> |
||||
|
||||
<div className='upload-progress__backdrop'> |
||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> |
||||
{({ width }) => |
||||
<div className='upload-progress__tracker' style={{ width: `${width}%` }} /> |
||||
} |
||||
</Motion> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue