diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2017-05-03 02:04:16 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-05-03 02:04:16 +0200 |
commit | f5bf5ebb82e3af420dcd23d602b1be6cc86838e1 (patch) | |
tree | 92eef08642a038cf44ccbc6d16a884293e7a0814 /app/javascript/mastodon | |
parent | 26bc5915727e0a0173c03cb49f5193dd612fb888 (diff) |
Replace sprockets/browserify with Webpack (#2617)
* Replace browserify with webpack * Add react-intl-translations-manager * Do not minify in development, add offline-plugin for ServiceWorker background cache updates * Adjust tests and dependencies * Fix production deployments * Fix tests * More optimizations * Improve travis cache for npm stuff * Re-run travis * Add back support for custom.scss as before * Remove offline-plugin and babili * Fix issue with Immutable.List().unshift(...values) not working as expected * Make travis load schema instead of running all migrations in sequence * Fix missing React import in WarningContainer. Optimize rendering performance by using ImmutablePureComponent instead of React.PureComponent. ImmutablePureComponent uses Immutable.is() to compare props. Replace dynamic callback bindings in <UI /> * Add react definitions to places that use JSX * Add Procfile.dev for running rails, webpack and streaming API at the same time
Diffstat (limited to 'app/javascript/mastodon')
211 files changed, 17172 insertions, 0 deletions
diff --git a/app/javascript/mastodon/.gitkeep b/app/javascript/mastodon/.gitkeep new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/app/javascript/mastodon/.gitkeep diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js new file mode 100644 index 000000000..eac5c78bb --- /dev/null +++ b/app/javascript/mastodon/actions/accounts.js @@ -0,0 +1,762 @@ +import api, { getLinks } from '../api' +import Immutable from 'immutable'; + +export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; +export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; +export const ACCOUNT_FETCH_FAIL = 'ACCOUNT_FETCH_FAIL'; + +export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST'; +export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS'; +export const ACCOUNT_FOLLOW_FAIL = 'ACCOUNT_FOLLOW_FAIL'; + +export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; +export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; +export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; + +export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; +export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; +export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; + +export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST'; +export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS'; +export const ACCOUNT_UNBLOCK_FAIL = 'ACCOUNT_UNBLOCK_FAIL'; + +export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST'; +export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS'; +export const ACCOUNT_MUTE_FAIL = 'ACCOUNT_MUTE_FAIL'; + +export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; +export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; +export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; + +export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST'; +export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS'; +export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL'; + +export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST'; +export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS'; +export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL'; + +export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; +export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; +export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; + +export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST'; +export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS'; +export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL'; + +export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; +export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; +export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; + +export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; +export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; +export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; + +export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; +export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; +export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; + +export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST'; +export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS'; +export const FOLLOW_REQUESTS_FETCH_FAIL = 'FOLLOW_REQUESTS_FETCH_FAIL'; + +export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST'; +export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS'; +export const FOLLOW_REQUESTS_EXPAND_FAIL = 'FOLLOW_REQUESTS_EXPAND_FAIL'; + +export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST'; +export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS'; +export const FOLLOW_REQUEST_AUTHORIZE_FAIL = 'FOLLOW_REQUEST_AUTHORIZE_FAIL'; + +export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST'; +export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS'; +export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL'; + +export function fetchAccount(id) { + return (dispatch, getState) => { + dispatch(fetchRelationships([id])); + + if (getState().getIn(['accounts', id], null) !== null) { + return; + } + + dispatch(fetchAccountRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}`).then(response => { + dispatch(fetchAccountSuccess(response.data)); + }).catch(error => { + dispatch(fetchAccountFail(id, error)); + }); + }; +}; + +export function fetchAccountTimeline(id, replace = false) { + return (dispatch, getState) => { + const ids = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()); + const newestId = ids.size > 0 ? ids.first() : null; + + let params = ''; + let skipLoading = false; + + if (newestId !== null && !replace) { + params = `?since_id=${newestId}`; + skipLoading = true; + } + + dispatch(fetchAccountTimelineRequest(id, skipLoading)); + + api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => { + dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading)); + }).catch(error => { + dispatch(fetchAccountTimelineFail(id, error, skipLoading)); + }); + }; +}; + +export function expandAccountTimeline(id) { + return (dispatch, getState) => { + const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last(); + + dispatch(expandAccountTimelineRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/statuses`, { + params: { + limit: 10, + max_id: lastId + } + }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandAccountTimelineSuccess(id, response.data, next)); + }).catch(error => { + dispatch(expandAccountTimelineFail(id, error)); + }); + }; +}; + +export function fetchAccountRequest(id) { + return { + type: ACCOUNT_FETCH_REQUEST, + id + }; +}; + +export function fetchAccountSuccess(account) { + return { + type: ACCOUNT_FETCH_SUCCESS, + account + }; +}; + +export function fetchAccountFail(id, error) { + return { + type: ACCOUNT_FETCH_FAIL, + id, + error, + skipAlert: true + }; +}; + +export function followAccount(id) { + return (dispatch, getState) => { + dispatch(followAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => { + dispatch(followAccountSuccess(response.data)); + }).catch(error => { + dispatch(followAccountFail(error)); + }); + }; +}; + +export function unfollowAccount(id) { + return (dispatch, getState) => { + dispatch(unfollowAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => { + dispatch(unfollowAccountSuccess(response.data)); + }).catch(error => { + dispatch(unfollowAccountFail(error)); + }); + } +}; + +export function followAccountRequest(id) { + return { + type: ACCOUNT_FOLLOW_REQUEST, + id + }; +}; + +export function followAccountSuccess(relationship) { + return { + type: ACCOUNT_FOLLOW_SUCCESS, + relationship + }; +}; + +export function followAccountFail(error) { + return { + type: ACCOUNT_FOLLOW_FAIL, + error + }; +}; + +export function unfollowAccountRequest(id) { + return { + type: ACCOUNT_UNFOLLOW_REQUEST, + id + }; +}; + +export function unfollowAccountSuccess(relationship) { + return { + type: ACCOUNT_UNFOLLOW_SUCCESS, + relationship + }; +}; + +export function unfollowAccountFail(error) { + return { + type: ACCOUNT_UNFOLLOW_FAIL, + error + }; +}; + +export function fetchAccountTimelineRequest(id, skipLoading) { + return { + type: ACCOUNT_TIMELINE_FETCH_REQUEST, + id, + skipLoading + }; +}; + +export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) { + return { + type: ACCOUNT_TIMELINE_FETCH_SUCCESS, + id, + statuses, + replace, + skipLoading + }; +}; + +export function fetchAccountTimelineFail(id, error, skipLoading) { + return { + type: ACCOUNT_TIMELINE_FETCH_FAIL, + id, + error, + skipLoading, + skipAlert: error.response.status === 404 + }; +}; + +export function expandAccountTimelineRequest(id) { + return { + type: ACCOUNT_TIMELINE_EXPAND_REQUEST, + id + }; +}; + +export function expandAccountTimelineSuccess(id, statuses, next) { + return { + type: ACCOUNT_TIMELINE_EXPAND_SUCCESS, + id, + statuses, + next + }; +}; + +export function expandAccountTimelineFail(id, error) { + return { + type: ACCOUNT_TIMELINE_EXPAND_FAIL, + id, + error + }; +}; + +export function blockAccount(id) { + return (dispatch, getState) => { + dispatch(blockAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/block`).then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(blockAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(blockAccountFail(id, error)); + }); + }; +}; + +export function unblockAccount(id) { + return (dispatch, getState) => { + dispatch(unblockAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => { + dispatch(unblockAccountSuccess(response.data)); + }).catch(error => { + dispatch(unblockAccountFail(id, error)); + }); + }; +}; + +export function blockAccountRequest(id) { + return { + type: ACCOUNT_BLOCK_REQUEST, + id + }; +}; + +export function blockAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_BLOCK_SUCCESS, + relationship, + statuses + }; +}; + +export function blockAccountFail(error) { + return { + type: ACCOUNT_BLOCK_FAIL, + error + }; +}; + +export function unblockAccountRequest(id) { + return { + type: ACCOUNT_UNBLOCK_REQUEST, + id + }; +}; + +export function unblockAccountSuccess(relationship) { + return { + type: ACCOUNT_UNBLOCK_SUCCESS, + relationship + }; +}; + +export function unblockAccountFail(error) { + return { + type: ACCOUNT_UNBLOCK_FAIL, + error + }; +}; + + +export function muteAccount(id) { + return (dispatch, getState) => { + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => { + // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers + dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(muteAccountFail(id, error)); + }); + }; +}; + +export function unmuteAccount(id) { + return (dispatch, getState) => { + dispatch(unmuteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => { + dispatch(unmuteAccountSuccess(response.data)); + }).catch(error => { + dispatch(unmuteAccountFail(id, error)); + }); + }; +}; + +export function muteAccountRequest(id) { + return { + type: ACCOUNT_MUTE_REQUEST, + id + }; +}; + +export function muteAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_MUTE_SUCCESS, + relationship, + statuses + }; +}; + +export function muteAccountFail(error) { + return { + type: ACCOUNT_MUTE_FAIL, + error + }; +}; + +export function unmuteAccountRequest(id) { + return { + type: ACCOUNT_UNMUTE_REQUEST, + id + }; +}; + +export function unmuteAccountSuccess(relationship) { + return { + type: ACCOUNT_UNMUTE_SUCCESS, + relationship + }; +}; + +export function unmuteAccountFail(error) { + return { + type: ACCOUNT_UNMUTE_FAIL, + error + }; +}; + + +export function fetchFollowers(id) { + return (dispatch, getState) => { + dispatch(fetchFollowersRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchFollowersFail(id, error)); + }); + }; +}; + +export function fetchFollowersRequest(id) { + return { + type: FOLLOWERS_FETCH_REQUEST, + id + }; +}; + +export function fetchFollowersSuccess(id, accounts, next) { + return { + type: FOLLOWERS_FETCH_SUCCESS, + id, + accounts, + next + }; +}; + +export function fetchFollowersFail(id, error) { + return { + type: FOLLOWERS_FETCH_FAIL, + id, + error + }; +}; + +export function expandFollowers(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'followers', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowersRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(expandFollowersFail(id, error)); + }); + }; +}; + +export function expandFollowersRequest(id) { + return { + type: FOLLOWERS_EXPAND_REQUEST, + id + }; +}; + +export function expandFollowersSuccess(id, accounts, next) { + return { + type: FOLLOWERS_EXPAND_SUCCESS, + id, + accounts, + next + }; +}; + +export function expandFollowersFail(id, error) { + return { + type: FOLLOWERS_EXPAND_FAIL, + id, + error + }; +}; + +export function fetchFollowing(id) { + return (dispatch, getState) => { + dispatch(fetchFollowingRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchFollowingFail(id, error)); + }); + }; +}; + +export function fetchFollowingRequest(id) { + return { + type: FOLLOWING_FETCH_REQUEST, + id + }; +}; + +export function fetchFollowingSuccess(id, accounts, next) { + return { + type: FOLLOWING_FETCH_SUCCESS, + id, + accounts, + next + }; +}; + +export function fetchFollowingFail(id, error) { + return { + type: FOLLOWING_FETCH_FAIL, + id, + error + }; +}; + +export function expandFollowing(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'following', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowingRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(expandFollowingFail(id, error)); + }); + }; +}; + +export function expandFollowingRequest(id) { + return { + type: FOLLOWING_EXPAND_REQUEST, + id + }; +}; + +export function expandFollowingSuccess(id, accounts, next) { + return { + type: FOLLOWING_EXPAND_SUCCESS, + id, + accounts, + next + }; +}; + +export function expandFollowingFail(id, error) { + return { + type: FOLLOWING_EXPAND_FAIL, + id, + error + }; +}; + +export function fetchRelationships(accountIds) { + return (dispatch, getState) => { + const loadedRelationships = getState().get('relationships'); + const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null); + + if (newAccountIds.length === 0) { + return; + } + + dispatch(fetchRelationshipsRequest(newAccountIds)); + + api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => { + dispatch(fetchRelationshipsSuccess(response.data)); + }).catch(error => { + dispatch(fetchRelationshipsFail(error)); + }); + }; +}; + +export function fetchRelationshipsRequest(ids) { + return { + type: RELATIONSHIPS_FETCH_REQUEST, + ids, + skipLoading: true + }; +}; + +export function fetchRelationshipsSuccess(relationships) { + return { + type: RELATIONSHIPS_FETCH_SUCCESS, + relationships, + skipLoading: true + }; +}; + +export function fetchRelationshipsFail(error) { + return { + type: RELATIONSHIPS_FETCH_FAIL, + error, + skipLoading: true + }; +}; + +export function fetchFollowRequests() { + return (dispatch, getState) => { + dispatch(fetchFollowRequestsRequest()); + + api(getState).get('/api/v1/follow_requests').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null)) + }).catch(error => dispatch(fetchFollowRequestsFail(error))); + }; +}; + +export function fetchFollowRequestsRequest() { + return { + type: FOLLOW_REQUESTS_FETCH_REQUEST + }; +}; + +export function fetchFollowRequestsSuccess(accounts, next) { + return { + type: FOLLOW_REQUESTS_FETCH_SUCCESS, + accounts, + next + }; +}; + +export function fetchFollowRequestsFail(error) { + return { + type: FOLLOW_REQUESTS_FETCH_FAIL, + error + }; +}; + +export function expandFollowRequests() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'follow_requests', 'next']); + + if (url === null) { + return; + } + + dispatch(expandFollowRequestsRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null)) + }).catch(error => dispatch(expandFollowRequestsFail(error))); + }; +}; + +export function expandFollowRequestsRequest() { + return { + type: FOLLOW_REQUESTS_EXPAND_REQUEST + }; +}; + +export function expandFollowRequestsSuccess(accounts, next) { + return { + type: FOLLOW_REQUESTS_EXPAND_SUCCESS, + accounts, + next + }; +}; + +export function expandFollowRequestsFail(error) { + return { + type: FOLLOW_REQUESTS_EXPAND_FAIL, + error + }; +}; + +export function authorizeFollowRequest(id) { + return (dispatch, getState) => { + dispatch(authorizeFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/authorize`) + .then(response => dispatch(authorizeFollowRequestSuccess(id))) + .catch(error => dispatch(authorizeFollowRequestFail(id, error))); + }; +}; + +export function authorizeFollowRequestRequest(id) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_REQUEST, + id + }; +}; + +export function authorizeFollowRequestSuccess(id) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + id + }; +}; + +export function authorizeFollowRequestFail(id, error) { + return { + type: FOLLOW_REQUEST_AUTHORIZE_FAIL, + id, + error + }; +}; + + +export function rejectFollowRequest(id) { + return (dispatch, getState) => { + dispatch(rejectFollowRequestRequest(id)); + + api(getState) + .post(`/api/v1/follow_requests/${id}/reject`) + .then(response => dispatch(rejectFollowRequestSuccess(id))) + .catch(error => dispatch(rejectFollowRequestFail(id, error))); + }; +}; + +export function rejectFollowRequestRequest(id) { + return { + type: FOLLOW_REQUEST_REJECT_REQUEST, + id + }; +}; + +export function rejectFollowRequestSuccess(id) { + return { + type: FOLLOW_REQUEST_REJECT_SUCCESS, + id + }; +}; + +export function rejectFollowRequestFail(id, error) { + return { + type: FOLLOW_REQUEST_REJECT_FAIL, + id, + error + }; +}; diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js new file mode 100644 index 000000000..086e0727e --- /dev/null +++ b/app/javascript/mastodon/actions/alerts.js @@ -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 + }; +}; diff --git a/app/javascript/mastodon/actions/blocks.js b/app/javascript/mastodon/actions/blocks.js new file mode 100644 index 000000000..79e316497 --- /dev/null +++ b/app/javascript/mastodon/actions/blocks.js @@ -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 + }; +}; diff --git a/app/javascript/mastodon/actions/cards.js b/app/javascript/mastodon/actions/cards.js new file mode 100644 index 000000000..805be9709 --- /dev/null +++ b/app/javascript/mastodon/actions/cards.js @@ -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 + }; +}; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js new file mode 100644 index 000000000..d7ff6ea63 --- /dev/null +++ b/app/javascript/mastodon/actions/compose.js @@ -0,0 +1,279 @@ +import api from '../api'; + +import { updateTimeline } from './timelines'; + +import * as emojione from 'emojione'; + +export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; +export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; +export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; +export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; +export const COMPOSE_REPLY = 'COMPOSE_REPLY'; +export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; +export const COMPOSE_MENTION = 'COMPOSE_MENTION'; +export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; +export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; +export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; +export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; +export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; + +export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; +export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; +export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; + +export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; +export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; + +export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; +export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; +export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; +export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; + +export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; + +export function changeCompose(text) { + return { + type: COMPOSE_CHANGE, + text: text + }; +}; + +export function replyCompose(status, router) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_REPLY, + status: status + }); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } + }; +}; + +export function cancelReplyCompose() { + return { + type: COMPOSE_REPLY_CANCEL + }; +}; + +export function mentionCompose(account, router) { + return (dispatch, getState) => { + dispatch({ + type: COMPOSE_MENTION, + account: account + }); + + if (!getState().getIn(['compose', 'mounted'])) { + router.push('/statuses/new'); + } + }; +}; + +export function submitCompose() { + return function (dispatch, getState) { + const status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], '')); + if (!status || !status.length) { + return; + } + dispatch(submitComposeRequest()); + api(getState).post('/api/v1/statuses', { + status, + in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), + media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')), + sensitive: getState().getIn(['compose', 'sensitive']), + spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), + visibility: getState().getIn(['compose', 'privacy']) + }, { + headers: { + 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']) + } + }).then(function (response) { + dispatch(submitComposeSuccess({ ...response.data })); + + // To make the app more responsive, immediately get the status into the columns + dispatch(updateTimeline('home', { ...response.data })); + + if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { + if (getState().getIn(['timelines', 'community', 'loaded'])) { + dispatch(updateTimeline('community', { ...response.data })); + } + + if (getState().getIn(['timelines', 'public', 'loaded'])) { + dispatch(updateTimeline('public', { ...response.data })); + } + } + }).catch(function (error) { + dispatch(submitComposeFail(error)); + }); + }; +}; + +export function submitComposeRequest() { + return { + type: COMPOSE_SUBMIT_REQUEST + }; +}; + +export function submitComposeSuccess(status) { + return { + type: COMPOSE_SUBMIT_SUCCESS, + status: status + }; +}; + +export function submitComposeFail(error) { + return { + type: COMPOSE_SUBMIT_FAIL, + error: error + }; +}; + +export function uploadCompose(files) { + return function (dispatch, getState) { + if (getState().getIn(['compose', 'media_attachments']).size > 3) { + return; + } + + dispatch(uploadComposeRequest()); + + let data = new FormData(); + data.append('file', files[0]); + + api(getState).post('/api/v1/media', data, { + onUploadProgress: function (e) { + dispatch(uploadComposeProgress(e.loaded, e.total)); + } + }).then(function (response) { + dispatch(uploadComposeSuccess(response.data)); + }).catch(function (error) { + dispatch(uploadComposeFail(error)); + }); + }; +}; + +export function uploadComposeRequest() { + return { + type: COMPOSE_UPLOAD_REQUEST, + skipLoading: true + }; +}; + +export function uploadComposeProgress(loaded, total) { + return { + type: COMPOSE_UPLOAD_PROGRESS, + loaded: loaded, + total: total + }; +}; + +export function uploadComposeSuccess(media) { + return { + type: COMPOSE_UPLOAD_SUCCESS, + media: media, + skipLoading: true + }; +}; + +export function uploadComposeFail(error) { + return { + type: COMPOSE_UPLOAD_FAIL, + error: error, + skipLoading: true + }; +}; + +export function undoUploadCompose(media_id) { + return { + type: COMPOSE_UPLOAD_UNDO, + media_id: media_id + }; +}; + +export function clearComposeSuggestions() { + return { + type: COMPOSE_SUGGESTIONS_CLEAR + }; +}; + +export function fetchComposeSuggestions(token) { + return (dispatch, getState) => { + api(getState).get('/api/v1/accounts/search', { + params: { + q: token, + resolve: false, + limit: 4 + } + }).then(response => { + dispatch(readyComposeSuggestions(token, response.data)); + }); + }; +}; + +export function readyComposeSuggestions(token, accounts) { + return { + type: COMPOSE_SUGGESTIONS_READY, + token, + accounts + }; +}; + +export function selectComposeSuggestion(position, token, accountId) { + return (dispatch, getState) => { + const completion = getState().getIn(['accounts', accountId, 'acct']); + + dispatch({ + type: COMPOSE_SUGGESTION_SELECT, + position, + token, + completion + }); + }; +}; + +export function mountCompose() { + return { + type: COMPOSE_MOUNT + }; +}; + +export function unmountCompose() { + return { + type: COMPOSE_UNMOUNT + }; +}; + +export function changeComposeSensitivity() { + return { + type: COMPOSE_SENSITIVITY_CHANGE, + }; +}; + +export function changeComposeSpoilerness() { + return { + type: COMPOSE_SPOILERNESS_CHANGE + }; +}; + +export function changeComposeSpoilerText(text) { + return { + type: COMPOSE_SPOILER_TEXT_CHANGE, + text + }; +}; + +export function changeComposeVisibility(value) { + return { + type: COMPOSE_VISIBILITY_CHANGE, + value + }; +}; + +export function insertEmojiCompose(position, emoji) { + return { + type: COMPOSE_EMOJI_INSERT, + position, + emoji + }; +}; diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js new file mode 100644 index 000000000..a25c1ae1c --- /dev/null +++ b/app/javascript/mastodon/actions/favourites.js @@ -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 + }; +}; diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js new file mode 100644 index 000000000..45f4508f6 --- /dev/null +++ b/app/javascript/mastodon/actions/interactions.js @@ -0,0 +1,235 @@ +import api from '../api' + +export const REBLOG_REQUEST = 'REBLOG_REQUEST'; +export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; +export const REBLOG_FAIL = 'REBLOG_FAIL'; + +export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST'; +export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS'; +export const FAVOURITE_FAIL = 'FAVOURITE_FAIL'; + +export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST'; +export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS'; +export const UNREBLOG_FAIL = 'UNREBLOG_FAIL'; + +export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST'; +export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS'; +export const UNFAVOURITE_FAIL = 'UNFAVOURITE_FAIL'; + +export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST'; +export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS'; +export const REBLOGS_FETCH_FAIL = 'REBLOGS_FETCH_FAIL'; + +export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; +export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; +export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; + +export function reblog(status) { + return function (dispatch, getState) { + dispatch(reblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { + // The reblog API method returns a new status wrapped around the original. In this case we are only + // interested in how the original is modified, hence passing it skipping the wrapper + dispatch(reblogSuccess(status, response.data.reblog)); + }).catch(function (error) { + dispatch(reblogFail(status, error)); + }); + }; +}; + +export function unreblog(status) { + return (dispatch, getState) => { + dispatch(unreblogRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => { + dispatch(unreblogSuccess(status, response.data)); + }).catch(error => { + dispatch(unreblogFail(status, error)); + }); + }; +}; + +export function reblogRequest(status) { + return { + type: REBLOG_REQUEST, + status: status + }; +}; + +export function reblogSuccess(status, response) { + return { + type: REBLOG_SUCCESS, + status: status, + response: response + }; +}; + +export function reblogFail(status, error) { + return { + type: REBLOG_FAIL, + status: status, + error: error + }; +}; + +export function unreblogRequest(status) { + return { + type: UNREBLOG_REQUEST, + status: status + }; +}; + +export function unreblogSuccess(status, response) { + return { + type: UNREBLOG_SUCCESS, + status: status, + response: response + }; +}; + +export function unreblogFail(status, error) { + return { + type: UNREBLOG_FAIL, + status: status, + error: error + }; +}; + +export function favourite(status) { + return function (dispatch, getState) { + dispatch(favouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) { + dispatch(favouriteSuccess(status, response.data)); + }).catch(function (error) { + dispatch(favouriteFail(status, error)); + }); + }; +}; + +export function unfavourite(status) { + return (dispatch, getState) => { + dispatch(unfavouriteRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => { + dispatch(unfavouriteSuccess(status, response.data)); + }).catch(error => { + dispatch(unfavouriteFail(status, error)); + }); + }; +}; + +export function favouriteRequest(status) { + return { + type: FAVOURITE_REQUEST, + status: status + }; +}; + +export function favouriteSuccess(status, response) { + return { + type: FAVOURITE_SUCCESS, + status: status, + response: response + }; +}; + +export function favouriteFail(status, error) { + return { + type: FAVOURITE_FAIL, + status: status, + error: error + }; +}; + +export function unfavouriteRequest(status) { + return { + type: UNFAVOURITE_REQUEST, + status: status + }; +}; + +export function unfavouriteSuccess(status, response) { + return { + type: UNFAVOURITE_SUCCESS, + status: status, + response: response + }; +}; + +export function unfavouriteFail(status, error) { + return { + type: UNFAVOURITE_FAIL, + status: status, + error: error + }; +}; + +export function fetchReblogs(id) { + return (dispatch, getState) => { + dispatch(fetchReblogsRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => { + dispatch(fetchReblogsSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchReblogsFail(id, error)); + }); + }; +}; + +export function fetchReblogsRequest(id) { + return { + type: REBLOGS_FETCH_REQUEST, + id + }; +}; + +export function fetchReblogsSuccess(id, accounts) { + return { + type: REBLOGS_FETCH_SUCCESS, + id, + accounts + }; +}; + +export function fetchReblogsFail(id, error) { + return { + type: REBLOGS_FETCH_FAIL, + error + }; +}; + +export function fetchFavourites(id) { + return (dispatch, getState) => { + dispatch(fetchFavouritesRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => { + dispatch(fetchFavouritesSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchFavouritesFail(id, error)); + }); + }; +}; + +export function fetchFavouritesRequest(id) { + return { + type: FAVOURITES_FETCH_REQUEST, + id + }; +}; + +export function fetchFavouritesSuccess(id, accounts) { + return { + type: FAVOURITES_FETCH_SUCCESS, + id, + accounts + }; +}; + +export function fetchFavouritesFail(id, error) { + return { + type: FAVOURITES_FETCH_FAIL, + error + }; +}; diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js new file mode 100644 index 000000000..615cd6bfe --- /dev/null +++ b/app/javascript/mastodon/actions/modal.js @@ -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 + }; +}; diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js new file mode 100644 index 000000000..824821594 --- /dev/null +++ b/app/javascript/mastodon/actions/mutes.js @@ -0,0 +1,82 @@ +import api, { getLinks } from '../api' +import { fetchRelationships } from './accounts'; + +export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; +export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; +export const MUTES_FETCH_FAIL = 'MUTES_FETCH_FAIL'; + +export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; +export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; +export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; + +export function fetchMutes() { + return (dispatch, getState) => { + dispatch(fetchMutesRequest()); + + api(getState).get('/api/v1/mutes').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchMutesSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(fetchMutesFail(error))); + }; +}; + +export function fetchMutesRequest() { + return { + type: MUTES_FETCH_REQUEST + }; +}; + +export function fetchMutesSuccess(accounts, next) { + return { + type: MUTES_FETCH_SUCCESS, + accounts, + next + }; +}; + +export function fetchMutesFail(error) { + return { + type: MUTES_FETCH_FAIL, + error + }; +}; + +export function expandMutes() { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'mutes', 'next']); + + if (url === null) { + return; + } + + dispatch(expandMutesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandMutesSuccess(response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandMutesFail(error))); + }; +}; + +export function expandMutesRequest() { + return { + type: MUTES_EXPAND_REQUEST + }; +}; + +export function expandMutesSuccess(accounts, next) { + return { + type: MUTES_EXPAND_SUCCESS, + accounts, + next + }; +}; + +export function expandMutesFail(error) { + return { + type: MUTES_EXPAND_FAIL, + error + }; +}; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js new file mode 100644 index 000000000..b09ca0854 --- /dev/null +++ b/app/javascript/mastodon/actions/notifications.js @@ -0,0 +1,165 @@ +import api, { getLinks } from '../api' +import Immutable from 'immutable'; +import IntlMessageFormat from 'intl-messageformat'; + +import { fetchRelationships } from './accounts'; + +export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; + +export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST'; +export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS'; +export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL'; + +export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; +export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; +export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; + +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; + +const fetchRelatedRelationships = (dispatch, notifications) => { + const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id); + + if (accountIds > 0) { + dispatch(fetchRelationships(accountIds)); + } +}; + +export function updateNotifications(notification, intlMessages, intlLocale) { + return (dispatch, getState) => { + const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); + const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + + dispatch({ + type: NOTIFICATIONS_UPDATE, + notification, + account: notification.account, + status: notification.status, + meta: playSound ? { sound: 'boop' } : undefined + }); + + fetchRelatedRelationships(dispatch, [notification]); + + // Desktop notifications + if (typeof window.Notification !== 'undefined' && showAlert) { + const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); + const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : $('<p>').html(notification.status ? notification.status.content : '').text(); + + new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); + } + }; +}; + +const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); + +export function refreshNotifications() { + return (dispatch, getState) => { + dispatch(refreshNotificationsRequest()); + + const params = {}; + const ids = getState().getIn(['notifications', 'items']); + + if (ids.size > 0) { + params.since_id = ids.first().get('id'); + } + + params.exclude_types = excludeTypesFromSettings(getState()); + + api(getState).get('/api/v1/notifications', { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(refreshNotificationsSuccess(response.data, next ? next.uri : null)); + fetchRelatedRelationships(dispatch, response.data); + }).catch(error => { + dispatch(refreshNotificationsFail(error)); + }); + }; +}; + +export function refreshNotificationsRequest() { + return { + type: NOTIFICATIONS_REFRESH_REQUEST + }; +}; + +export function refreshNotificationsSuccess(notifications, next) { + return { + type: NOTIFICATIONS_REFRESH_SUCCESS, + notifications, + accounts: notifications.map(item => item.account), + statuses: notifications.map(item => item.status).filter(status => !!status), + next + }; +}; + +export function refreshNotificationsFail(error) { + return { + type: NOTIFICATIONS_REFRESH_FAIL, + error + }; +}; + +export function expandNotifications() { + return (dispatch, getState) => { + const url = getState().getIn(['notifications', 'next'], null); + + if (url === null || getState().getIn(['notifications', 'isLoading'])) { + return; + } + + dispatch(expandNotificationsRequest()); + + const params = {}; + + params.exclude_types = excludeTypesFromSettings(getState()); + + api(getState).get(url, params).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); + fetchRelatedRelationships(dispatch, response.data); + }).catch(error => { + dispatch(expandNotificationsFail(error)); + }); + }; +}; + +export function expandNotificationsRequest() { + return { + type: NOTIFICATIONS_EXPAND_REQUEST + }; +}; + +export function expandNotificationsSuccess(notifications, next) { + return { + type: NOTIFICATIONS_EXPAND_SUCCESS, + notifications, + accounts: notifications.map(item => item.account), + statuses: notifications.map(item => item.status).filter(status => !!status), + next + }; +}; + +export function expandNotificationsFail(error) { + return { + type: NOTIFICATIONS_EXPAND_FAIL, + error + }; +}; + +export function clearNotifications() { + return (dispatch, getState) => { + dispatch({ + type: NOTIFICATIONS_CLEAR + }); + + api(getState).post('/api/v1/notifications/clear'); + }; +}; + +export function scrollTopNotifications(top) { + return { + type: NOTIFICATIONS_SCROLL_TOP, + top + }; +}; diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js new file mode 100644 index 000000000..a161c50ef --- /dev/null +++ b/app/javascript/mastodon/actions/onboarding.js @@ -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()); + } + }; +}; diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js new file mode 100644 index 000000000..094670d62 --- /dev/null +++ b/app/javascript/mastodon/actions/reports.js @@ -0,0 +1,72 @@ +import api from '../api'; + +export const REPORT_INIT = 'REPORT_INIT'; +export const REPORT_CANCEL = 'REPORT_CANCEL'; + +export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST'; +export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS'; +export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; + +export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; +export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; + +export function initReport(account, status) { + return { + type: REPORT_INIT, + account, + status + }; +}; + +export function cancelReport() { + return { + type: REPORT_CANCEL + }; +}; + +export function toggleStatusReport(statusId, checked) { + return { + type: REPORT_STATUS_TOGGLE, + statusId, + checked, + }; +}; + +export function submitReport() { + return (dispatch, getState) => { + dispatch(submitReportRequest()); + + api(getState).post('/api/v1/reports', { + account_id: getState().getIn(['reports', 'new', 'account_id']), + status_ids: getState().getIn(['reports', 'new', 'status_ids']), + comment: getState().getIn(['reports', 'new', 'comment']) + }).then(response => dispatch(submitReportSuccess(response.data))).catch(error => dispatch(submitReportFail(error))); + }; +}; + +export function submitReportRequest() { + return { + type: REPORT_SUBMIT_REQUEST + }; +}; + +export function submitReportSuccess(report) { + return { + type: REPORT_SUBMIT_SUCCESS, + report + }; +}; + +export function submitReportFail(error) { + return { + type: REPORT_SUBMIT_FAIL, + error + }; +}; + +export function changeReportComment(comment) { + return { + type: REPORT_COMMENT_CHANGE, + comment + }; +}; diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js new file mode 100644 index 000000000..df3ae0db1 --- /dev/null +++ b/app/javascript/mastodon/actions/search.js @@ -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 + }; +}; diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js new file mode 100644 index 000000000..c754b30ca --- /dev/null +++ b/app/javascript/mastodon/actions/settings.js @@ -0,0 +1,19 @@ +import axios from 'axios'; + +export const SETTING_CHANGE = 'SETTING_CHANGE'; + +export function changeSetting(key, value) { + return { + type: SETTING_CHANGE, + key, + value + }; +}; + +export function saveSettings() { + return (_, getState) => { + axios.put('/api/web/settings', { + data: getState().get('settings').toJS() + }); + }; +}; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js new file mode 100644 index 000000000..19df2c36c --- /dev/null +++ b/app/javascript/mastodon/actions/statuses.js @@ -0,0 +1,141 @@ +import api from '../api'; + +import { deleteFromTimelines } from './timelines'; +import { fetchStatusCard } from './cards'; + +export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; +export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS'; +export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL'; + +export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST'; +export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS'; +export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL'; + +export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST'; +export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS'; +export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL'; + +export function fetchStatusRequest(id, skipLoading) { + return { + type: STATUS_FETCH_REQUEST, + id, + skipLoading + }; +}; + +export function fetchStatus(id) { + return (dispatch, getState) => { + const skipLoading = getState().getIn(['statuses', id], null) !== null; + + dispatch(fetchContext(id)); + dispatch(fetchStatusCard(id)); + + if (skipLoading) { + return; + } + + dispatch(fetchStatusRequest(id, skipLoading)); + + api(getState).get(`/api/v1/statuses/${id}`).then(response => { + dispatch(fetchStatusSuccess(response.data, skipLoading)); + }).catch(error => { + dispatch(fetchStatusFail(id, error, skipLoading)); + }); + }; +}; + +export function fetchStatusSuccess(status, skipLoading) { + return { + type: STATUS_FETCH_SUCCESS, + status, + skipLoading + }; +}; + +export function fetchStatusFail(id, error, skipLoading) { + return { + type: STATUS_FETCH_FAIL, + id, + error, + skipLoading, + skipAlert: true + }; +}; + +export function deleteStatus(id) { + return (dispatch, getState) => { + dispatch(deleteStatusRequest(id)); + + api(getState).delete(`/api/v1/statuses/${id}`).then(response => { + dispatch(deleteStatusSuccess(id)); + dispatch(deleteFromTimelines(id)); + }).catch(error => { + dispatch(deleteStatusFail(id, error)); + }); + }; +}; + +export function deleteStatusRequest(id) { + return { + type: STATUS_DELETE_REQUEST, + id: id + }; +}; + +export function deleteStatusSuccess(id) { + return { + type: STATUS_DELETE_SUCCESS, + id: id + }; +}; + +export function deleteStatusFail(id, error) { + return { + type: STATUS_DELETE_FAIL, + id: id, + error: error + }; +}; + +export function fetchContext(id) { + return (dispatch, getState) => { + dispatch(fetchContextRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/context`).then(response => { + dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); + + }).catch(error => { + if (error.response.status === 404) { + dispatch(deleteFromTimelines(id)); + } + + dispatch(fetchContextFail(id, error)); + }); + }; +}; + +export function fetchContextRequest(id) { + return { + type: CONTEXT_FETCH_REQUEST, + id + }; +}; + +export function fetchContextSuccess(id, ancestors, descendants) { + return { + type: CONTEXT_FETCH_SUCCESS, + id, + ancestors, + descendants, + statuses: ancestors.concat(descendants) + }; +}; + +export function fetchContextFail(id, error) { + return { + type: CONTEXT_FETCH_FAIL, + id, + error, + skipAlert: true + }; +}; diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js new file mode 100644 index 000000000..3bba99549 --- /dev/null +++ b/app/javascript/mastodon/actions/store.js @@ -0,0 +1,17 @@ +import Immutable from 'immutable'; + +export const STORE_HYDRATE = 'STORE_HYDRATE'; + +const convertState = rawState => + Immutable.fromJS(rawState, (k, v) => + Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x => + Number.isNaN(x * 1) ? x : x * 1)); + +export function hydrateStore(rawState) { + const state = convertState(rawState); + + return { + type: STORE_HYDRATE, + state + }; +}; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js new file mode 100644 index 000000000..6cd1f04b3 --- /dev/null +++ b/app/javascript/mastodon/actions/timelines.js @@ -0,0 +1,186 @@ +import api, { getLinks } from '../api' +import Immutable from 'immutable'; + +export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; +export const TIMELINE_DELETE = 'TIMELINE_DELETE'; + +export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST'; +export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS'; +export const TIMELINE_REFRESH_FAIL = 'TIMELINE_REFRESH_FAIL'; + +export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; +export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; +export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; + +export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; + +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; +export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; + +export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { + return { + type: TIMELINE_REFRESH_SUCCESS, + timeline, + statuses, + skipLoading, + next + }; +}; + +export function updateTimeline(timeline, status) { + return (dispatch, getState) => { + const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; + + dispatch({ + type: TIMELINE_UPDATE, + timeline, + status, + references + }); + }; +}; + +export function deleteFromTimelines(id) { + return (dispatch, getState) => { + const accountId = getState().getIn(['statuses', id, 'account']); + const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); + const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); + + dispatch({ + type: TIMELINE_DELETE, + id, + accountId, + references, + reblogOf + }); + }; +}; + +export function refreshTimelineRequest(timeline, id, skipLoading) { + return { + type: TIMELINE_REFRESH_REQUEST, + timeline, + id, + skipLoading + }; +}; + +export function refreshTimeline(timeline, id = null) { + return function (dispatch, getState) { + if (getState().getIn(['timelines', timeline, 'isLoading'])) { + return; + } + + const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List()); + const newestId = ids.size > 0 ? ids.first() : null; + let params = getState().getIn(['timelines', timeline, 'params'], {}); + const path = getState().getIn(['timelines', timeline, 'path'])(id); + + let skipLoading = false; + + if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) { + if (id === null && getState().getIn(['timelines', timeline, 'online'])) { + // Skip refreshing when timeline is live anyway + return; + } + + params = { ...params, since_id: newestId }; + skipLoading = true; + } + + dispatch(refreshTimelineRequest(timeline, id, skipLoading)); + + api(getState).get(path, { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading, next ? next.uri : null)); + }).catch(error => { + dispatch(refreshTimelineFail(timeline, error, skipLoading)); + }); + }; +}; + +export function refreshTimelineFail(timeline, error, skipLoading) { + return { + type: TIMELINE_REFRESH_FAIL, + timeline, + error, + skipLoading + }; +}; + +export function expandTimeline(timeline) { + return (dispatch, getState) => { + if (getState().getIn(['timelines', timeline, 'isLoading'])) { + return; + } + + if (getState().getIn(['timelines', timeline, 'items']).size === 0) { + return; + } + + const path = getState().getIn(['timelines', timeline, 'path'])(getState().getIn(['timelines', timeline, 'id'])); + const params = getState().getIn(['timelines', timeline, 'params'], {}); + const lastId = getState().getIn(['timelines', timeline, 'items']).last(); + + dispatch(expandTimelineRequest(timeline)); + + api(getState).get(path, { + params: { + ...params, + max_id: lastId, + limit: 10 + } + }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandTimelineSuccess(timeline, response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandTimelineFail(timeline, error)); + }); + }; +}; + +export function expandTimelineRequest(timeline) { + return { + type: TIMELINE_EXPAND_REQUEST, + timeline + }; +}; + +export function expandTimelineSuccess(timeline, statuses, next) { + return { + type: TIMELINE_EXPAND_SUCCESS, + timeline, + statuses, + next + }; +}; + +export function expandTimelineFail(timeline, error) { + return { + type: TIMELINE_EXPAND_FAIL, + timeline, + error + }; +}; + +export function scrollTopTimeline(timeline, top) { + return { + type: TIMELINE_SCROLL_TOP, + timeline, + top + }; +}; + +export function connectTimeline(timeline) { + return { + type: TIMELINE_CONNECT, + timeline + }; +}; + +export function disconnectTimeline(timeline) { + return { + type: TIMELINE_DISCONNECT, + timeline + }; +}; diff --git a/app/javascript/mastodon/api.js b/app/javascript/mastodon/api.js new file mode 100644 index 000000000..185729ce0 --- /dev/null +++ b/app/javascript/mastodon/api.js @@ -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; + } + }] +}); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js new file mode 100644 index 000000000..9016bedb6 --- /dev/null +++ b/app/javascript/mastodon/components/account.js @@ -0,0 +1,93 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from './avatar'; +import DisplayName from './display_name'; +import Permalink from './permalink'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' } +}); + +class Account extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleFollow = this.handleFollow.bind(this); + this.handleBlock = this.handleBlock.bind(this); + this.handleMute = this.handleMute.bind(this); + } + + handleFollow () { + this.props.onFollow(this.props.account); + } + + handleBlock () { + this.props.onBlock(this.props.account); + } + + handleMute () { + this.props.onMute(this.props.account); + } + + render () { + const { account, me, intl } = this.props; + + if (!account) { + return <div />; + } + + let buttons; + + if (account.get('id') !== me && account.get('relationship', null) !== null) { + const following = account.getIn(['relationship', 'following']); + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); + + if (requested) { + buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} /> + } else if (blocking) { + buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; + } else if (muting) { + buttons = <IconButton active={true} icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />; + } else { + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; + } + } + + return ( + <div className='account'> + <div className='account__wrapper'> + <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> + <div className='account__avatar-wrapper'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={36} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__relationship'> + {buttons} + </div> + </div> + </div> + ); + } + +} + +Account.propTypes = { + account: ImmutablePropTypes.map.isRequired, + me: PropTypes.number.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +} + +export default injectIntl(Account); diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js new file mode 100644 index 000000000..6df578b77 --- /dev/null +++ b/app/javascript/mastodon/components/attachment_list.js @@ -0,0 +1,33 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; + +class AttachmentList extends React.PureComponent { + + render () { + const { media } = this.props; + + return ( + <div className='attachment-list'> + <div className='attachment-list__icon'> + <i className='fa fa-link' /> + </div> + + <ul className='attachment-list__list'> + {media.map(attachment => + <li key={attachment.get('id')}> + <a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a> + </li> + )} + </ul> + </div> + ); + } +} + +AttachmentList.propTypes = { + media: ImmutablePropTypes.list.isRequired +}; + +export default AttachmentList; diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js new file mode 100644 index 000000000..6d8d3b2a3 --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -0,0 +1,213 @@ +import React from 'react'; +import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { isRtl } from '../rtl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const textAtCursorMatchesToken = (str, caretPosition) => { + let word; + + let left = str.slice(0, caretPosition).search(/\S+$/); + let right = str.slice(caretPosition).search(/\s/); + + if (right < 0) { + word = str.slice(left); + } else { + word = str.slice(left, right + caretPosition); + } + + if (!word || word.trim().length < 2 || word[0] !== '@') { + return [null, null]; + } + + word = word.trim().toLowerCase().slice(1); + + if (word.length > 0) { + return [left + 1, word]; + } else { + return [null, null]; + } +}; + +class AutosuggestTextarea extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + suggestionsHidden: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0 + }; + this.onChange = this.onChange.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onBlur = this.onBlur.bind(this); + this.onSuggestionClick = this.onSuggestionClick.bind(this); + this.setTextarea = this.setTextarea.bind(this); + this.onPaste = this.onPaste.bind(this); + } + + onChange (e) { + const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); + + if (token !== null && this.state.lastToken !== token) { + this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); + this.props.onSuggestionsFetchRequested(token); + } else if (token === null) { + this.setState({ lastToken: null }); + this.props.onSuggestionsClearRequested(); + } + + // auto-resize textarea + e.target.style.height = `${e.target.scrollHeight}px`; + + this.props.onChange(e); + } + + onKeyDown (e) { + const { suggestions, disabled } = this.props; + const { selectedSuggestion, suggestionsHidden } = this.state; + + if (disabled) { + e.preventDefault(); + return; + } + + switch(e.key) { + case 'Escape': + if (!suggestionsHidden) { + e.preventDefault(); + this.setState({ suggestionsHidden: true }); + } + + break; + case 'ArrowDown': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + + break; + case 'ArrowUp': + if (suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + + break; + case 'Enter': + case 'Tab': + // Select suggestion + if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + } + + break; + } + + if (e.defaultPrevented || !this.props.onKeyDown) { + return; + } + + this.props.onKeyDown(e); + } + + onBlur () { + // If we hide the suggestions immediately, then this will prevent the + // onClick for the suggestions themselves from firing. + // Setting a short window for that to take place before hiding the + // suggestions ensures that can't happen. + setTimeout(() => { + this.setState({ suggestionsHidden: true }); + }, 100); + } + + onSuggestionClick (suggestion, e) { + e.preventDefault(); + this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); + this.textarea.focus(); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { + this.setState({ suggestionsHidden: false }); + } + } + + setTextarea (c) { + this.textarea = c; + } + + onPaste (e) { + if (e.clipboardData && e.clipboardData.files.length === 1) { + this.props.onPaste(e.clipboardData.files) + e.preventDefault(); + } + } + + reset () { + this.textarea.style.height = 'auto'; + } + + render () { + const { value, suggestions, disabled, placeholder, onKeyUp } = this.props; + const { suggestionsHidden, selectedSuggestion } = this.state; + const style = { direction: 'ltr' }; + + if (isRtl(value)) { + style.direction = 'rtl'; + } + + return ( + <div className='autosuggest-textarea'> + <textarea + ref={this.setTextarea} + className='autosuggest-textarea__textarea' + disabled={disabled} + placeholder={placeholder} + autoFocus={true} + value={value} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + onKeyUp={onKeyUp} + onBlur={this.onBlur} + onPaste={this.onPaste} + style={style} + /> + + <div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'> + {suggestions.map((suggestion, i) => ( + <div + role='button' + tabIndex='0' + key={suggestion} + className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} + onClick={this.onSuggestionClick.bind(this, suggestion)}> + <AutosuggestAccountContainer id={suggestion} /> + </div> + ))} + </div> + </div> + ); + } + +}; + +AutosuggestTextarea.propTypes = { + value: PropTypes.string, + suggestions: ImmutablePropTypes.list, + disabled: PropTypes.bool, + placeholder: PropTypes.string, + onSuggestionSelected: PropTypes.func.isRequired, + onSuggestionsClearRequested: PropTypes.func.isRequired, + onSuggestionsFetchRequested: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onKeyUp: PropTypes.func, + onKeyDown: PropTypes.func, + onPaste: PropTypes.func.isRequired, +}; + +export default AutosuggestTextarea; diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js new file mode 100644 index 000000000..47f2715c7 --- /dev/null +++ b/app/javascript/mastodon/components/avatar.js @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class Avatar extends React.PureComponent { + + constructor (props, context) { + super(props, context); + + this.state = { + hovering: false + }; + + this.handleMouseEnter = this.handleMouseEnter.bind(this); + this.handleMouseLeave = this.handleMouseLeave.bind(this); + } + + handleMouseEnter () { + if (this.props.animate) return; + this.setState({ hovering: true }); + } + + handleMouseLeave () { + if (this.props.animate) return; + this.setState({ hovering: false }); + } + + render () { + const { src, size, staticSrc, animate } = this.props; + const { hovering } = this.state; + + const style = { + ...this.props.style, + width: `${size}px`, + height: `${size}px`, + backgroundSize: `${size}px ${size}px` + }; + + if (hovering || animate) { + style.backgroundImage = `url(${src})`; + } else { + style.backgroundImage = `url(${staticSrc})`; + } + + return ( + <div + className='account__avatar' + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} + style={style} + /> + ); + } + +} + +Avatar.propTypes = { + src: PropTypes.string.isRequired, + staticSrc: PropTypes.string, + size: PropTypes.number.isRequired, + style: PropTypes.object, + animate: PropTypes.bool +}; + +Avatar.defaultProps = { + animate: false +}; + +export default Avatar; diff --git a/app/javascript/mastodon/components/button.js b/app/javascript/mastodon/components/button.js new file mode 100644 index 000000000..1063e0289 --- /dev/null +++ b/app/javascript/mastodon/components/button.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class Button extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick (e) { + if (!this.props.disabled) { + this.props.onClick(); + } + } + + render () { + const style = { + display: this.props.block ? 'block' : 'inline-block', + width: this.props.block ? '100%' : 'auto', + padding: `0 ${this.props.size / 2.25}px`, + height: `${this.props.size}px`, + lineHeight: `${this.props.size}px` + }; + + return ( + <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ ...style, ...this.props.style }}> + {this.props.text || this.props.children} + </button> + ); + } + +} + +Button.propTypes = { + text: PropTypes.node, + onClick: PropTypes.func, + disabled: PropTypes.bool, + block: PropTypes.bool, + secondary: PropTypes.bool, + size: PropTypes.number, + style: PropTypes.object, + children: PropTypes.node +}; + +Button.defaultProps = { + size: 36 +}; + +export default Button; diff --git a/app/javascript/mastodon/components/collapsable.js b/app/javascript/mastodon/components/collapsable.js new file mode 100644 index 000000000..a61f67d8e --- /dev/null +++ b/app/javascript/mastodon/components/collapsable.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { Motion, spring } from 'react-motion'; +import PropTypes from 'prop-types'; + +const Collapsable = ({ fullHeight, isVisible, children }) => ( + <Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}> + {({ opacity, height }) => + <div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}> + {children} + </div> + } + </Motion> +); + +Collapsable.propTypes = { + fullHeight: PropTypes.number.isRequired, + isVisible: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired +}; + +export default Collapsable; diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js new file mode 100644 index 000000000..bedc417fd --- /dev/null +++ b/app/javascript/mastodon/components/column_back_button.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +class ColumnBackButton extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick () { + if (window.history && window.history.length === 1) this.context.router.push("/"); + else this.context.router.goBack(); + } + + render () { + return ( + <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button'> + <i className='fa fa-fw fa-chevron-left column-back-button__icon'/> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </div> + ); + } + +}; + +ColumnBackButton.contextTypes = { + router: PropTypes.object +}; + +export default ColumnBackButton; diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js new file mode 100644 index 000000000..9aa7e92c2 --- /dev/null +++ b/app/javascript/mastodon/components/column_back_button_slim.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +class ColumnBackButtonSlim extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick () { + this.context.router.push('/'); + } + + render () { + return ( + <div className='column-back-button--slim'> + <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'> + <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </div> + </div> + ); + } +} + +ColumnBackButtonSlim.contextTypes = { + router: PropTypes.object +}; + +export default ColumnBackButtonSlim; diff --git a/app/javascript/mastodon/components/column_collapsable.js b/app/javascript/mastodon/components/column_collapsable.js new file mode 100644 index 000000000..797946859 --- /dev/null +++ b/app/javascript/mastodon/components/column_collapsable.js @@ -0,0 +1,57 @@ +import React from 'react'; +import { Motion, spring } from 'react-motion'; +import PropTypes from 'prop-types'; + +class ColumnCollapsable extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + collapsed: true + }; + + this.handleToggleCollapsed = this.handleToggleCollapsed.bind(this); + } + + handleToggleCollapsed () { + const currentState = this.state.collapsed; + + this.setState({ collapsed: !currentState }); + + if (!currentState && this.props.onCollapse) { + this.props.onCollapse(); + } + } + + render () { + const { icon, title, fullHeight, children } = this.props; + const { collapsed } = this.state; + const collapsedClassName = collapsed ? 'collapsable-collapsed' : 'collapsable'; + + return ( + <div className='column-collapsable'> + <div role='button' tabIndex='0' title={`${title}`} className={`column-icon ${collapsedClassName}`} onClick={this.handleToggleCollapsed}> + <i className={`fa fa-${icon}`} /> + </div> + + <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}> + {({ opacity, height }) => + <div style={{ overflow: height === fullHeight ? 'auto' : 'hidden', height: `${height}px`, opacity: opacity / 100, maxHeight: '70vh' }}> + {children} + </div> + } + </Motion> + </div> + ); + } +} + +ColumnCollapsable.propTypes = { + icon: PropTypes.string.isRequired, + title: PropTypes.string, + fullHeight: PropTypes.number.isRequired, + children: PropTypes.node, + onCollapse: PropTypes.func +}; + +export default ColumnCollapsable; diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js new file mode 100644 index 000000000..6bdd06db7 --- /dev/null +++ b/app/javascript/mastodon/components/display_name.js @@ -0,0 +1,25 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import escapeTextContentForBrowser from 'escape-html'; +import emojify from '../emoji'; + +class DisplayName extends React.PureComponent { + + render () { + const displayName = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name'); + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + + return ( + <span className='display-name'> + <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHTML} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span> + </span> + ); + } + +}; + +DisplayName.propTypes = { + account: ImmutablePropTypes.map.isRequired +} + +export default DisplayName; diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js new file mode 100644 index 000000000..aed0757b1 --- /dev/null +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -0,0 +1,79 @@ +import React from 'react'; +import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; +import PropTypes from 'prop-types'; + +class DropdownMenu extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + direction: 'left' + }; + this.setRef = this.setRef.bind(this); + this.renderItem = this.renderItem.bind(this); + } + + setRef (c) { + this.dropdown = c; + } + + handleClick (i, e) { + const { action } = this.props.items[i]; + + if (typeof action === 'function') { + e.preventDefault(); + action(); + this.dropdown.hide(); + } + } + + renderItem (item, i) { + if (item === null) { + return <li key={ 'sep' + i } className='dropdown__sep' />; + } + + const { text, action, href = '#' } = item; + + return ( + <li className='dropdown__content-list-item' key={ text + i }> + <a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)} className='dropdown__content-list-link'> + {text} + </a> + </li> + ); + } + + render () { + const { icon, items, size, direction, ariaLabel } = this.props; + const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right"; + + return ( + <Dropdown ref={this.setRef}> + <DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }} aria-label={ariaLabel}> + <i className={ `fa fa-fw fa-${icon} dropdown__icon` } aria-hidden={true} /> + </DropdownTrigger> + + <DropdownContent className={directionClass}> + <ul className='dropdown__content-list'> + {items.map(this.renderItem)} + </ul> + </DropdownContent> + </Dropdown> + ); + } + +} + +DropdownMenu.propTypes = { + icon: PropTypes.string.isRequired, + items: PropTypes.array.isRequired, + size: PropTypes.number.isRequired, + direction: PropTypes.string, + ariaLabel: PropTypes.string +}; + +DropdownMenu.defaultProps = { + ariaLabel: "Menu" +}; + +export default DropdownMenu; diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js new file mode 100644 index 000000000..34ede66fd --- /dev/null +++ b/app/javascript/mastodon/components/extended_video_player.js @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class ExtendedVideoPlayer extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleLoadedData = this.handleLoadedData.bind(this); + this.setRef = this.setRef.bind(this); + } + + handleLoadedData () { + if (this.props.time) { + this.video.currentTime = this.props.time; + } + } + + componentDidMount () { + this.video.addEventListener('loadeddata', this.handleLoadedData); + } + + componentWillUnmount () { + this.video.removeEventListener('loadeddata', this.handleLoadedData); + } + + setRef (c) { + this.video = c; + } + + render () { + return ( + <div className='extended-video-player'> + <video + ref={this.setRef} + src={this.props.src} + autoPlay + muted={this.props.muted} + controls={this.props.controls} + loop={!this.props.controls} + /> + </div> + ); + } + +} + +ExtendedVideoPlayer.propTypes = { + src: PropTypes.string.isRequired, + time: PropTypes.number, + controls: PropTypes.bool.isRequired, + muted: PropTypes.bool.isRequired +}; + +export default ExtendedVideoPlayer; diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js new file mode 100644 index 000000000..87324b6c8 --- /dev/null +++ b/app/javascript/mastodon/components/icon_button.js @@ -0,0 +1,96 @@ +import React from 'react'; +import { Motion, spring } from 'react-motion'; +import PropTypes from 'prop-types'; + +class IconButton extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick (e) { + e.preventDefault(); + + if (!this.props.disabled) { + this.props.onClick(e); + } + } + + render () { + let style = { + fontSize: `${this.props.size}px`, + width: `${this.props.size * 1.28571429}px`, + height: `${this.props.size * 1.28571429}px`, + lineHeight: `${this.props.size}px`, + ...this.props.style + }; + + if (this.props.active) { + style = { ...style, ...this.props.activeStyle }; + } + + const classes = ['icon-button']; + + if (this.props.active) { + classes.push('active'); + } + + if (this.props.disabled) { + classes.push('disabled'); + } + + if (this.props.inverted) { + classes.push('inverted'); + } + + if (this.props.overlay) { + classes.push('overlayed'); + } + + if (this.props.className) { + classes.push(this.props.className) + } + + return ( + <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> + {({ rotate }) => + <button + aria-label={this.props.title} + title={this.props.title} + className={classes.join(' ')} + onClick={this.handleClick} + style={style}> + <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> + </button> + } + </Motion> + ); + } + +} + +IconButton.propTypes = { + className: PropTypes.string, + title: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + onClick: PropTypes.func, + size: PropTypes.number, + active: PropTypes.bool, + style: PropTypes.object, + activeStyle: PropTypes.object, + disabled: PropTypes.bool, + inverted: PropTypes.bool, + animate: PropTypes.bool, + overlay: PropTypes.bool +}; + +IconButton.defaultProps = { + size: 18, + active: false, + disabled: false, + animate: false, + overlay: false +}; + +export default IconButton; diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js new file mode 100644 index 000000000..36dae79af --- /dev/null +++ b/app/javascript/mastodon/components/load_more.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +const LoadMore = ({ onClick }) => ( + <a href="#" className='load-more' role='button' onClick={onClick}> + <FormattedMessage id='status.load_more' defaultMessage='Load more' /> + </a> +); + +LoadMore.propTypes = { + onClick: PropTypes.func +}; + +export default LoadMore; diff --git a/app/javascript/mastodon/components/loading_indicator.js b/app/javascript/mastodon/components/loading_indicator.js new file mode 100644 index 000000000..c09244834 --- /dev/null +++ b/app/javascript/mastodon/components/loading_indicator.js @@ -0,0 +1,10 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +const LoadingIndicator = () => ( + <div className='loading-indicator'> + <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /> + </div> +); + +export default LoadingIndicator; diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js new file mode 100644 index 000000000..dc08c457d --- /dev/null +++ b/app/javascript/mastodon/components/media_gallery.js @@ -0,0 +1,196 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { isIOS } from '../is_mobile'; + +const messages = defineMessages({ + toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' } +}); + +class Item extends React.PureComponent { + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick (e) { + const { index, onClick } = this.props; + + if (e.button === 0) { + e.preventDefault(); + onClick(index); + } + + e.stopPropagation(); + } + + render () { + const { attachment, index, size } = this.props; + + let width = 50; + let height = 100; + let top = 'auto'; + let left = 'auto'; + let bottom = 'auto'; + let right = 'auto'; + + if (size === 1) { + width = 100; + } + + if (size === 4 || (size === 3 && index > 0)) { + height = 50; + } + + if (size === 2) { + if (index === 0) { + right = '2px'; + } else { + left = '2px'; + } + } else if (size === 3) { + if (index === 0) { + right = '2px'; + } else if (index > 0) { + left = '2px'; + } + + if (index === 1) { + bottom = '2px'; + } else if (index > 1) { + top = '2px'; + } + } else if (size === 4) { + if (index === 0 || index === 2) { + right = '2px'; + } + + if (index === 1 || index === 3) { + left = '2px'; + } + + if (index < 2) { + bottom = '2px'; + } else { + top = '2px'; + } + } + + let thumbnail = ''; + + if (attachment.get('type') === 'image') { + thumbnail = ( + <a + className='media-gallery__item-thumbnail' + href={attachment.get('remote_url') || attachment.get('url')} + onClick={this.handleClick} + target='_blank' + style={{ backgroundImage: `url(${attachment.get('preview_url')})` }} + /> + ); + } else if (attachment.get('type') === 'gifv') { + const autoPlay = !isIOS() && this.props.autoPlayGif; + + thumbnail = ( + <div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}> + <video + className='media-gallery__item-gifv-thumbnail' + role='application' + src={attachment.get('url')} + onClick={this.handleClick} + autoPlay={autoPlay} + loop={true} + muted={true} + /> + + <span className='media-gallery__gifv__label'>GIF</span> + </div> + ); + } + + return ( + <div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + {thumbnail} + </div> + ); + } + +} + +Item.propTypes = { + attachment: ImmutablePropTypes.map.isRequired, + index: PropTypes.number.isRequired, + size: PropTypes.number.isRequired, + onClick: PropTypes.func.isRequired, + autoPlayGif: PropTypes.bool.isRequired +}; + +class MediaGallery extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + visible: !props.sensitive + }; + this.handleOpen = this.handleOpen.bind(this); + this.handleClick = this.handleClick.bind(this); + } + + handleOpen (e) { + this.setState({ visible: !this.state.visible }); + } + + handleClick (index) { + this.props.onOpenMedia(this.props.media, index); + } + + render () { + const { media, intl, sensitive } = this.props; + + let children; + + if (!this.state.visible) { + let warning; + + if (sensitive) { + warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; + } else { + warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; + } + + children = ( + <div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}> + <span className='media-spoiler__warning'>{warning}</span> + <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + const size = media.take(4).size; + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />); + } + + return ( + <div className='media-gallery' style={{ height: `${this.props.height}px` }}> + <div className='spoiler-button' style={{ display: !this.state.visible ? 'none' : 'block' }}> + <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> + </div> + + {children} + </div> + ); + } + +} + +MediaGallery.propTypes = { + sensitive: PropTypes.bool, + media: ImmutablePropTypes.list.isRequired, + height: PropTypes.number.isRequired, + onOpenMedia: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + autoPlayGif: PropTypes.bool.isRequired +}; + +export default injectIntl(MediaGallery); diff --git a/app/javascript/mastodon/components/missing_indicator.js b/app/javascript/mastodon/components/missing_indicator.js new file mode 100644 index 000000000..87df7f61c --- /dev/null +++ b/app/javascript/mastodon/components/missing_indicator.js @@ -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; diff --git a/app/javascript/mastodon/components/permalink.js b/app/javascript/mastodon/components/permalink.js new file mode 100644 index 000000000..26444f27c --- /dev/null +++ b/app/javascript/mastodon/components/permalink.js @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class Permalink extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick (e) { + if (e.button === 0) { + e.preventDefault(); + this.context.router.push(this.props.to); + } + } + + render () { + const { href, children, className, ...other } = this.props; + + return ( + <a href={href} onClick={this.handleClick} {...other} className={'permalink ' + className}> + {children} + </a> + ); + } + +} + +Permalink.contextTypes = { + router: PropTypes.object +}; + +Permalink.propTypes = { + className: PropTypes.string, + href: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + children: PropTypes.node +}; + +export default Permalink; diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js new file mode 100644 index 000000000..9c7a8121e --- /dev/null +++ b/app/javascript/mastodon/components/relative_timestamp.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { injectIntl, FormattedRelative } from 'react-intl'; +import PropTypes from 'prop-types'; + +const RelativeTimestamp = ({ intl, timestamp }) => { + const date = new Date(timestamp); + + return ( + <time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}> + <FormattedRelative value={date} /> + </time> + ); +}; + +RelativeTimestamp.propTypes = { + intl: PropTypes.object.isRequired, + timestamp: PropTypes.string.isRequired +}; + +export default injectIntl(RelativeTimestamp); diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js new file mode 100644 index 000000000..39ed6dd4f --- /dev/null +++ b/app/javascript/mastodon/components/status.js @@ -0,0 +1,123 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from './avatar'; +import RelativeTimestamp from './relative_timestamp'; +import DisplayName from './display_name'; +import MediaGallery from './media_gallery'; +import VideoPlayer from './video_player'; +import AttachmentList from './attachment_list'; +import StatusContent from './status_content'; +import StatusActionBar from './status_action_bar'; +import { FormattedMessage } from 'react-intl'; +import emojify from '../emoji'; +import escapeTextContentForBrowser from 'escape-html'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class Status extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + this.handleAccountClick = this.handleAccountClick.bind(this); + } + + handleClick () { + const { status } = this.props; + this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); + } + + handleAccountClick (id, e) { + if (e.button === 0) { + e.preventDefault(); + this.context.router.push(`/accounts/${id}`); + } + } + + render () { + let media = ''; + const { status, ...other } = this.props; + + if (status === null) { + return <div />; + } + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + let displayName = status.getIn(['account', 'display_name']); + + if (displayName.length === 0) { + displayName = status.getIn(['account', 'username']); + } + + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + + return ( + <div className='status__wrapper'> + <div className='status__prepend'> + <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> + <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} /> + </div> + + <Status {...other} wrapped={true} status={status.get('reblog')} /> + </div> + ); + } + + if (status.get('media_attachments').size > 0 && !this.props.muted) { + if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />; + } else { + media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />; + } + } + + return ( + <div className={this.props.muted ? 'status muted' : 'status'}> + <div className='status__info'> + <div className='status__info-time'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + </div> + + <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /> + </div> + + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} onClick={this.handleClick} /> + + {media} + + <StatusActionBar {...this.props} /> + </div> + ); + } + +} + +Status.contextTypes = { + router: PropTypes.object +}; + +Status.propTypes = { + status: ImmutablePropTypes.map, + wrapped: PropTypes.bool, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onDelete: PropTypes.func, + onOpenMedia: PropTypes.func, + onOpenVideo: PropTypes.func, + onBlock: PropTypes.func, + me: PropTypes.number, + boostModal: PropTypes.bool, + autoPlayGif: PropTypes.bool, + muted: PropTypes.bool +}; + +export default Status; diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js new file mode 100644 index 000000000..dc4466d6c --- /dev/null +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -0,0 +1,138 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from './icon_button'; +import DropdownMenu from './dropdown_menu'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + open: { id: 'status.open', defaultMessage: 'Expand this status' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' } +}); + +class StatusActionBar extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleReplyClick = this.handleReplyClick.bind(this); + this.handleFavouriteClick = this.handleFavouriteClick.bind(this); + this.handleReblogClick = this.handleReblogClick.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); + this.handleMentionClick = this.handleMentionClick.bind(this); + this.handleMuteClick = this.handleMuteClick.bind(this); + this.handleBlockClick = this.handleBlockClick.bind(this); + this.handleOpen = this.handleOpen.bind(this); + this.handleReport = this.handleReport.bind(this); + } + + handleReplyClick () { + this.props.onReply(this.props.status, this.context.router); + } + + handleFavouriteClick () { + this.props.onFavourite(this.props.status); + } + + handleReblogClick (e) { + this.props.onReblog(this.props.status, e); + } + + handleDeleteClick () { + this.props.onDelete(this.props.status); + } + + handleMentionClick () { + this.props.onMention(this.props.status.get('account'), this.context.router); + } + + handleMuteClick () { + this.props.onMute(this.props.status.get('account')); + } + + handleBlockClick () { + this.props.onBlock(this.props.status.get('account')); + } + + handleOpen () { + this.context.router.push(`/statuses/${this.props.status.get('id')}`); + } + + handleReport () { + this.props.onReport(this.props.status); + this.context.router.push('/report'); + } + + render () { + const { status, me, intl } = this.props; + const reblog_disabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct'; + let menu = []; + + menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + menu.push(null); + + if (status.getIn(['account', 'id']) === me) { + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); + } else { + menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push(null); + menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); + menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); + menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + } + + let reblogIcon = 'retweet'; + if (status.get('visibility') === 'direct') reblogIcon = 'envelope'; + else if (status.get('visibility') === 'private') reblogIcon = 'lock'; + let reply_icon; + let reply_title; + if (status.get('in_reply_to_id', null) === null) { + reply_icon = "reply"; + reply_title = intl.formatMessage(messages.reply); + } else { + reply_icon = "reply-all"; + reply_title = intl.formatMessage(messages.replyAll); + } + + return ( + <div className='status__action-bar'> + <div className='status__action-bar-button-wrapper'><IconButton title={reply_title} icon={reply_icon} onClick={this.handleReplyClick} /></div> + <div className='status__action-bar-button-wrapper'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div> + <div className='status__action-bar-button-wrapper'><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} className='star-icon' /></div> + + <div className='status__action-bar-dropdown'> + <DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" ariaLabel="More"/> + </div> + </div> + ); + } + +} + +StatusActionBar.contextTypes = { + router: PropTypes.object +}; + +StatusActionBar.propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onDelete: PropTypes.func, + onMention: PropTypes.func, + onMute: PropTypes.func, + onBlock: PropTypes.func, + onReport: PropTypes.func, + me: PropTypes.number.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(StatusActionBar); diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js new file mode 100644 index 000000000..1d462103b --- /dev/null +++ b/app/javascript/mastodon/components/status_content.js @@ -0,0 +1,165 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import escapeTextContentForBrowser from 'escape-html'; +import PropTypes from 'prop-types'; +import emojify from '../emoji'; +import { isRtl } from '../rtl'; +import { FormattedMessage } from 'react-intl'; +import Permalink from './permalink'; + +class StatusContent extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + hidden: true + }; + this.onMentionClick = this.onMentionClick.bind(this); + this.onHashtagClick = this.onHashtagClick.bind(this); + this.handleMouseDown = this.handleMouseDown.bind(this) + this.handleMouseUp = this.handleMouseUp.bind(this); + this.handleSpoilerClick = this.handleSpoilerClick.bind(this); + this.setRef = this.setRef.bind(this); + }; + + componentDidMount () { + const node = this.node; + const links = node.querySelectorAll('a'); + + for (var i = 0; i < links.length; ++i) { + let link = links[i]; + let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); + let media = this.props.status.get('media_attachments').find(item => link.href === item.get('text_url') || (item.get('remote_url').length > 0 && link.href === item.get('remote_url'))); + + if (mention) { + link.addEventListener('click', this.onMentionClick.bind(this, mention), false); + link.setAttribute('title', mention.get('acct')); + } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { + link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); + } else if (media) { + link.innerHTML = '<i class="fa fa-fw fa-photo"></i>'; + } else { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener'); + link.setAttribute('title', link.href); + } + } + } + + onMentionClick (mention, e) { + if (e.button === 0) { + e.preventDefault(); + this.context.router.push(`/accounts/${mention.get('id')}`); + } + } + + onHashtagClick (hashtag, e) { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (e.button === 0) { + e.preventDefault(); + this.context.router.push(`/timelines/tag/${hashtag}`); + } + } + + handleMouseDown (e) { + this.startXY = [e.clientX, e.clientY]; + } + + handleMouseUp (e) { + const [ startX, startY ] = this.startXY; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) { + return; + } + + if (deltaX + deltaY < 5 && e.button === 0) { + this.props.onClick(); + } + + this.startXY = null; + } + + handleSpoilerClick (e) { + e.preventDefault(); + this.setState({ hidden: !this.state.hidden }); + } + + setRef (c) { + this.node = c; + } + + render () { + const { status } = this.props; + const { hidden } = this.state; + + const content = { __html: emojify(status.get('content')) }; + const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) }; + const directionStyle = { direction: 'ltr' }; + + if (isRtl(status.get('content'))) { + directionStyle.direction = 'rtl'; + } + + if (status.get('spoiler_text').length > 0) { + let mentionsPlaceholder = ''; + + const mentionLinks = status.get('mentions').map(item => ( + <Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'> + @<span>{item.get('username')}</span> + </Permalink> + )).reduce((aggregate, item) => [...aggregate, item, ' '], []) + + const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />; + + if (hidden) { + mentionsPlaceholder = <div>{mentionLinks}</div>; + } + + return ( + <div className='status__content' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <p style={{ marginBottom: hidden && status.get('mentions').size === 0 ? '0px' : '' }} > + <span dangerouslySetInnerHTML={spoilerContent} /> <a tabIndex='0' className='status__content__spoiler-link' role='button' onClick={this.handleSpoilerClick}>{toggleText}</a> + </p> + + {mentionsPlaceholder} + + <div ref={this.setRef} style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} /> + </div> + ); + } else if (this.props.onClick) { + return ( + <div + ref={this.setRef} + className='status__content' + style={{ ...directionStyle }} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + dangerouslySetInnerHTML={content} + /> + ); + } else { + return ( + <div + ref={this.setRef} + className='status__content status__content--no-action' + style={{ ...directionStyle }} + dangerouslySetInnerHTML={content} + /> + ); + } + } + +} + +StatusContent.contextTypes = { + router: PropTypes.object +}; + +StatusContent.propTypes = { + status: ImmutablePropTypes.map.isRequired, + onClick: PropTypes.func +}; + +export default StatusContent; diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js new file mode 100644 index 000000000..9abf1fbfe --- /dev/null +++ b/app/javascript/mastodon/components/status_list.js @@ -0,0 +1,130 @@ +import React from 'react'; +import Status from './status'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { ScrollContainer } from 'react-router-scroll'; +import PropTypes from 'prop-types'; +import StatusContainer from '../containers/status_container'; +import LoadMore from './load_more'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class StatusList extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + this.setRef = this.setRef.bind(this); + this.handleLoadMore = this.handleLoadMore.bind(this); + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + const offset = scrollHeight - scrollTop - clientHeight; + this._oldScrollPosition = scrollHeight - scrollTop; + + if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) { + this.props.onScrollToBottom(); + } else if (scrollTop < 100 && this.props.onScrollToTop) { + this.props.onScrollToTop(); + } else if (this.props.onScroll) { + this.props.onScroll(); + } + } + + componentDidMount () { + this.attachScrollListener(); + } + + componentDidUpdate (prevProps) { + if (this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) { + this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition; + } + } + + componentWillUnmount () { + this.detachScrollListener(); + } + + attachScrollListener () { + this.node.addEventListener('scroll', this.handleScroll); + } + + detachScrollListener () { + this.node.removeEventListener('scroll', this.handleScroll); + } + + setRef (c) { + this.node = c; + } + + handleLoadMore (e) { + e.preventDefault(); + this.props.onScrollToBottom(); + } + + render () { + const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props; + + let loadMore = ''; + let scrollableArea = ''; + let unread = ''; + + if (!isLoading && statusIds.size > 0 && hasMore) { + loadMore = <LoadMore onClick={this.handleLoadMore} />; + } + + if (isUnread) { + unread = <div className='status-list__unread-indicator' />; + } + + if (isLoading || statusIds.size > 0 || !emptyMessage) { + scrollableArea = ( + <div className='scrollable' ref={this.setRef}> + {unread} + + <div className='status-list'> + {prepend} + + {statusIds.map((statusId) => { + return <StatusContainer key={statusId} id={statusId} />; + })} + + {loadMore} + </div> + </div> + ); + } else { + scrollableArea = ( + <div className='empty-column-indicator' ref={this.setRef}> + {emptyMessage} + </div> + ); + } + + return ( + <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}> + {scrollableArea} + </ScrollContainer> + ); + } + +} + +StatusList.propTypes = { + scrollKey: PropTypes.string.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + onScrollToBottom: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + isUnread: PropTypes.bool, + hasMore: PropTypes.bool, + prepend: PropTypes.node, + emptyMessage: PropTypes.node +}; + +StatusList.defaultProps = { + trackScroll: true +}; + +export default StatusList; diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js new file mode 100644 index 000000000..0c8aea3a9 --- /dev/null +++ b/app/javascript/mastodon/components/video_player.js @@ -0,0 +1,210 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from './icon_button'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { isIOS } from '../is_mobile'; + +const messages = defineMessages({ + toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, + toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }, + expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' }, + expand_video: { id: 'video_player.video_error', defaultMessage: 'Video could not be played' } +}); + +class VideoPlayer extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + visible: !this.props.sensitive, + preview: true, + muted: true, + hasAudio: true, + videoError: false + }; + + this.handleClick = this.handleClick.bind(this); + this.handleVideoClick = this.handleVideoClick.bind(this); + this.handleOpen = this.handleOpen.bind(this); + this.handleVisibility = this.handleVisibility.bind(this); + this.handleExpand = this.handleExpand.bind(this); + this.setRef = this.setRef.bind(this); + this.handleLoadedData = this.handleLoadedData.bind(this); + this.handleVideoError = this.handleVideoError.bind(this); + } + + handleClick () { + this.setState({ muted: !this.state.muted }); + } + + handleVideoClick (e) { + e.stopPropagation(); + + const node = this.video; + + if (node.paused) { + node.play(); + } else { + node.pause(); + } + } + + handleOpen () { + this.setState({ preview: !this.state.preview }); + } + + handleVisibility () { + this.setState({ + visible: !this.state.visible, + preview: true + }); + } + + handleExpand () { + this.video.pause(); + this.props.onOpenVideo(this.props.media, this.video.currentTime); + } + + setRef (c) { + this.video = c; + } + + handleLoadedData () { + if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) { + this.setState({ hasAudio: false }); + } + } + + handleVideoError () { + this.setState({ videoError: true }); + } + + componentDidMount () { + if (!this.video) { + return; + } + + this.video.addEventListener('loadeddata', this.handleLoadedData); + this.video.addEventListener('error', this.handleVideoError); + } + + componentDidUpdate () { + if (!this.video) { + return; + } + + this.video.addEventListener('loadeddata', this.handleLoadedData); + this.video.addEventListener('error', this.handleVideoError); + } + + componentWillUnmount () { + if (!this.video) { + return; + } + + this.video.removeEventListener('loadeddata', this.handleLoadedData); + this.video.removeEventListener('error', this.handleVideoError); + } + + render () { + const { media, intl, width, height, sensitive, autoplay } = this.props; + + let spoilerButton = ( + <div className='status__video-player-spoiler' style={{ display: !this.state.visible ? 'none' : 'block' }} > + <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} /> + </div> + ); + + let expandButton = ( + <div className='status__video-player-expand'> + <IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} /> + </div> + ); + + let muteButton = ''; + + if (this.state.hasAudio) { + muteButton = ( + <div className='status__video-player-mute'> + <IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /> + </div> + ); + } + + if (!this.state.visible) { + if (sensitive) { + return ( + <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}> + {spoilerButton} + <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + return ( + <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}> + {spoilerButton} + <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> + <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } + } + + if (this.state.preview && !autoplay) { + return ( + <div role='button' tabIndex='0' className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center` }} onClick={this.handleOpen}> + {spoilerButton} + <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div> + </div> + ); + } + + if (this.state.videoError) { + return ( + <div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' > + <span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span> + </div> + ); + } + + return ( + <div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}> + {spoilerButton} + {muteButton} + {expandButton} + + <video + className='status__video-player-video' + role='button' + tabIndex='0' + ref={this.setRef} + src={media.get('url')} + autoPlay={!isIOS()} + loop={true} + muted={this.state.muted} + onClick={this.handleVideoClick} + /> + </div> + ); + } + +} + +VideoPlayer.propTypes = { + media: ImmutablePropTypes.map.isRequired, + width: PropTypes.number, + height: PropTypes.number, + sensitive: PropTypes.bool, + intl: PropTypes.object.isRequired, + autoplay: PropTypes.bool, + onOpenVideo: PropTypes.func.isRequired +}; + +VideoPlayer.defaultProps = { + width: 239, + height: 110 +}; + +export default injectIntl(VideoPlayer); diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js new file mode 100644 index 000000000..3c30be715 --- /dev/null +++ b/app/javascript/mastodon/containers/account_container.js @@ -0,0 +1,50 @@ +import { connect } from 'react-redux'; +import { makeGetAccount } from '../selectors'; +import Account from '../components/account'; +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + muteAccount, + unmuteAccount, +} from '../actions/accounts'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id), + me: state.getIn(['meta', 'me']) + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch) => ({ + onFollow (account) { + if (account.getIn(['relationship', 'following'])) { + dispatch(unfollowAccount(account.get('id'))); + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(blockAccount(account.get('id'))); + } + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(muteAccount(account.get('id'))); + } + } +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(Account); diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js new file mode 100644 index 000000000..637199686 --- /dev/null +++ b/app/javascript/mastodon/containers/mastodon.js @@ -0,0 +1,314 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from '../store/configureStore'; +import { + refreshTimelineSuccess, + updateTimeline, + deleteFromTimelines, + refreshTimeline, + connectTimeline, + disconnectTimeline +} from '../actions/timelines'; +import { showOnboardingOnce } from '../actions/onboarding'; +import { updateNotifications, refreshNotifications } from '../actions/notifications'; +import createBrowserHistory from 'history/lib/createBrowserHistory'; +import { + applyRouterMiddleware, + useRouterHistory, + Router, + Route, + IndexRedirect, + IndexRoute +} from 'react-router'; +import { useScroll } from 'react-router-scroll'; +import UI from '../features/ui'; +import Status from '../features/status'; +import GettingStarted from '../features/getting_started'; +import PublicTimeline from '../features/public_timeline'; +import CommunityTimeline from '../features/community_timeline'; +import AccountTimeline from '../features/account_timeline'; +import HomeTimeline from '../features/home_timeline'; +import Compose from '../features/compose'; +import Followers from '../features/followers'; +import Following from '../features/following'; +import Reblogs from '../features/reblogs'; +import Favourites from '../features/favourites'; +import HashtagTimeline from '../features/hashtag_timeline'; +import Notifications from '../features/notifications'; +import FollowRequests from '../features/follow_requests'; +import GenericNotFound from '../features/generic_not_found'; +import FavouritedStatuses from '../features/favourited_statuses'; +import Blocks from '../features/blocks'; +import Mutes from '../features/mutes'; +import Report from '../features/report'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import ar from 'react-intl/locale-data/ar'; +import en from 'react-intl/locale-data/en'; +import de from 'react-intl/locale-data/de'; +import eo from 'react-intl/locale-data/eo'; +import es from 'react-intl/locale-data/es'; +import fa from 'react-intl/locale-data/fa'; +import fi from 'react-intl/locale-data/fi'; +import fr from 'react-intl/locale-data/fr'; +import he from 'react-intl/locale-data/he'; +import hu from 'react-intl/locale-data/hu'; +import it from 'react-intl/locale-data/it'; +import ja from 'react-intl/locale-data/ja'; +import pt from 'react-intl/locale-data/pt'; +import nl from 'react-intl/locale-data/nl'; +import no from 'react-intl/locale-data/no'; +import ru from 'react-intl/locale-data/ru'; +import uk from 'react-intl/locale-data/uk'; +import zh from 'react-intl/locale-data/zh'; +import bg from 'react-intl/locale-data/bg'; +import id from 'react-intl/locale-data/id'; +import getMessagesForLocale from '../locales'; +import { hydrateStore } from '../actions/store'; +import createStream from '../stream'; + +const store = configureStore(); +const initialState = JSON.parse(document.getElementById("initial-state").textContent); +store.dispatch(hydrateStore(initialState)); + +const browserHistory = useRouterHistory(createBrowserHistory)({ + basename: '/web' +}); + +addLocaleData([ + ...en, + ...ar, + ...de, + ...eo, + ...es, + ...fa, + ...fi, + ...fr, + ...he, + ...hu, + ...it, + ...ja, + ...pt, + ...nl, + ...no, + ...ru, + ...uk, + ...zh, + ...bg, + ...id, +]); + +const getTopWhenReplacing = (previous, { location }) => location && location.action === 'REPLACE' && [0, 0]; + +const hiddenColumnContainerStyle = { + position: 'absolute', + left: '0', + top: '0', + visibility: 'hidden' +}; + +class Container extends React.PureComponent { + + constructor(props) { + super(props); + + this.state = { + renderedPersistents: [], + unrenderedPersistents: [], + }; + } + + componentWillMount () { + this.unlistenHistory = null; + + this.setState(() => { + return { + mountImpersistent: false, + renderedPersistents: [], + unrenderedPersistents: [ + {pathname: '/timelines/home', component: HomeTimeline}, + {pathname: '/timelines/public', component: PublicTimeline}, + {pathname: '/timelines/public/local', component: CommunityTimeline}, + + {pathname: '/notifications', component: Notifications}, + {pathname: '/favourites', component: FavouritedStatuses} + ], + }; + }, () => { + if (this.unlistenHistory) { + return; + } + + this.unlistenHistory = browserHistory.listen(location => { + const pathname = location.pathname.replace(/\/$/, '').toLowerCase(); + + this.setState(oldState => { + let persistentMatched = false; + + const newState = { + renderedPersistents: oldState.renderedPersistents.map(persistent => { + const givenMatched = persistent.pathname === pathname; + + if (givenMatched) { + persistentMatched = true; + } + + return { + hidden: !givenMatched, + pathname: persistent.pathname, + component: persistent.component + }; + }), + }; + + if (!persistentMatched) { + newState.unrenderedPersistents = []; + + oldState.unrenderedPersistents.forEach(persistent => { + if (persistent.pathname === pathname) { + persistentMatched = true; + + newState.renderedPersistents.push({ + hidden: false, + pathname: persistent.pathname, + component: persistent.component + }); + } else { + newState.unrenderedPersistents.push(persistent); + } + }); + } + + newState.mountImpersistent = !persistentMatched; + + return newState; + }); + }); + }); + } + + componentWillUnmount () { + if (this.unlistenHistory) { + this.unlistenHistory(); + } + + this.unlistenHistory = "done"; + } + + render () { + // Hide some components rather than unmounting them to allow to show again + // quickly and keep the view state such as the scrolled offset. + const persistentsView = this.state.renderedPersistents.map((persistent) => + <div aria-hidden={persistent.hidden} key={persistent.pathname} className='mastodon-column-container' style={persistent.hidden ? hiddenColumnContainerStyle : null}> + <persistent.component shouldUpdateScroll={persistent.hidden ? Function.prototype : getTopWhenReplacing} /> + </div> + ); + + return ( + <UI> + {this.state.mountImpersistent && this.props.children} + {persistentsView} + </UI> + ); + } +} + +Container.propTypes = { + children: PropTypes.node, +}; + +class Mastodon extends React.Component { + + componentDidMount() { + const { locale } = this.props; + const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = store.getState().getIn(['meta', 'access_token']); + + this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', { + + connected () { + store.dispatch(connectTimeline('home')); + }, + + disconnected () { + store.dispatch(disconnectTimeline('home')); + }, + + received (data) { + switch(data.event) { + case 'update': + store.dispatch(updateTimeline('home', JSON.parse(data.payload))); + break; + case 'delete': + store.dispatch(deleteFromTimelines(data.payload)); + break; + case 'notification': + store.dispatch(updateNotifications(JSON.parse(data.payload), getMessagesForLocale(locale), locale)); + break; + } + }, + + reconnected () { + store.dispatch(connectTimeline('home')); + store.dispatch(refreshTimeline('home')); + store.dispatch(refreshNotifications()); + } + + }); + + // Desktop notifications + if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { + Notification.requestPermission(); + } + + store.dispatch(showOnboardingOnce()); + } + + componentWillUnmount () { + if (typeof this.subscription !== 'undefined') { + this.subscription.close(); + this.subscription = null; + } + } + + render () { + const { locale } = this.props; + + return ( + <IntlProvider locale={locale} messages={getMessagesForLocale(locale)}> + <Provider store={store}> + <Router history={browserHistory} render={applyRouterMiddleware(useScroll())}> + <Route path='/' component={Container}> + <IndexRedirect to='/getting-started' /> + <Route path='getting-started' component={GettingStarted} /> + <Route path='timelines/tag/:id' component={HashtagTimeline} /> + + <Route path='statuses/new' component={Compose} /> + <Route path='statuses/:statusId' component={Status} /> + <Route path='statuses/:statusId/reblogs' component={Reblogs} /> + <Route path='statuses/:statusId/favourites' component={Favourites} /> + + <Route path='accounts/:accountId' component={AccountTimeline} /> + <Route path='accounts/:accountId/followers' component={Followers} /> + <Route path='accounts/:accountId/following' component={Following} /> + + <Route path='follow_requests' component={FollowRequests} /> + <Route path='blocks' component={Blocks} /> + <Route path='mutes' component={Mutes} /> + <Route path='report' component={Report} /> + + <Route path='*' component={GenericNotFound} /> + </Route> + </Router> + </Provider> + </IntlProvider> + ); + } + +} + +Mastodon.propTypes = { + locale: PropTypes.string.isRequired +}; + +export default Mastodon; diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js new file mode 100644 index 000000000..eb1f1ab79 --- /dev/null +++ b/app/javascript/mastodon/containers/status_container.js @@ -0,0 +1,118 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Status from '../components/status'; +import { makeGetStatus } from '../selectors'; +import { + replyCompose, + mentionCompose +} from '../actions/compose'; +import { + reblog, + favourite, + unreblog, + unfavourite +} from '../actions/interactions'; +import { + blockAccount, + muteAccount +} from '../actions/accounts'; +import { deleteStatus } from '../actions/statuses'; +import { initReport } from '../actions/reports'; +import { openModal } from '../actions/modal'; +import { createSelector } from 'reselect' +import { isMobile } from '../is_mobile' +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, props.id), + me: state.getIn(['meta', 'me']), + boostModal: state.getIn(['meta', 'boost_modal']), + autoPlayGif: state.getIn(['meta', 'auto_play_gif']) + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onReply (status, router) { + dispatch(replyCompose(status, router)); + }, + + onModalReblog (status) { + dispatch(reblog(status)); + }, + + onReblog (status, e) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + if (e.shiftKey || !this.boostModal) { + this.onModalReblog(status); + } else { + dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); + } + } + }, + + onFavourite (status) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }, + + onDelete (status) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'))) + })); + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onOpenMedia (media, index) { + dispatch(openModal('MEDIA', { media, index })); + }, + + onOpenVideo (media, time) { + dispatch(openModal('VIDEO', { media, time })); + }, + + onBlock (account) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.get('id'))) + })); + }, + + onReport (status) { + dispatch(initReport(status.get('account'), status)); + }, + + onMute (account) { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.muteConfirm), + onConfirm: () => dispatch(muteAccount(account.get('id'))) + })); + }, + +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js new file mode 100644 index 000000000..eee657b86 --- /dev/null +++ b/app/javascript/mastodon/emoji.js @@ -0,0 +1,35 @@ +import emojione from 'emojione'; + +const toImage = str => shortnameToImage(unicodeToImage(str)); + +const unicodeToImage = str => { + const mappedUnicode = emojione.mapUnicodeToShort(); + + return str.replace(emojione.regUnicode, unicodeChar => { + if (typeof unicodeChar === 'undefined' || unicodeChar === '' || !(unicodeChar in emojione.jsEscapeMap)) { + return unicodeChar; + } + + const unicode = emojione.jsEscapeMap[unicodeChar]; + const short = mappedUnicode[unicode]; + const filename = emojione.emojioneList[short].fname; + const alt = emojione.convert(unicode.toUpperCase()); + + return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`; + }); +}; + +const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => { + if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) { + return shortname; + } + + const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1]; + const alt = emojione.convert(unicode.toUpperCase()); + + return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${unicode}.svg" />`; +}); + +export default function emojify(text) { + return toImage(text); +}; diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js new file mode 100644 index 000000000..069348050 --- /dev/null +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -0,0 +1,93 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import DropdownMenu from '../../../components/dropdown_menu'; +import { Link } from 'react-router'; +import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; + +const messages = defineMessages({ + mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, + edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + report: { id: 'account.report', defaultMessage: 'Report @{name}' }, + disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' } +}); + +class ActionBar extends React.PureComponent { + + render () { + const { account, me, intl } = this.props; + + let menu = []; + let extraInfo = ''; + + menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); + menu.push(null); + + if (account.get('id') === me) { + menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); + } else { + if (account.getIn(['relationship', 'muting'])) { + menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); + } else { + menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute }); + } + + if (account.getIn(['relationship', 'blocking'])) { + menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock }); + } else { + menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock }); + } + + menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport }); + } + + if (account.get('acct') !== account.get('username')) { + extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>; + } + + return ( + <div className='account__action-bar'> + <div className='account__action-bar-dropdown'> + <DropdownMenu items={menu} icon='bars' size={24} direction="right" /> + </div> + + <div className='account__action-bar-links'> + <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> + <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span> + <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong> + </Link> + + <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> + <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span> + <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong> + </Link> + + <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> + <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span> + <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong> + </Link> + </div> + </div> + ); + } + +} + +ActionBar.propTypes = { + account: ImmutablePropTypes.map.isRequired, + me: PropTypes.number.isRequired, + onFollow: PropTypes.func, + onBlock: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReport: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(ActionBar); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js new file mode 100644 index 000000000..fbaa5e9e6 --- /dev/null +++ b/app/javascript/mastodon/features/account/components/header.js @@ -0,0 +1,150 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import emojify from '../../../emoji'; +import escapeTextContentForBrowser from 'escape-html'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import { Motion, spring } from 'react-motion'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' } +}); + +const makeMapStateToProps = () => { + const mapStateToProps = (state, props) => ({ + autoPlayGif: state.getIn(['meta', 'auto_play_gif']) + }); + + return mapStateToProps; +}; + +class Avatar extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + + this.state = { + isHovered: false + }; + + this.handleMouseOver = this.handleMouseOver.bind(this); + this.handleMouseOut = this.handleMouseOut.bind(this); + } + + handleMouseOver () { + if (this.state.isHovered) return; + this.setState({ isHovered: true }); + } + + handleMouseOut () { + if (!this.state.isHovered) return; + this.setState({ isHovered: false }); + } + + render () { + const { account, autoPlayGif } = this.props; + const { isHovered } = this.state; + + return ( + <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}> + {({ radius }) => + <a + href={account.get('url')} + className='account__header__avatar' + target='_blank' + rel='noopener' + style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }} + onMouseOver={this.handleMouseOver} + onMouseOut={this.handleMouseOut} + onFocus={this.handleMouseOver} + onBlur={this.handleMouseOut} + /> + } + </Motion> + ); + } + +} + +Avatar.propTypes = { + account: ImmutablePropTypes.map.isRequired, + autoPlayGif: PropTypes.bool.isRequired +}; + +class Header extends ImmutablePureComponent { + + render () { + const { account, me, intl } = this.props; + + if (!account) { + return null; + } + + let displayName = account.get('display_name'); + let info = ''; + let actionBtn = ''; + let lockedIcon = ''; + + if (displayName.length === 0) { + displayName = account.get('username'); + } + + if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { + info = <span className='account--follows-info' style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span> + } + + if (me !== account.get('id')) { + if (account.getIn(['relationship', 'requested'])) { + actionBtn = ( + <div style={{ position: 'absolute', top: '10px', left: '20px' }}> + <IconButton size={26} disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} /> + </div> + ); + } else if (!account.getIn(['relationship', 'blocking'])) { + actionBtn = ( + <div style={{ position: 'absolute', top: '10px', left: '20px' }}> + <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> + </div> + ); + } + } + + if (account.get('locked')) { + lockedIcon = <i className='fa fa-lock' />; + } + + const content = { __html: emojify(account.get('note')) }; + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + + return ( + <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}> + <div style={{ padding: '20px 10px' }}> + <Avatar account={account} autoPlayGif={this.props.autoPlayGif} /> + + <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} /> + <span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span> + <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} /> + + {info} + {actionBtn} + </div> + </div> + ); + } + +} + +Header.propTypes = { + account: ImmutablePropTypes.map, + me: PropTypes.number.isRequired, + onFollow: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + autoPlayGif: PropTypes.bool.isRequired +}; + +export default connect(makeMapStateToProps)(injectIntl(Header)); diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js new file mode 100644 index 000000000..b4dca3a57 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -0,0 +1,83 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import InnerHeader from '../../account/components/header'; +import ActionBar from '../../account/components/action_bar'; +import MissingIndicator from '../../../components/missing_indicator'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class Header extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleFollow = this.handleFollow.bind(this); + this.handleBlock = this.handleBlock.bind(this); + this.handleMention = this.handleMention.bind(this); + this.handleReport = this.handleReport.bind(this); + this.handleMute = this.handleMute.bind(this); + } + + handleFollow () { + this.props.onFollow(this.props.account); + } + + handleBlock () { + this.props.onBlock(this.props.account); + } + + handleMention () { + this.props.onMention(this.props.account, this.context.router); + } + + handleReport () { + this.props.onReport(this.props.account); + this.context.router.push('/report'); + } + + handleMute() { + this.props.onMute(this.props.account); + } + + render () { + const { account, me } = this.props; + + if (account === null) { + return <MissingIndicator />; + } + + return ( + <div className='account-timeline__header'> + <InnerHeader + account={account} + me={me} + onFollow={this.handleFollow} + /> + + <ActionBar + account={account} + me={me} + onBlock={this.handleBlock} + onMention={this.handleMention} + onReport={this.handleReport} + onMute={this.handleMute} + /> + </div> + ); + } +} + +Header.propTypes = { + account: ImmutablePropTypes.map, + me: PropTypes.number.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReport: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired +}; + +Header.contextTypes = { + router: PropTypes.object +}; + +export default Header; diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js new file mode 100644 index 000000000..50999d2e0 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { makeGetAccount } from '../../../selectors'; +import Header from '../components/header'; +import { + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + muteAccount, + unmuteAccount +} from '../../../actions/accounts'; +import { mentionCompose } from '../../../actions/compose'; +import { initReport } from '../../../actions/reports'; +import { openModal } from '../../../actions/modal'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' } +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, Number(accountId)), + me: state.getIn(['meta', 'me']) + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onFollow (account) { + if (account.getIn(['relationship', 'following'])) { + dispatch(unfollowAccount(account.get('id'))); + } else { + dispatch(followAccount(account.get('id'))); + } + }, + + onBlock (account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.get('id'))) + })); + } + }, + + onMention (account, router) { + dispatch(mentionCompose(account, router)); + }, + + onReport (account) { + dispatch(initReport(account)); + }, + + onMute (account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.muteConfirm), + onConfirm: () => dispatch(muteAccount(account.get('id'))) + })); + } + } +}); + +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js new file mode 100644 index 000000000..fb76f4d2e --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -0,0 +1,89 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { + fetchAccount, + fetchAccountTimeline, + expandAccountTimeline +} from '../../actions/accounts'; +import StatusList from '../../components/status_list'; +import LoadingIndicator from '../../components/loading_indicator'; +import Column from '../ui/components/column'; +import HeaderContainer from './containers/header_container'; +import ColumnBackButton from '../../components/column_back_button'; +import Immutable from 'immutable'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items'], Immutable.List()), + isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']), + hasMore: !!state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'next']), + me: state.getIn(['meta', 'me']) +}); + +class AccountTimeline extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScrollToBottom = this.handleScrollToBottom.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); + this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId))); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); + this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId))); + } + } + + handleScrollToBottom () { + if (!this.props.isLoading && this.props.hasMore) { + this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId))); + } + } + + render () { + const { statusIds, isLoading, hasMore, me } = this.props; + + if (!statusIds && isLoading) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <StatusList + prepend={<HeaderContainer accountId={this.props.params.accountId} />} + scrollKey='account_timeline' + statusIds={statusIds} + isLoading={isLoading} + hasMore={hasMore} + me={me} + onScrollToBottom={this.handleScrollToBottom} + /> + </Column> + ); + } + +} + +AccountTimeline.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + me: PropTypes.number.isRequired +}; + +export default connect(mapStateToProps)(AccountTimeline); diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js new file mode 100644 index 000000000..e25d9b2b4 --- /dev/null +++ b/app/javascript/mastodon/features/blocks/index.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import LoadingIndicator from '../../components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll'; +import Column from '../ui/components/column'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import AccountContainer from '../../containers/account_container'; +import { fetchBlocks, expandBlocks } from '../../actions/blocks'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.blocks', defaultMessage: 'Blocked users' } +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'blocks', 'items']) +}); + +class Blocks extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchBlocks()); + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandBlocks()); + } + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='ban' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='blocks'> + <div className='scrollable' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } +} + +Blocks.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(Blocks)); diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js new file mode 100644 index 000000000..883263631 --- /dev/null +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -0,0 +1,96 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../ui/components/column'; +import { + refreshTimeline, + updateTimeline, + deleteFromTimelines, + connectTimeline, + disconnectTimeline +} from '../../actions/timelines'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import createStream from '../../stream'; + +const messages = defineMessages({ + title: { id: 'column.community', defaultMessage: 'Local timeline' } +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, + streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), + accessToken: state.getIn(['meta', 'access_token']) +}); + +let subscription; + +class CommunityTimeline extends React.PureComponent { + + componentDidMount () { + const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + + dispatch(refreshTimeline('community')); + + if (typeof subscription !== 'undefined') { + return; + } + + subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { + + connected () { + dispatch(connectTimeline('community')); + }, + + reconnected () { + dispatch(connectTimeline('community')); + }, + + disconnected () { + dispatch(disconnectTimeline('community')); + }, + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('community', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + } + } + + }); + } + + componentWillUnmount () { + // if (typeof subscription !== 'undefined') { + // subscription.close(); + // subscription = null; + // } + } + + render () { + const { intl, hasUnread } = this.props; + + return ( + <Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}> + <ColumnBackButtonSlim /> + <StatusListContainer {...this.props} scrollKey='community_timeline' type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} /> + </Column> + ); + } + +} + +CommunityTimeline.propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + streamingAPIBaseURL: PropTypes.string.isRequired, + accessToken: PropTypes.string.isRequired, + hasUnread: PropTypes.bool +}; + +export default connect(mapStateToProps)(injectIntl(CommunityTimeline)); diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.js b/app/javascript/mastodon/features/compose/components/autosuggest_account.js new file mode 100644 index 000000000..3d87c4649 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/autosuggest_account.js @@ -0,0 +1,26 @@ +import React from 'react'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class AutosuggestAccount extends ImmutablePureComponent { + + render () { + const { account } = this.props; + + return ( + <div className='autosuggest-account'> + <div className='autosuggest-account-icon'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={18} /></div> + <DisplayName account={account} /> + </div> + ); + } + +} + +AutosuggestAccount.propTypes = { + account: ImmutablePropTypes.map.isRequired +}; + +export default AutosuggestAccount; diff --git a/app/javascript/mastodon/features/compose/components/character_counter.js b/app/javascript/mastodon/features/compose/components/character_counter.js new file mode 100644 index 000000000..617f85cfe --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/character_counter.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { length } from 'stringz'; + +class CharacterCounter extends React.PureComponent { + + checkRemainingText (diff) { + if (diff < 0) { + return <span className='character-counter character-counter--over'>{diff}</span>; + } + return <span className='character-counter'>{diff}</span>; + } + + render () { + const diff = this.props.max - length(this.props.text); + + return this.checkRemainingText(diff); + } + +} + +CharacterCounter.propTypes = { + text: PropTypes.string.isRequired, + max: PropTypes.number.isRequired +} + +export default CharacterCounter; diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js new file mode 100644 index 000000000..0b9c097e3 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -0,0 +1,211 @@ +import React from 'react'; +import CharacterCounter from './character_counter'; +import Button from '../../../components/button'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; +import AutosuggestTextarea from '../../../components/autosuggest_textarea'; +import { debounce } from 'react-decoration'; +import UploadButtonContainer from '../containers/upload_button_container'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Toggle from 'react-toggle'; +import Collapsable from '../../../components/collapsable'; +import SpoilerButtonContainer from '../containers/spoiler_button_container'; +import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import EmojiPickerDropdown from './emoji_picker_dropdown'; +import UploadFormContainer from '../containers/upload_form_container'; +import TextIconButton from './text_icon_button'; +import WarningContainer from '../containers/warning_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' }, + publish: { id: 'compose_form.publish', defaultMessage: 'Toot' } +}); + +class ComposeForm extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleChange = this.handleChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.onSuggestionsClearRequested = this.onSuggestionsClearRequested.bind(this); + this.onSuggestionsFetchRequested = this.onSuggestionsFetchRequested.bind(this); + this.onSuggestionSelected = this.onSuggestionSelected.bind(this); + this.handleChangeSpoilerText = this.handleChangeSpoilerText.bind(this); + this.setAutosuggestTextarea = this.setAutosuggestTextarea.bind(this); + this.handleEmojiPick = this.handleEmojiPick.bind(this); + } + + handleChange (e) { + this.props.onChange(e.target.value); + } + + handleKeyDown (e) { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + } + + handleSubmit () { + this.autosuggestTextarea.reset(); + this.props.onSubmit(); + } + + onSuggestionsClearRequested () { + this.props.onClearSuggestions(); + } + + @debounce(500) + onSuggestionsFetchRequested (token) { + this.props.onFetchSuggestions(token); + } + + onSuggestionSelected (tokenStart, token, value) { + this._restoreCaret = null; + this.props.onSuggestionSelected(tokenStart, token, value); + } + + handleChangeSpoilerText (e) { + this.props.onChangeSpoilerText(e.target.value); + } + + componentWillReceiveProps (nextProps) { + // If this is the update where we've finished uploading, + // save the last caret position so we can restore it below! + if (!nextProps.is_uploading && this.props.is_uploading) { + this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart; + } + } + + componentDidUpdate (prevProps) { + // This statement does several things: + // - If we're beginning a reply, and, + // - Replying to zero or one users, places the cursor at the end of the textbox. + // - Replying to more than one user, selects any usernames past the first; + // this provides a convenient shortcut to drop everyone else from the conversation. + // - If we've just finished uploading an image, and have a saved caret position, + // restores the cursor to that position after the text changes! + if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) { + let selectionEnd, selectionStart; + + if (this.props.preselectDate !== prevProps.preselectDate) { + selectionEnd = this.props.text.length; + selectionStart = this.props.text.search(/\s/) + 1; + } else if (typeof this._restoreCaret === 'number') { + selectionStart = this._restoreCaret; + selectionEnd = this._restoreCaret; + } else { + selectionEnd = this.props.text.length; + selectionStart = selectionEnd; + } + + this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); + this.autosuggestTextarea.textarea.focus(); + } + } + + setAutosuggestTextarea (c) { + this.autosuggestTextarea = c; + } + + handleEmojiPick (data) { + const position = this.autosuggestTextarea.textarea.selectionStart; + this._restoreCaret = position + data.shortname.length + 1; + this.props.onPickEmoji(position, data); + } + + render () { + const { intl, onPaste } = this.props; + const disabled = this.props.is_submitting; + const text = [this.props.spoiler_text, this.props.text].join(''); + + let publishText = ''; + let reply_to_other = false; + + if (this.props.privacy === 'private' || this.props.privacy === 'direct') { + publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; + } else { + publishText = intl.formatMessage(messages.publish) + (this.props.privacy !== 'unlisted' ? '!' : ''); + } + + return ( + <div className='compose-form'> + <Collapsable isVisible={this.props.spoiler} fullHeight={50}> + <div className="spoiler-input"> + <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type="text" className="spoiler-input__input" id='cw-spoiler-input'/> + </div> + </Collapsable> + + <WarningContainer /> + + <ReplyIndicatorContainer /> + + <div className='compose-form__autosuggest-wrapper'> + <AutosuggestTextarea + ref={this.setAutosuggestTextarea} + placeholder={intl.formatMessage(messages.placeholder)} + disabled={disabled} + value={this.props.text} + onChange={this.handleChange} + suggestions={this.props.suggestions} + onKeyDown={this.handleKeyDown} + onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} + onSuggestionsClearRequested={this.onSuggestionsClearRequested} + onSuggestionSelected={this.onSuggestionSelected} + onPaste={onPaste} + /> + + <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> + </div> + + <div className='compose-form__modifiers'> + <UploadFormContainer /> + </div> + + <div className='compose-form__buttons-wrapper'> + <div className='compose-form__buttons'> + <UploadButtonContainer /> + <PrivacyDropdownContainer /> + <SensitiveButtonContainer /> + <SpoilerButtonContainer /> + </div> + + <div className='compose-form__publish'> + <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div> + <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length > 500 || (text.length !==0 && text.trim().length === 0)} block /></div> + </div> + </div> + </div> + ); + } + +} + +ComposeForm.propTypes = { + intl: PropTypes.object.isRequired, + text: PropTypes.string.isRequired, + suggestion_token: PropTypes.string, + suggestions: ImmutablePropTypes.list, + spoiler: PropTypes.bool, + privacy: PropTypes.string, + spoiler_text: PropTypes.string, + focusDate: PropTypes.instanceOf(Date), + preselectDate: PropTypes.instanceOf(Date), + is_submitting: PropTypes.bool, + is_uploading: PropTypes.bool, + me: PropTypes.number, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClearSuggestions: PropTypes.func.isRequired, + onFetchSuggestions: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func.isRequired, + onChangeSpoilerText: PropTypes.func.isRequired, + onPaste: PropTypes.func.isRequired, + onPickEmoji: PropTypes.func.isRequired +}; + +export default injectIntl(ComposeForm); diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js new file mode 100644 index 000000000..3e0b290d6 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -0,0 +1,115 @@ +import React from 'react'; +import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; +import EmojiPicker from 'emojione-picker'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, + people: { id: 'emoji_button.people', defaultMessage: 'People' }, + nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, + food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, + activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, + travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, + objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, + symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, + flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' } +}); + +const settings = { + imageType: 'png', + sprites: false, + imagePathPNG: '/emoji/' +}; + +const dropdownStyle = { + position: 'absolute', + right: '5px', + top: '5px' +}; + +const dropdownTriggerStyle = { + display: 'block', + fontSize: '24px', + lineHeight: '24px', + marginLeft: '2px', + width: '24px' +} + +class EmojiPickerDropdown extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.setRef = this.setRef.bind(this); + this.handleChange = this.handleChange.bind(this); + } + + setRef (c) { + this.dropdown = c; + } + + handleChange (data) { + this.dropdown.hide(); + this.props.onPickEmoji(data); + } + + render () { + const { intl } = this.props; + + const categories = { + people: { + title: intl.formatMessage(messages.people), + emoji: 'smile', + }, + nature: { + title: intl.formatMessage(messages.nature), + emoji: 'hamster', + }, + food: { + title: intl.formatMessage(messages.food), + emoji: 'pizza', + }, + activity: { + title: intl.formatMessage(messages.activity), + emoji: 'soccer', + }, + travel: { + title: intl.formatMessage(messages.travel), + emoji: 'earth_americas', + }, + objects: { + title: intl.formatMessage(messages.objects), + emoji: 'bulb', + }, + symbols: { + title: intl.formatMessage(messages.symbols), + emoji: 'clock9', + }, + flags: { + title: intl.formatMessage(messages.flags), + emoji: 'flag_gb', + } + } + + return ( + <Dropdown ref={this.setRef} style={dropdownStyle}> + <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={dropdownTriggerStyle}> + <img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" /> + </DropdownTrigger> + + <DropdownContent className='dropdown__left'> + <EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} categories={categories} search={true} /> + </DropdownContent> + </Dropdown> + ); + } + +} + +EmojiPickerDropdown.propTypes = { + intl: PropTypes.object.isRequired, + onPickEmoji: PropTypes.func.isRequired +}; + +export default injectIntl(EmojiPickerDropdown); diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js new file mode 100644 index 000000000..aec8f6153 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js @@ -0,0 +1,37 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from '../../../components/avatar'; +import IconButton from '../../../components/icon_button'; +import DisplayName from '../../../components/display_name'; +import Permalink from '../../../components/permalink'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class NavigationBar extends ImmutablePureComponent { + + render () { + return ( + <div className='navigation-bar'> + <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <Avatar src={this.props.account.get('avatar')} animate size={40} /> + </Permalink> + + <div className='navigation-bar__profile'> + <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong> + </Permalink> + + <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> + </div> + </div> + ); + } + +} + +NavigationBar.propTypes = { + account: ImmutablePropTypes.map.isRequired +}; + +export default NavigationBar; diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js new file mode 100644 index 000000000..b77d55f4d --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -0,0 +1,105 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; +import IconButton from '../../../components/icon_button'; + +const messages = defineMessages({ + public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, + public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, + direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, + change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' } +}); + +const iconStyle = { + height: null, + lineHeight: '27px' +} + +class PrivacyDropdown extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + open: false + }; + this.handleToggle = this.handleToggle.bind(this); + this.handleClick = this.handleClick.bind(this); + this.onGlobalClick = this.onGlobalClick.bind(this); + this.setRef = this.setRef.bind(this); + } + + handleToggle () { + this.setState({ open: !this.state.open }); + } + + handleClick (value, e) { + e.preventDefault(); + this.setState({ open: false }); + this.props.onChange(value); + } + + onGlobalClick (e) { + if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { + this.setState({ open: false }); + } + } + + componentDidMount () { + window.addEventListener('click', this.onGlobalClick); + window.addEventListener('touchstart', this.onGlobalClick); + } + + componentWillUnmount () { + window.removeEventListener('click', this.onGlobalClick); + window.removeEventListener('touchstart', this.onGlobalClick); + } + + setRef (c) { + this.node = c; + } + + render () { + const { value, onChange, intl } = this.props; + const { open } = this.state; + + const options = [ + { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) }, + { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) }, + { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) }, + { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) } + ]; + + const valueOption = options.find(item => item.value === value); + + return ( + <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}> + <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle}/></div> + <div className='privacy-dropdown__dropdown'> + {options.map(item => + <div role='button' tabIndex='0' key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}> + <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div> + <div className='privacy-dropdown__option__content'> + <strong>{item.shortText}</strong> + {item.longText} + </div> + </div> + )} + </div> + </div> + ); + } + +} + +PrivacyDropdown.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(PrivacyDropdown); diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js new file mode 100644 index 000000000..e53831b60 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js @@ -0,0 +1,71 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Avatar from '../../../components/avatar'; +import IconButton from '../../../components/icon_button'; +import DisplayName from '../../../components/display_name'; +import emojify from '../../../emoji'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' } +}); + +class ReplyIndicator extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + this.handleAccountClick = this.handleAccountClick.bind(this); + } + + handleClick () { + this.props.onCancel(); + } + + handleAccountClick (e) { + if (e.button === 0) { + e.preventDefault(); + this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + } + + render () { + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const content = { __html: emojify(status.get('content')) }; + + return ( + <div className='reply-indicator'> + <div className='reply-indicator__header'> + <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> + + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'> + <div className='reply-indicator__display-avatar'><Avatar size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div> + <DisplayName account={status.get('account')} /> + </a> + </div> + + <div className='reply-indicator__content' dangerouslySetInnerHTML={content} /> + </div> + ); + } + +} + +ReplyIndicator.contextTypes = { + router: PropTypes.object +}; + +ReplyIndicator.propTypes = { + status: ImmutablePropTypes.map, + onCancel: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(ReplyIndicator); diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js new file mode 100644 index 000000000..61ae9ce23 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -0,0 +1,82 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + placeholder: { id: 'search.placeholder', defaultMessage: 'Search' } +}); + +class Search extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleChange = this.handleChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleFocus = this.handleFocus.bind(this); + this.handleClear = this.handleClear.bind(this); + } + + handleChange (e) { + this.props.onChange(e.target.value); + } + + handleClear (e) { + e.preventDefault(); + + if (this.props.value.length > 0 || this.props.submitted) { + this.props.onClear(); + } + } + + handleKeyDown (e) { + if (e.key === 'Enter') { + e.preventDefault(); + this.props.onSubmit(); + } + } + + noop () { + + } + + handleFocus () { + this.props.onShow(); + } + + render () { + const { intl, value, submitted } = this.props; + const hasValue = value.length > 0 || submitted; + + return ( + <div className='search'> + <input + className='search__input' + type='text' + placeholder={intl.formatMessage(messages.placeholder)} + value={value} + onChange={this.handleChange} + onKeyUp={this.handleKeyDown} + onFocus={this.handleFocus} + /> + + <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}> + <i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> + <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} /> + </div> + </div> + ); + } + +} + +Search.propTypes = { + value: PropTypes.string.isRequired, + submitted: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onShow: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(Search); diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js new file mode 100644 index 000000000..79e880f0a --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -0,0 +1,67 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import AccountContainer from '../../../containers/account_container'; +import StatusContainer from '../../../containers/status_container'; +import { Link } from 'react-router'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class SearchResults extends ImmutablePureComponent { + + render () { + const { results } = this.props; + + let accounts, statuses, hashtags; + let count = 0; + + if (results.get('accounts') && results.get('accounts').size > 0) { + count += results.get('accounts').size; + accounts = ( + <div className='search-results__section'> + {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} + </div> + ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( + <div className='search-results__section'> + {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} + </div> + ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( + <div className='search-results__section'> + {results.get('hashtags').map(hashtag => + <Link className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}> + #{hashtag} + </Link> + )} + </div> + ); + } + + return ( + <div className='search-results'> + <div className='search-results__header'> + <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} /> + </div> + + {accounts} + {statuses} + {hashtags} + </div> + ); + } + +} + +SearchResults.propTypes = { + results: ImmutablePropTypes.map.isRequired +}; + +export default SearchResults; diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.js b/app/javascript/mastodon/features/compose/components/text_icon_button.js new file mode 100644 index 000000000..bcfa21090 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/text_icon_button.js @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class TextIconButton extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick (e) { + e.preventDefault(); + this.props.onClick(); + } + + render () { + const { label, title, active, ariaControls } = this.props; + + return ( + <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> + {label} + </button> + ); + } + +} + +TextIconButton.propTypes = { + label: PropTypes.string.isRequired, + title: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + ariaControls: PropTypes.string +}; + +export default TextIconButton; diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js new file mode 100644 index 000000000..15ec2edd6 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload_button.js @@ -0,0 +1,61 @@ +import React from 'react'; +import IconButton from '../../../components/icon_button'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + upload: { id: 'upload_button.label', defaultMessage: 'Add media' } +}); + + +const iconStyle = { + height: null, + lineHeight: '27px' +} + +class UploadButton extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleChange = this.handleChange.bind(this); + this.handleClick = this.handleClick.bind(this); + this.setRef = this.setRef.bind(this); + } + + handleChange (e) { + if (e.target.files.length > 0) { + this.props.onSelectFile(e.target.files); + } + } + + handleClick () { + this.fileElement.click(); + } + + setRef (c) { + this.fileElement = c; + } + + render () { + + const { intl, resetFileKey, disabled } = this.props; + + return ( + <div className='compose-form__upload-button'> + <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle}/> + <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} /> + </div> + ); + } + +} + +UploadButton.propTypes = { + disabled: PropTypes.bool, + onSelectFile: PropTypes.func.isRequired, + style: PropTypes.object, + resetFileKey: PropTypes.number, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(UploadButton); diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js new file mode 100644 index 000000000..8e48538da --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload_form.js @@ -0,0 +1,46 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import UploadProgressContainer from '../containers/upload_progress_container'; +import { Motion, spring } from 'react-motion'; + +const messages = defineMessages({ + undo: { id: 'upload_form.undo', defaultMessage: 'Undo' } +}); + +class UploadForm extends React.PureComponent { + + render () { + const { intl, media } = this.props; + + const uploads = media.map(attachment => + <div className='compose-form__upload' key={attachment.get('id')}> + <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> + {({ scale }) => + <div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}> + <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} /> + </div> + } + </Motion> + </div> + ); + + return ( + <div className='compose-form__upload-wrapper'> + <UploadProgressContainer /> + <div className='compose-form__uploads-wrapper'>{uploads}</div> + </div> + ); + } + +} + +UploadForm.propTypes = { + media: ImmutablePropTypes.list.isRequired, + onRemoveFile: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(UploadForm); diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.js b/app/javascript/mastodon/features/compose/components/upload_progress.js new file mode 100644 index 000000000..bb2932a55 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/upload_progress.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Motion, spring } from 'react-motion'; +import { FormattedMessage } from 'react-intl'; + +class UploadProgress extends React.PureComponent { + + render () { + const { active, progress } = this.props; + + if (!active) { + return null; + } + + return ( + <div className='upload-progress'> + <div className='upload-progress__icon'> + <i className='fa fa-upload' /> + </div> + + <div className='upload-progress__message'> + <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> + + <div className='upload-progress__backdrop'> + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> + {({ width }) => + <div className='upload-progress__tracker' style={{ width: `${width}%` }} /> + } + </Motion> + </div> + </div> + </div> + ); + } + +} + +UploadProgress.propTypes = { + active: PropTypes.bool, + progress: PropTypes.number +}; + +export default UploadProgress; diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js new file mode 100644 index 000000000..6ad00b691 --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/warning.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class Warning extends React.PureComponent { + + constructor (props) { + super(props); + } + + render () { + const { message } = this.props; + + return ( + <div className='compose-form__warning'> + {message} + </div> + ); + } + +} + +Warning.propTypes = { + message: PropTypes.node.isRequired +}; + +export default Warning; diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js new file mode 100644 index 000000000..de76a364d --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import AutosuggestAccount from '../components/autosuggest_account'; +import { makeGetAccount } from '../../../selectors'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id) + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(AutosuggestAccount); diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js new file mode 100644 index 000000000..ef46eb09c --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import AutosuggestStatus from '../components/autosuggest_status'; +import { makeGetStatus } from '../../../selectors'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, { id }) => ({ + status: getStatus(state, id) + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(AutosuggestStatus); diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js new file mode 100644 index 000000000..892183b83 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -0,0 +1,64 @@ +import { connect } from 'react-redux'; +import ComposeForm from '../components/compose_form'; +import { uploadCompose } from '../../../actions/compose'; +import { + changeCompose, + submitCompose, + clearComposeSuggestions, + fetchComposeSuggestions, + selectComposeSuggestion, + changeComposeSpoilerText, + insertEmojiCompose +} from '../../../actions/compose'; + +const mapStateToProps = state => ({ + text: state.getIn(['compose', 'text']), + suggestion_token: state.getIn(['compose', 'suggestion_token']), + suggestions: state.getIn(['compose', 'suggestions']), + spoiler: state.getIn(['compose', 'spoiler']), + spoiler_text: state.getIn(['compose', 'spoiler_text']), + privacy: state.getIn(['compose', 'privacy']), + focusDate: state.getIn(['compose', 'focusDate']), + preselectDate: state.getIn(['compose', 'preselectDate']), + is_submitting: state.getIn(['compose', 'is_submitting']), + is_uploading: state.getIn(['compose', 'is_uploading']), + me: state.getIn(['compose', 'me']) +}); + +const mapDispatchToProps = (dispatch) => ({ + + onChange (text) { + dispatch(changeCompose(text)); + }, + + onSubmit () { + dispatch(submitCompose()); + }, + + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, + + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, + + onSuggestionSelected (position, token, accountId) { + dispatch(selectComposeSuggestion(position, token, accountId)); + }, + + onChangeSpoilerText (checked) { + dispatch(changeComposeSpoilerText(checked)); + }, + + onPaste (files) { + dispatch(uploadCompose(files)); + }, + + onPickEmoji (position, data) { + dispatch(insertEmojiCompose(position, data)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js new file mode 100644 index 000000000..0006608da --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js @@ -0,0 +1,10 @@ +import { connect } from 'react-redux'; +import NavigationBar from '../components/navigation_bar'; + +const mapStateToProps = (state, props) => { + return { + account: state.getIn(['accounts', state.getIn(['meta', 'me'])]) + }; +}; + +export default connect(mapStateToProps)(NavigationBar); diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js new file mode 100644 index 000000000..1eee8f84c --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import PrivacyDropdown from '../components/privacy_dropdown'; +import { changeComposeVisibility } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + value: state.getIn(['compose', 'privacy']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeVisibility(value)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown); diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js new file mode 100644 index 000000000..39b48f3b6 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { cancelReplyCompose } from '../../../actions/compose'; +import { makeGetStatus } from '../../../selectors'; +import ReplyIndicator from '../components/reply_indicator'; + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = dispatch => ({ + + onCancel () { + dispatch(cancelReplyCompose()); + } + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js new file mode 100644 index 000000000..906c0c28c --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/search_container.js @@ -0,0 +1,35 @@ +import { connect } from 'react-redux'; +import { + changeSearch, + clearSearch, + submitSearch, + showSearch +} from '../../../actions/search'; +import Search from '../components/search'; + +const mapStateToProps = state => ({ + value: state.getIn(['search', 'value']), + submitted: state.getIn(['search', 'submitted']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeSearch(value)); + }, + + onClear () { + dispatch(clearSearch()); + }, + + onSubmit () { + dispatch(submitSearch()); + }, + + onShow () { + dispatch(showSearch()); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js new file mode 100644 index 000000000..e5911fd38 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import SearchResults from '../components/search_results'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']) +}); + +export default connect(mapStateToProps)(SearchResults); diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js new file mode 100644 index 000000000..78e40e048 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import TextIconButton from '../components/text_icon_button'; +import { changeComposeSensitivity } from '../../../actions/compose'; +import { Motion, spring } from 'react-motion'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' } +}); + +const mapStateToProps = state => ({ + visible: state.getIn(['compose', 'media_attachments']).size > 0, + active: state.getIn(['compose', 'sensitive']) +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + } + +}); + +class SensitiveButton extends React.PureComponent { + + render () { + const { visible, active, onClick, intl } = this.props; + + return ( + <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> + {({ scale }) => + <div style={{ display: visible ? 'block' : 'none', transform: `translateZ(0) scale(${scale})` }}> + <TextIconButton onClick={onClick} label='NSFW' title={intl.formatMessage(messages.title)} active={active} /> + </div> + } + </Motion> + ); + } + +} + +SensitiveButton.propTypes = { + visible: PropTypes.bool, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js b/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js new file mode 100644 index 000000000..b1c80fe19 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import TextIconButton from '../components/text_icon_button'; +import { changeComposeSpoilerness } from '../../../actions/compose'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' } +}); + +const mapStateToProps = (state, { intl }) => ({ + label: 'CW', + title: intl.formatMessage(messages.title), + active: state.getIn(['compose', 'spoiler']), + ariaControls: 'cw-spoiler-input' +}); + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSpoilerness()); + } + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton)); diff --git a/app/javascript/mastodon/features/compose/containers/upload_button_container.js b/app/javascript/mastodon/features/compose/containers/upload_button_container.js new file mode 100644 index 000000000..78e5312f5 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/upload_button_container.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import UploadButton from '../components/upload_button'; +import { uploadCompose } from '../../../actions/compose'; + +const mapStateToProps = state => ({ + disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), + resetFileKey: state.getIn(['compose', 'resetFileKey']) +}); + +const mapDispatchToProps = dispatch => ({ + + onSelectFile (files) { + dispatch(uploadCompose(files)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(UploadButton); diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js new file mode 100644 index 000000000..a6a202e17 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/upload_form_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import UploadForm from '../components/upload_form'; +import { undoUploadCompose } from '../../../actions/compose'; + +const mapStateToProps = (state, props) => ({ + media: state.getIn(['compose', 'media_attachments']), +}); + +const mapDispatchToProps = dispatch => ({ + + onRemoveFile (media_id) { + dispatch(undoUploadCompose(media_id)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(UploadForm); diff --git a/app/javascript/mastodon/features/compose/containers/upload_progress_container.js b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js new file mode 100644 index 000000000..b0f1d4d19 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import UploadProgress from '../components/upload_progress'; + +const mapStateToProps = (state, props) => ({ + active: state.getIn(['compose', 'is_uploading']), + progress: state.getIn(['compose', 'progress']) +}); + +export default connect(mapStateToProps)(UploadProgress); diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js new file mode 100644 index 000000000..bf5e6a5f8 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/warning_container.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import Warning from '../components/warning'; +import { createSelector } from 'reselect'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig)); + +const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { + return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []; +}); + +const mapStateToProps = state => { + const mentionedUsernames = getMentionedUsernames(state); + const mentionedUsernamesWithDomains = getMentionedDomains(state); + + return { + needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null, + mentionedDomains: mentionedUsernamesWithDomains, + needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']) + }; +}; + +const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => { + if (needsLockWarning) { + return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; + } else if (needsLeakWarning) { + return ( + <Warning + message={<FormattedMessage + id='compose_form.privacy_disclaimer' + defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.' + values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }} + />} + /> + ); + } + + return null; +}; + +WarningWrapper.propTypes = { + needsLeakWarning: PropTypes.bool, + needsLockWarning: PropTypes.bool, + mentionedDomains: PropTypes.array.isRequired, +}; + +export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js new file mode 100644 index 000000000..68d779c6c --- /dev/null +++ b/app/javascript/mastodon/features/compose/index.js @@ -0,0 +1,86 @@ +import React from 'react'; +import ComposeFormContainer from './containers/compose_form_container'; +import UploadFormContainer from './containers/upload_form_container'; +import NavigationContainer from './containers/navigation_container'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { mountCompose, unmountCompose } from '../../actions/compose'; +import { Link } from 'react-router'; +import { injectIntl, defineMessages } from 'react-intl'; +import SearchContainer from './containers/search_container'; +import { Motion, spring } from 'react-motion'; +import SearchResultsContainer from './containers/search_results_container'; + +const messages = defineMessages({ + start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, + community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' } +}); + +const mapStateToProps = state => ({ + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) +}); + +class Compose extends React.PureComponent { + + componentDidMount () { + this.props.dispatch(mountCompose()); + } + + componentWillUnmount () { + this.props.dispatch(unmountCompose()); + } + + render () { + const { withHeader, showSearch, intl } = this.props; + + let header = ''; + + if (withHeader) { + header = ( + <div className='drawer__header'> + <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)}><i role="img" aria-label={intl.formatMessage(messages.start)} className='fa fa-fw fa-asterisk' /></Link> + <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)}><i role="img" aria-label={intl.formatMessage(messages.community)} className='fa fa-fw fa-users' /></Link> + <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)}><i role="img" aria-label={intl.formatMessage(messages.public)} className='fa fa-fw fa-globe' /></Link> + <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)}><i role="img" aria-label={intl.formatMessage(messages.preferences)} className='fa fa-fw fa-cog' /></a> + <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)}><i role="img" aria-label={intl.formatMessage(messages.logout)} className='fa fa-fw fa-sign-out' /></a> + </div> + ); + } + + return ( + <div className='drawer'> + {header} + + <SearchContainer /> + + <div className='drawer__pager'> + <div className='drawer__inner'> + <NavigationContainer /> + <ComposeFormContainer /> + </div> + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => + <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + <SearchResultsContainer /> + </div> + } + </Motion> + </div> + </div> + ); + } + +} + +Compose.propTypes = { + dispatch: PropTypes.func.isRequired, + withHeader: PropTypes.bool, + showSearch: PropTypes.bool, + intl: PropTypes.object.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(Compose)); diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js new file mode 100644 index 000000000..995f61f17 --- /dev/null +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites'; +import Column from '../ui/components/column'; +import StatusList from '../../components/status_list'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.favourites', defaultMessage: 'Favourites' } +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'favourites', 'items']), + loaded: state.getIn(['status_lists', 'favourites', 'loaded']), + me: state.getIn(['meta', 'me']) +}); + +class Favourites extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScrollToBottom = this.handleScrollToBottom.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchFavouritedStatuses()); + } + + handleScrollToBottom () { + this.props.dispatch(expandFavouritedStatuses()); + } + + render () { + const { statusIds, loaded, intl, me } = this.props; + + if (!loaded) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='star' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <StatusList {...this.props} scrollKey='favourited_statuses' onScrollToBottom={this.handleScrollToBottom} /> + </Column> + ); + } + +} + +Favourites.propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + loaded: PropTypes.bool, + intl: PropTypes.object.isRequired, + me: PropTypes.number.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(Favourites)); diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js new file mode 100644 index 000000000..c916aa176 --- /dev/null +++ b/app/javascript/mastodon/features/favourites/index.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchFavourites } from '../../actions/interactions'; +import { ScrollContainer } from 'react-router-scroll'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import ColumnBackButton from '../../components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)]) +}); + +class Favourites extends ImmutablePureComponent { + + componentWillMount () { + this.props.dispatch(fetchFavourites(Number(this.props.params.statusId))); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId))); + } + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='favourites'> + <div className='scrollable'> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Favourites.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list +}; + +export default connect(mapStateToProps)(Favourites); diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js new file mode 100644 index 000000000..9fe464628 --- /dev/null +++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Permalink from '../../../components/permalink'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import emojify from '../../../emoji'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' } +}); + +class AccountAuthorize extends ImmutablePureComponent { + + render () { + const { intl, account, onAuthorize, onReject } = this.props; + const content = { __html: emojify(account.get('note')) }; + + return ( + <div className='account-authorize__wrapper'> + <div className='account-authorize'> + <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'> + <div className='account-authorize__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={48} /></div> + <DisplayName account={account} /> + </Permalink> + + <div className='account__header__content' dangerouslySetInnerHTML={content} /> + </div> + + <div className='account--panel'> + <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div> + <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div> + </div> + </div> + ); + } + +} + +AccountAuthorize.propTypes = { + account: ImmutablePropTypes.map.isRequired, + onAuthorize: PropTypes.func.isRequired, + onReject: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(AccountAuthorize); diff --git a/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js new file mode 100644 index 000000000..da1e5eaa1 --- /dev/null +++ b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { makeGetAccount } from '../../../selectors'; +import AccountAuthorize from '../components/account_authorize'; +import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id) + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { id }) => ({ + onAuthorize (account) { + dispatch(authorizeFollowRequest(id)); + }, + + onReject (account) { + dispatch(rejectFollowRequest(id)); + } +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize); diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js new file mode 100644 index 000000000..c88de48c0 --- /dev/null +++ b/app/javascript/mastodon/features/follow_requests/index.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll'; +import Column from '../ui/components/column'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import AccountAuthorizeContainer from './containers/account_authorize_container'; +import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' } +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'follow_requests', 'items']) +}); + +class FollowRequests extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchFollowRequests()); + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandFollowRequests()); + } + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='users' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='follow_requests'> + <div className='scrollable' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountAuthorizeContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } +} + +FollowRequests.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(FollowRequests)); diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js new file mode 100644 index 000000000..8a1105b55 --- /dev/null +++ b/app/javascript/mastodon/features/followers/index.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { + fetchAccount, + fetchFollowers, + expandFollowers +} from '../../actions/accounts'; +import { ScrollContainer } from 'react-router-scroll'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import HeaderContainer from '../account_timeline/containers/header_container'; +import LoadMore from '../../components/load_more'; +import ColumnBackButton from '../../components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items']) +}); + +class Followers extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + this.handleLoadMore = this.handleLoadMore.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); + this.props.dispatch(fetchFollowers(Number(this.props.params.accountId))); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); + this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId))); + } + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandFollowers(Number(this.props.params.accountId))); + } + } + + handleLoadMore (e) { + e.preventDefault(); + this.props.dispatch(expandFollowers(Number(this.props.params.accountId))); + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='followers'> + <div className='scrollable' onScroll={this.handleScroll}> + <div className='followers'> + <HeaderContainer accountId={this.props.params.accountId} /> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + <LoadMore onClick={this.handleLoadMore} /> + </div> + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Followers.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list +}; + +export default connect(mapStateToProps)(Followers); diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js new file mode 100644 index 000000000..f181fe727 --- /dev/null +++ b/app/javascript/mastodon/features/following/index.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { + fetchAccount, + fetchFollowing, + expandFollowing +} from '../../actions/accounts'; +import { ScrollContainer } from 'react-router-scroll'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import HeaderContainer from '../account_timeline/containers/header_container'; +import LoadMore from '../../components/load_more'; +import ColumnBackButton from '../../components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items']) +}); + +class Following extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + this.handleLoadMore = this.handleLoadMore.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchAccount(Number(this.props.params.accountId))); + this.props.dispatch(fetchFollowing(Number(this.props.params.accountId))); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(Number(nextProps.params.accountId))); + this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId))); + } + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandFollowing(Number(this.props.params.accountId))); + } + } + + handleLoadMore (e) { + e.preventDefault(); + this.props.dispatch(expandFollowing(Number(this.props.params.accountId))); + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='following'> + <div className='scrollable' onScroll={this.handleScroll}> + <div className='following'> + <HeaderContainer accountId={this.props.params.accountId} /> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + <LoadMore onClick={this.handleLoadMore} /> + </div> + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Following.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list +}; + +export default connect(mapStateToProps)(Following); diff --git a/app/javascript/mastodon/features/generic_not_found/index.js b/app/javascript/mastodon/features/generic_not_found/index.js new file mode 100644 index 000000000..0290be47f --- /dev/null +++ b/app/javascript/mastodon/features/generic_not_found/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import Column from '../ui/components/column'; +import MissingIndicator from '../../components/missing_indicator'; + +const GenericNotFound = () => ( + <Column> + <MissingIndicator /> + </Column> +); + +export default GenericNotFound; diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js new file mode 100644 index 000000000..6bdff2fba --- /dev/null +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -0,0 +1,73 @@ +import React from 'react'; +import Column from '../ui/components/column'; +import ColumnLink from '../ui/components/column_link'; +import ColumnSubheading from '../ui/components/column_subheading'; +import { Link } from 'react-router'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, + navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation'}, + settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings'}, + community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, + favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, + blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, + mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, + info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' } +}); + +const mapStateToProps = state => ({ + me: state.getIn(['accounts', state.getIn(['meta', 'me'])]) +}); + +class GettingStarted extends ImmutablePureComponent { + + render () { + const { intl, me } = this.props; + + let followRequests = ''; + + if (me.get('locked')) { + followRequests = <ColumnLink icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />; + } + + return ( + <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile={true}> + <div className='getting-started__wrapper'> + <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)}/> + <ColumnLink icon='users' hideOnMobile={true} text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' /> + <ColumnLink icon='globe' hideOnMobile={true} text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' /> + <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> + {followRequests} + <ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' /> + <ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' /> + <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)}/> + <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> + <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> + <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> + </div> + + <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}> + <div className='static-content getting-started'> + <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p> + </div> + </div> + </Column> + ); + } +} + +GettingStarted.propTypes = { + intl: PropTypes.object.isRequired, + me: ImmutablePropTypes.map.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(GettingStarted)); diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js new file mode 100644 index 000000000..f5134decf --- /dev/null +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -0,0 +1,90 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../ui/components/column'; +import { + refreshTimeline, + updateTimeline, + deleteFromTimelines +} from '../../actions/timelines'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import { FormattedMessage } from 'react-intl'; +import createStream from '../../stream'; + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, + streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), + accessToken: state.getIn(['meta', 'access_token']) +}); + +class HashtagTimeline extends React.PureComponent { + + _subscribe (dispatch, id) { + const { streamingAPIBaseURL, accessToken } = this.props; + + this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, { + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('tag', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + } + } + + }); + } + + _unsubscribe () { + if (typeof this.subscription !== 'undefined') { + this.subscription.close(); + this.subscription = null; + } + } + + componentDidMount () { + const { dispatch } = this.props; + const { id } = this.props.params; + + dispatch(refreshTimeline('tag', id)); + this._subscribe(dispatch, id); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.id !== this.props.params.id) { + this.props.dispatch(refreshTimeline('tag', nextProps.params.id)); + this._unsubscribe(); + this._subscribe(this.props.dispatch, nextProps.params.id); + } + } + + componentWillUnmount () { + this._unsubscribe(); + } + + render () { + const { id, hasUnread } = this.props.params; + + return ( + <Column icon='hashtag' active={hasUnread} heading={id}> + <ColumnBackButtonSlim /> + <StatusListContainer scrollKey='hashtag_timeline' type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} /> + </Column> + ); + } + +} + +HashtagTimeline.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + streamingAPIBaseURL: PropTypes.string.isRequired, + accessToken: PropTypes.string.isRequired, + hasUnread: PropTypes.bool +}; + +export default connect(mapStateToProps)(HashtagTimeline); diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js new file mode 100644 index 000000000..460221fc3 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnCollapsable from '../../../components/column_collapsable'; +import SettingToggle from '../../notifications/components/setting_toggle'; +import SettingText from './setting_text'; + +const messages = defineMessages({ + filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }, + settings: { id: 'home.settings', defaultMessage: 'Column settings' } +}); + +class ColumnSettings extends React.PureComponent { + + render () { + const { settings, onChange, onSave, intl } = this.props; + + return ( + <ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={209} onCollapse={onSave}> + <div className='column-settings__outer'> + <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> + </div> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + + <div className='column-settings__row'> + <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> + </div> + </div> + </ColumnCollapsable> + ); + } + +} + +ColumnSettings.propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +} + +export default injectIntl(ColumnSettings); diff --git a/app/javascript/mastodon/features/home_timeline/components/setting_text.js b/app/javascript/mastodon/features/home_timeline/components/setting_text.js new file mode 100644 index 000000000..dfa2939b7 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/setting_text.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +class SettingText extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleChange = this.handleChange.bind(this); + } + + handleChange (e) { + this.props.onChange(this.props.settingKey, e.target.value) + } + + render () { + const { settings, settingKey, label } = this.props; + + return ( + <input + className='setting-text' + value={settings.getIn(settingKey)} + onChange={this.handleChange} + placeholder={label} + /> + ); + } + +} + +SettingText.propTypes = { + settings: ImmutablePropTypes.map.isRequired, + settingKey: PropTypes.array.isRequired, + label: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired +}; + +export default SettingText; diff --git a/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..3b3ce19bc --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from '../../../actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'home']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['home', ...key], checked)); + }, + + onSave () { + dispatch(saveSettings()); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js new file mode 100644 index 000000000..d7c438122 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../ui/components/column'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { Link } from 'react-router'; + +const messages = defineMessages({ + title: { id: 'column.home', defaultMessage: 'Home' } +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0 +}); + +class HomeTimeline extends React.PureComponent { + + render () { + const { intl, hasUnread } = this.props; + + return ( + <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}> + <ColumnSettingsContainer /> + <StatusListContainer {...this.props} scrollKey='home_timeline' type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} /> + </Column> + ); + } + +} + +HomeTimeline.propTypes = { + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool +}; + +export default connect(mapStateToProps)(injectIntl(HomeTimeline)); diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js new file mode 100644 index 000000000..884b3b3e7 --- /dev/null +++ b/app/javascript/mastodon/features/mutes/index.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { ScrollContainer } from 'react-router-scroll'; +import Column from '../ui/components/column'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import AccountContainer from '../../containers/account_container'; +import { fetchMutes, expandMutes } from '../../actions/mutes'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.mutes', defaultMessage: 'Muted users' } +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'mutes', 'items']) +}); + +class Mutes extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchMutes()); + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + + if (scrollTop === scrollHeight - clientHeight) { + this.props.dispatch(expandMutes()); + } + } + + render () { + const { intl, accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='volume-off' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + <ScrollContainer scrollKey='mutes'> + <div className='scrollable mutes' onScroll={this.handleScroll}> + {accountIds.map(id => + <AccountContainer key={id} id={id} /> + )} + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Mutes.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired +}; + +export default connect(mapStateToProps)(injectIntl(Mutes)); diff --git a/app/javascript/mastodon/features/notifications/components/clear_column_button.js b/app/javascript/mastodon/features/notifications/components/clear_column_button.js new file mode 100644 index 000000000..a948bff46 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/clear_column_button.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + clear: { id: 'notifications.clear', defaultMessage: 'Clear notifications' } +}); + +class ClearColumnButton extends React.Component { + + render () { + const { intl } = this.props; + + return ( + <div role='button' title={intl.formatMessage(messages.clear)} className='column-icon column-icon-clear' tabIndex='0' onClick={this.props.onClick}> + <i className='fa fa-eraser' /> + </div> + ); + } +} + +ClearColumnButton.propTypes = { + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(ClearColumnButton); diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js new file mode 100644 index 000000000..7d52b7dcd --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnCollapsable from '../../../components/column_collapsable'; +import SettingToggle from './setting_toggle'; + +const messages = defineMessages({ + settings: { id: 'notifications.settings', defaultMessage: 'Column settings' } +}); + +class ColumnSettings extends React.PureComponent { + + render () { + const { settings, intl, onChange, onSave } = this.props; + + const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; + const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; + const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; + + return ( + <ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={616} onCollapse={onSave}> + <div className='column-settings__outer'> + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> + </div> + + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> + </div> + </div> + </ColumnCollapsable> + ); + } + +} + +ColumnSettings.propTypes = { + settings: ImmutablePropTypes.map.isRequired, + onChange: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired + }).isRequired +}; + +export default injectIntl(ColumnSettings); diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js new file mode 100644 index 000000000..f54a65747 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -0,0 +1,90 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import StatusContainer from '../../../containers/status_container'; +import AccountContainer from '../../../containers/account_container'; +import { FormattedMessage } from 'react-intl'; +import Permalink from '../../../components/permalink'; +import emojify from '../../../emoji'; +import escapeTextContentForBrowser from 'escape-html'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class Notification extends ImmutablePureComponent { + + renderFollow (account, link) { + return ( + <div className='notification notification-follow'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-user-plus' /> + </div> + + <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} /> + </div> + + <AccountContainer id={account.get('id')} withNote={false} /> + </div> + ); + } + + renderMention (notification) { + return <StatusContainer id={notification.get('status')} />; + } + + renderFavourite (notification, link) { + return ( + <div className='notification notification-favourite'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-star star-icon'/> + </div> + + <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} /> + </div> + + <StatusContainer id={notification.get('status')} muted={true} /> + </div> + ); + } + + renderReblog (notification, link) { + return ( + <div className='notification notification-reblog'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-retweet' /> + </div> + + <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} /> + </div> + + <StatusContainer id={notification.get('status')} muted={true} /> + </div> + ); + } + + render () { // eslint-disable-line consistent-return + const { notification } = this.props; + const account = notification.get('account'); + const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const link = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />; + + switch(notification.get('type')) { + case 'follow': + return this.renderFollow(account, link); + case 'mention': + return this.renderMention(notification); + case 'favourite': + return this.renderFavourite(notification, link); + case 'reblog': + return this.renderReblog(notification, link); + } + } + +} + +Notification.propTypes = { + notification: ImmutablePropTypes.map.isRequired +}; + +export default Notification; diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js new file mode 100644 index 000000000..080804a40 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +const SettingToggle = ({ settings, settingKey, label, onChange, htmlFor = '' }) => ( + <label htmlFor={htmlFor} className='setting-toggle__label'> + <Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} /> + <span className='setting-toggle'>{label}</span> + </label> +); + +SettingToggle.propTypes = { + settings: ImmutablePropTypes.map.isRequired, + settingKey: PropTypes.array.isRequired, + label: PropTypes.node.isRequired, + onChange: PropTypes.func.isRequired, + htmlFor: PropTypes.string +}; + +export default SettingToggle; diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js new file mode 100644 index 000000000..bc24c75e0 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../components/column_settings'; +import { changeSetting, saveSettings } from '../../../actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'notifications']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['notifications', ...key], checked)); + }, + + onSave () { + dispatch(saveSettings()); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js new file mode 100644 index 000000000..4ca1b1b7b --- /dev/null +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { makeGetNotification } from '../../../selectors'; +import Notification from '../components/notification'; + +const makeMapStateToProps = () => { + const getNotification = makeGetNotification(); + + const mapStateToProps = (state, props) => ({ + notification: getNotification(state, props.notification, props.accountId) + }); + + return mapStateToProps; +}; + +export default connect(makeMapStateToProps)(Notification); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js new file mode 100644 index 000000000..989013cc7 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/index.js @@ -0,0 +1,143 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from '../ui/components/column'; +import { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications'; +import NotificationContainer from './containers/notification_container'; +import { ScrollContainer } from 'react-router-scroll'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { createSelector } from 'reselect'; +import Immutable from 'immutable'; +import LoadMore from '../../components/load_more'; +import ClearColumnButton from './components/clear_column_button'; +import { openModal } from '../../actions/modal'; + +const messages = defineMessages({ + title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, + clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' } +}); + +const getNotifications = createSelector([ + state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), + state => state.getIn(['notifications', 'items']) +], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type')))); + +const mapStateToProps = state => ({ + notifications: getNotifications(state), + isLoading: state.getIn(['notifications', 'isLoading'], true), + isUnread: state.getIn(['notifications', 'unread']) > 0 +}); + +class Notifications extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleScroll = this.handleScroll.bind(this); + this.handleLoadMore = this.handleLoadMore.bind(this); + this.handleClear = this.handleClear.bind(this); + this.setRef = this.setRef.bind(this); + } + + handleScroll (e) { + const { scrollTop, scrollHeight, clientHeight } = e.target; + const offset = scrollHeight - scrollTop - clientHeight; + this._oldScrollPosition = scrollHeight - scrollTop; + + if (250 > offset && !this.props.isLoading) { + this.props.dispatch(expandNotifications()); + } else if (scrollTop < 100) { + this.props.dispatch(scrollTopNotifications(true)); + } else { + this.props.dispatch(scrollTopNotifications(false)); + } + } + + componentDidUpdate (prevProps) { + if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) { + this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition; + } + } + + handleLoadMore (e) { + e.preventDefault(); + this.props.dispatch(expandNotifications()); + } + + handleClear () { + const { dispatch, intl } = this.props; + + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.clearMessage), + confirm: intl.formatMessage(messages.clearConfirm), + onConfirm: () => dispatch(clearNotifications()) + })); + } + + setRef (c) { + this.node = c; + } + + render () { + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread } = this.props; + + let loadMore = ''; + let scrollableArea = ''; + let unread = ''; + + if (!isLoading && notifications.size > 0) { + loadMore = <LoadMore onClick={this.handleLoadMore} />; + } + + if (isUnread) { + unread = <div className='notifications__unread-indicator' />; + } + + if (isLoading || notifications.size > 0) { + scrollableArea = ( + <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}> + {unread} + + <div> + {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)} + {loadMore} + </div> + </div> + ); + } else { + scrollableArea = ( + <div className='empty-column-indicator' ref={this.setRef}> + <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." /> + </div> + ); + } + + return ( + <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}> + <ColumnSettingsContainer /> + <ClearColumnButton onClick={this.handleClear} /> + <ScrollContainer scrollKey='notifications' shouldUpdateScroll={shouldUpdateScroll}> + {scrollableArea} + </ScrollContainer> + </Column> + ); + } + +} + +Notifications.propTypes = { + notifications: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + intl: PropTypes.object.isRequired, + isLoading: PropTypes.bool, + isUnread: PropTypes.bool +}; + +Notifications.defaultProps = { + trackScroll: true +}; + +export default connect(mapStateToProps)(injectIntl(Notifications)); diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js new file mode 100644 index 000000000..3b270c62f --- /dev/null +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -0,0 +1,96 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../ui/components/column'; +import { + refreshTimeline, + updateTimeline, + deleteFromTimelines, + connectTimeline, + disconnectTimeline +} from '../../actions/timelines'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import createStream from '../../stream'; + +const messages = defineMessages({ + title: { id: 'column.public', defaultMessage: 'Federated timeline' } +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, + streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), + accessToken: state.getIn(['meta', 'access_token']) +}); + +let subscription; + +class PublicTimeline extends React.PureComponent { + + componentDidMount () { + const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + + dispatch(refreshTimeline('public')); + + if (typeof subscription !== 'undefined') { + return; + } + + subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { + + connected () { + dispatch(connectTimeline('public')); + }, + + reconnected () { + dispatch(connectTimeline('public')); + }, + + disconnected () { + dispatch(disconnectTimeline('public')); + }, + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline('public', JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + } + } + + }); + } + + componentWillUnmount () { + // if (typeof subscription !== 'undefined') { + // subscription.close(); + // subscription = null; + // } + } + + render () { + const { intl, hasUnread } = this.props; + + return ( + <Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}> + <ColumnBackButtonSlim /> + <StatusListContainer {...this.props} type='public' scrollKey='public_timeline' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} /> + </Column> + ); + } + +} + +PublicTimeline.propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + streamingAPIBaseURL: PropTypes.string.isRequired, + accessToken: PropTypes.string.isRequired, + hasUnread: PropTypes.bool +}; + +export default connect(mapStateToProps)(injectIntl(PublicTimeline)); diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js new file mode 100644 index 000000000..48df8451d --- /dev/null +++ b/app/javascript/mastodon/features/reblogs/index.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchReblogs } from '../../actions/interactions'; +import { ScrollContainer } from 'react-router-scroll'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import ColumnBackButton from '../../components/column_back_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)]) +}); + +class Reblogs extends ImmutablePureComponent { + + componentWillMount () { + this.props.dispatch(fetchReblogs(Number(this.props.params.statusId))); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId))); + } + } + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='reblogs'> + <div className='scrollable reblogs'> + {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Reblogs.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list +}; + +export default connect(mapStateToProps)(Reblogs); diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js new file mode 100644 index 000000000..85f792479 --- /dev/null +++ b/app/javascript/mastodon/features/report/components/status_check_box.js @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import emojify from '../../../emoji'; +import Toggle from 'react-toggle'; + +class StatusCheckBox extends React.PureComponent { + + render () { + const { status, checked, onToggle, disabled } = this.props; + const content = { __html: emojify(status.get('content')) }; + + if (status.get('reblog')) { + return null; + } + + return ( + <div className='status-check-box'> + <div + className='status__content' + dangerouslySetInnerHTML={content} + /> + + <div className='status-check-box-toggle'> + <Toggle checked={checked} onChange={onToggle} disabled={disabled} /> + </div> + </div> + ); + } + +} + +StatusCheckBox.propTypes = { + status: ImmutablePropTypes.map.isRequired, + checked: PropTypes.bool, + onToggle: PropTypes.func.isRequired, + disabled: PropTypes.bool +}; + +export default StatusCheckBox; diff --git a/app/javascript/mastodon/features/report/containers/status_check_box_container.js b/app/javascript/mastodon/features/report/containers/status_check_box_container.js new file mode 100644 index 000000000..67ce9d9f3 --- /dev/null +++ b/app/javascript/mastodon/features/report/containers/status_check_box_container.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import StatusCheckBox from '../components/status_check_box'; +import { toggleStatusReport } from '../../../actions/reports'; +import Immutable from 'immutable'; + +const mapStateToProps = (state, { id }) => ({ + status: state.getIn(['statuses', id]), + checked: state.getIn(['reports', 'new', 'status_ids'], Immutable.Set()).includes(id) +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onToggle (e) { + dispatch(toggleStatusReport(id, e.target.checked)); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox); diff --git a/app/javascript/mastodon/features/report/index.js b/app/javascript/mastodon/features/report/index.js new file mode 100644 index 000000000..661fffe56 --- /dev/null +++ b/app/javascript/mastodon/features/report/index.js @@ -0,0 +1,131 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { cancelReport, changeReportComment, submitReport } from '../../actions/reports'; +import { fetchAccountTimeline } from '../../actions/accounts'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from '../ui/components/column'; +import Button from '../../components/button'; +import { makeGetAccount } from '../../selectors'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import StatusCheckBox from './containers/status_check_box_container'; +import Immutable from 'immutable'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; + +const messages = defineMessages({ + heading: { id: 'report.heading', defaultMessage: 'New report' }, + placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, + submit: { id: 'report.submit', defaultMessage: 'Submit' } +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = state => { + const accountId = state.getIn(['reports', 'new', 'account_id']); + + return { + isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), + account: getAccount(state, accountId), + comment: state.getIn(['reports', 'new', 'comment']), + statusIds: Immutable.OrderedSet(state.getIn(['timelines', 'accounts_timelines', accountId, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])) + }; + }; + + return mapStateToProps; +}; + +class Report extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleCommentChange = this.handleCommentChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + componentWillMount () { + if (!this.props.account) { + this.context.router.replace('/'); + } + } + + componentDidMount () { + if (!this.props.account) { + return; + } + + this.props.dispatch(fetchAccountTimeline(this.props.account.get('id'))); + } + + componentWillReceiveProps (nextProps) { + if (this.props.account !== nextProps.account && nextProps.account) { + this.props.dispatch(fetchAccountTimeline(nextProps.account.get('id'))); + } + } + + handleCommentChange (e) { + this.props.dispatch(changeReportComment(e.target.value)); + } + + handleSubmit () { + this.props.dispatch(submitReport()); + this.context.router.replace('/'); + } + + render () { + const { account, comment, intl, statusIds, isSubmitting } = this.props; + + if (!account) { + return null; + } + + return ( + <Column heading={intl.formatMessage(messages.heading)} icon='flag'> + <ColumnBackButtonSlim /> + + <div className='report scrollable'> + <div className='report__target'> + <FormattedMessage id='report.target' defaultMessage='Reporting' /> + <strong>{account.get('acct')}</strong> + </div> + + <div className='scrollable report__statuses'> + <div> + {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)} + </div> + </div> + + <div className='report__textarea-wrapper'> + <textarea + className='report__textarea' + placeholder={intl.formatMessage(messages.placeholder)} + value={comment} + onChange={this.handleCommentChange} + disabled={isSubmitting} + /> + + <div className='report__submit'> + <div className='report__submit-button'><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div> + </div> + </div> + </div> + </Column> + ); + } + +} + +Report.contextTypes = { + router: PropTypes.object +}; + +Report.propTypes = { + isSubmitting: PropTypes.bool, + account: ImmutablePropTypes.map, + statusIds: ImmutablePropTypes.orderedSet.isRequired, + comment: PropTypes.string.isRequired, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default connect(makeMapStateToProps)(injectIntl(Report)); diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js new file mode 100644 index 000000000..384b47c8f --- /dev/null +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from '../../../components/icon_button'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import DropdownMenu from '../../../components/dropdown_menu'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' } +}); + +class ActionBar extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleReplyClick = this.handleReplyClick.bind(this); + this.handleReblogClick = this.handleReblogClick.bind(this); + this.handleFavouriteClick = this.handleFavouriteClick.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); + this.handleMentionClick = this.handleMentionClick.bind(this); + this.handleReport = this.handleReport.bind(this); + } + + handleReplyClick () { + this.props.onReply(this.props.status); + } + + handleReblogClick (e) { + this.props.onReblog(this.props.status, e); + } + + handleFavouriteClick () { + this.props.onFavourite(this.props.status); + } + + handleDeleteClick () { + this.props.onDelete(this.props.status); + } + + handleMentionClick () { + this.props.onMention(this.props.status.get('account'), this.context.router); + } + + handleReport () { + this.props.onReport(this.props.status); + this.context.router.push('/report'); + } + + render () { + const { status, me, intl } = this.props; + + let menu = []; + + if (me === status.getIn(['account', 'id'])) { + 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.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport }); + } + + let reblogIcon = 'retweet'; + if (status.get('visibility') === 'direct') reblogIcon = 'envelope'; + else if (status.get('visibility') === 'private') reblogIcon = 'lock'; + + let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private'); + + return ( + <div className='detailed-status__action-bar'> + <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div> + <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div> + <div className='detailed-status__button'><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> + <div className='detailed-status__button'><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" ariaLabel="More" /></div> + </div> + ); + } + +} + +ActionBar.contextTypes = { + router: PropTypes.object +}; + +ActionBar.propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReply: PropTypes.func.isRequired, + onReblog: PropTypes.func.isRequired, + onFavourite: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, + onReport: PropTypes.func, + me: PropTypes.number.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(ActionBar); diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js new file mode 100644 index 000000000..9e7d4f884 --- /dev/null +++ b/app/javascript/mastodon/features/status/components/card.js @@ -0,0 +1,96 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const hostStyle = { + display: 'block', + marginTop: '5px', + fontSize: '13px' +}; + +const getHostname = url => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +class Card extends React.PureComponent { + + renderLink () { + const { card } = this.props; + + let image = ''; + let provider = card.get('provider_name'); + + if (card.get('image')) { + image = ( + <div className='status-card__image'> + <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' /> + </div> + ); + } + + if (provider.length < 1) { + provider = getHostname(card.get('url')) + } + + return ( + <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'> + {image} + + <div className='status-card__content'> + <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong> + <p className='status-card__description'>{(card.get('description') || '').substring(0, 50)}</p> + <span className='status-card__host' style={hostStyle}>{provider}</span> + </div> + </a> + ); + } + + renderPhoto () { + const { card } = this.props; + + return ( + <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'> + <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} /> + </a> + ); + } + + renderVideo () { + const { card } = this.props; + const content = { __html: card.get('html') }; + + return ( + <div + className='status-card-video' + dangerouslySetInnerHTML={content} + /> + ); + } + + render () { + const { card } = this.props; + + if (card === null) { + return null; + } + + switch(card.get('type')) { + case 'link': + return this.renderLink(); + case 'photo': + return this.renderPhoto(); + case 'video': + return this.renderVideo(); + case 'rich': + default: + return null; + } + } +} + +Card.propTypes = { + card: ImmutablePropTypes.map +}; + +export default Card; diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js new file mode 100644 index 000000000..913a186b9 --- /dev/null +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -0,0 +1,96 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import StatusContent from '../../../components/status_content'; +import MediaGallery from '../../../components/media_gallery'; +import VideoPlayer from '../../../components/video_player'; +import AttachmentList from '../../../components/attachment_list'; +import { Link } from 'react-router'; +import { FormattedDate, FormattedNumber } from 'react-intl'; +import CardContainer from '../containers/card_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +class DetailedStatus extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleAccountClick = this.handleAccountClick.bind(this); + } + + handleAccountClick (e) { + if (e.button === 0) { + e.preventDefault(); + this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + + e.stopPropagation(); + } + + render () { + const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; + + let media = ''; + let applicationLink = ''; + + if (status.get('media_attachments').size > 0) { + if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { + media = <AttachmentList media={status.get('media_attachments')} />; + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} onOpenVideo={this.props.onOpenVideo} autoplay />; + } else { + media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />; + } + } else if (status.get('spoiler_text').length === 0) { + media = <CardContainer statusId={status.get('id')} />; + } + + if (status.get('application')) { + applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>; + } + + return ( + <div className='detailed-status'> + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'><Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div> + <DisplayName account={status.get('account')} /> + </a> + + <StatusContent status={status} /> + + {media} + + <div className='detailed-status__meta'> + <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> + <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> + </a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> + <i className='fa fa-retweet' /> + <span className='detailed-status__reblogs'> + <FormattedNumber value={status.get('reblogs_count')} /> + </span> + </Link> · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> + <i className='fa fa-star' /> + <span className='detailed-status__favorites'> + <FormattedNumber value={status.get('favourites_count')} /> + </span> + </Link> + </div> + </div> + ); + } + +} + +DetailedStatus.contextTypes = { + router: PropTypes.object +}; + +DetailedStatus.propTypes = { + status: ImmutablePropTypes.map.isRequired, + onOpenMedia: PropTypes.func.isRequired, + onOpenVideo: PropTypes.func.isRequired, + autoPlayGif: PropTypes.bool, +}; + +export default DetailedStatus; diff --git a/app/javascript/mastodon/features/status/containers/card_container.js b/app/javascript/mastodon/features/status/containers/card_container.js new file mode 100644 index 000000000..5c8bfeec2 --- /dev/null +++ b/app/javascript/mastodon/features/status/containers/card_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Card from '../components/card'; + +const mapStateToProps = (state, { statusId }) => ({ + card: state.getIn(['cards', statusId], null) +}); + +export default connect(mapStateToProps)(Card); diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js new file mode 100644 index 000000000..2e8c9e56a --- /dev/null +++ b/app/javascript/mastodon/features/status/index.js @@ -0,0 +1,199 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchStatus } from '../../actions/statuses'; +import Immutable from 'immutable'; +import EmbeddedStatus from '../../components/status'; +import MissingIndicator from '../../components/missing_indicator'; +import DetailedStatus from './components/detailed_status'; +import ActionBar from './components/action_bar'; +import Column from '../ui/components/column'; +import { + favourite, + unfavourite, + reblog, + unreblog +} from '../../actions/interactions'; +import { + replyCompose, + mentionCompose +} from '../../actions/compose'; +import { deleteStatus } from '../../actions/statuses'; +import { initReport } from '../../actions/reports'; +import { + makeGetStatus, + getStatusAncestors, + getStatusDescendants +} from '../../selectors'; +import { ScrollContainer } from 'react-router-scroll'; +import ColumnBackButton from '../../components/column_back_button'; +import StatusContainer from '../../containers/status_container'; +import { openModal } from '../../actions/modal'; +import { isMobile } from '../../is_mobile' +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +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?' } +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, props) => ({ + status: getStatus(state, Number(props.params.statusId)), + ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]), + descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]), + me: state.getIn(['meta', 'me']), + boostModal: state.getIn(['meta', 'boost_modal']), + autoPlayGif: state.getIn(['meta', 'auto_play_gif']) + }); + + return mapStateToProps; +}; + +class Status extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleFavouriteClick = this.handleFavouriteClick.bind(this); + this.handleReplyClick = this.handleReplyClick.bind(this); + this.handleModalReblog = this.handleModalReblog.bind(this); + this.handleReblogClick = this.handleReblogClick.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); + this.handleMentionClick = this.handleMentionClick.bind(this); + this.handleOpenMedia = this.handleOpenMedia.bind(this); + this.handleOpenVideo = this.handleOpenVideo.bind(this); + this.handleReport = this.handleReport.bind(this); + } + + componentWillMount () { + this.props.dispatch(fetchStatus(Number(this.props.params.statusId))); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchStatus(Number(nextProps.params.statusId))); + } + } + + handleFavouriteClick (status) { + if (status.get('favourited')) { + this.props.dispatch(unfavourite(status)); + } else { + this.props.dispatch(favourite(status)); + } + } + + handleReplyClick (status) { + this.props.dispatch(replyCompose(status, this.context.router)); + } + + handleModalReblog (status) { + this.props.dispatch(reblog(status)); + } + + handleReblogClick (status, e) { + if (status.get('reblogged')) { + this.props.dispatch(unreblog(status)); + } else { + if (e.shiftKey || !this.props.boostModal) { + this.handleModalReblog(status); + } else { + this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog })); + } + } + } + + handleDeleteClick (status) { + const { dispatch, intl } = this.props; + + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.get('id'))) + })); + } + + handleMentionClick (account, router) { + this.props.dispatch(mentionCompose(account, router)); + } + + handleOpenMedia (media, index) { + this.props.dispatch(openModal('MEDIA', { media, index })); + } + + handleOpenVideo (media, time) { + this.props.dispatch(openModal('VIDEO', { media, time })); + } + + handleReport (status) { + this.props.dispatch(initReport(status.get('account'), status)); + } + + renderChildren (list) { + return list.map(id => <StatusContainer key={id} id={id} />); + } + + render () { + let ancestors, descendants; + const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props; + + if (status === null) { + return ( + <Column> + <ColumnBackButton /> + <MissingIndicator /> + </Column> + ); + } + + const account = status.get('account'); + + if (ancestorsIds && ancestorsIds.size > 0) { + ancestors = <div>{this.renderChildren(ancestorsIds)}</div>; + } + + if (descendantsIds && descendantsIds.size > 0) { + descendants = <div>{this.renderChildren(descendantsIds)}</div>; + } + + return ( + <Column> + <ColumnBackButton /> + + <ScrollContainer scrollKey='thread'> + <div className='scrollable detailed-status__wrapper'> + {ancestors} + + <DetailedStatus status={status} autoPlayGif={autoPlayGif} me={me} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} /> + <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} /> + + {descendants} + </div> + </ScrollContainer> + </Column> + ); + } + +} + +Status.contextTypes = { + router: PropTypes.object +}; + +Status.propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + status: ImmutablePropTypes.map, + ancestorsIds: ImmutablePropTypes.list, + descendantsIds: ImmutablePropTypes.list, + me: PropTypes.number, + boostModal: PropTypes.bool, + autoPlayGif: PropTypes.bool, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(connect(makeMapStateToProps)(Status)); diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js new file mode 100644 index 000000000..d6000fe4e --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/boost_modal.js @@ -0,0 +1,84 @@ +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 Button from '../../../components/button'; +import StatusContent from '../../../components/status_content'; +import Avatar from '../../../components/avatar'; +import RelativeTimestamp from '../../../components/relative_timestamp'; +import DisplayName from '../../../components/display_name'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + reblog: { id: 'status.reblog', defaultMessage: 'Boost' } +}); + +class BoostModal extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.handleReblog = this.handleReblog.bind(this); + this.handleAccountClick = this.handleAccountClick.bind(this); + } + + handleReblog() { + this.props.onReblog(this.props.status); + this.props.onClose(); + } + + handleAccountClick (e) { + if (e.button === 0) { + e.preventDefault(); + this.props.onClose(); + this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + } + } + + render () { + const { status, intl, onClose } = this.props; + + return ( + <div className='modal-root__modal boost-modal'> + <div className='boost-modal__container'> + <div className='status light'> + <div className='boost-modal__status-header'> + <div className='boost-modal__status-time'> + <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> + </div> + + <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'> + <div className='status__avatar'> + <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /> + </div> + + <DisplayName account={status.get('account')} /> + </a> + </div> + + <StatusContent status={status} /> + </div> + </div> + + <div className='boost-modal__action-bar'> + <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /></div> + <Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} /> + </div> + </div> + ); + } + +} + +BoostModal.contextTypes = { + router: PropTypes.object +}; + +BoostModal.propTypes = { + status: ImmutablePropTypes.map.isRequired, + onReblog: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(BoostModal); diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js new file mode 100644 index 000000000..fcb197573 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/column.js @@ -0,0 +1,93 @@ +import React from 'react'; +import ColumnHeader from './column_header'; +import PropTypes from 'prop-types'; + +const easingOutQuint = (x, t, b, c, d) => c*((t=t/d-1)*t*t*t*t + 1) + b; + +const scrollTop = (node) => { + const startTime = Date.now(); + const offset = node.scrollTop; + const targetY = -offset; + const duration = 1000; + let interrupt = false; + + const step = () => { + const elapsed = Date.now() - startTime; + const percentage = elapsed / duration; + + if (percentage > 1 || interrupt) { + return; + } + + node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration); + requestAnimationFrame(step); + }; + + step(); + + return () => { + interrupt = true; + }; +}; + +class Column extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleHeaderClick = this.handleHeaderClick.bind(this); + this.handleWheel = this.handleWheel.bind(this); + this.setRef = this.setRef.bind(this); + } + + handleHeaderClick () { + const scrollable = this.node.querySelector('.scrollable'); + if (!scrollable) { + return; + } + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleWheel () { + if (typeof this._interruptScrollAnimation !== 'undefined') { + this._interruptScrollAnimation(); + } + } + + setRef (c) { + this.node = c; + } + + render () { + const { heading, icon, children, active, hideHeadingOnMobile } = this.props; + + let columnHeaderId = null + let header = ''; + + if (heading) { + columnHeaderId = heading.replace(/ /g, '-') + header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} hideOnMobile={hideHeadingOnMobile} columnHeaderId={columnHeaderId}/>; + } + return ( + <div + ref={this.setRef} + role='region' + aria-labelledby={columnHeaderId} + className='column' + onWheel={this.handleWheel}> + {header} + {children} + </div> + ); + } + +} + +Column.propTypes = { + heading: PropTypes.string, + icon: PropTypes.string, + children: PropTypes.node, + active: PropTypes.bool, + hideHeadingOnMobile: PropTypes.bool +}; + +export default Column; diff --git a/app/javascript/mastodon/features/ui/components/column_header.js b/app/javascript/mastodon/features/ui/components/column_header.js new file mode 100644 index 000000000..2701cd57d --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/column_header.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types' + +class ColumnHeader extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick () { + this.props.onClick(); + } + + render () { + const { type, active, hideOnMobile, columnHeaderId } = this.props; + + let icon = ''; + + if (this.props.icon) { + icon = <i className={`fa fa-fw fa-${this.props.icon} column-header__icon`} />; + } + + return ( + <div role='button heading' tabIndex='0' className={`column-header ${active ? 'active' : ''} ${hideOnMobile ? 'hidden-on-mobile' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}> + {icon} + {type} + </div> + ); + } + +} + +ColumnHeader.propTypes = { + icon: PropTypes.string, + type: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func, + hideOnMobile: PropTypes.bool, + columnHeaderId: PropTypes.string +}; + +export default ColumnHeader; diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js new file mode 100644 index 000000000..cffe796ba --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/column_link.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router'; + +const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => { + if (href) { + return ( + <a href={href} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + </a> + ); + } else { + return ( + <Link to={to} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`}> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + </Link> + ); + } +}; + +ColumnLink.propTypes = { + icon: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + to: PropTypes.string, + href: PropTypes.string, + method: PropTypes.string, + hideOnMobile: PropTypes.bool +}; + +export default ColumnLink; diff --git a/app/javascript/mastodon/features/ui/components/column_subheading.js b/app/javascript/mastodon/features/ui/components/column_subheading.js new file mode 100644 index 000000000..8160c4aa3 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/column_subheading.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const ColumnSubheading = ({ text }) => { + return ( + <div className='column-subheading'> + {text} + </div> + ); +}; + +ColumnSubheading.propTypes = { + text: PropTypes.string.isRequired, +}; + +export default ColumnSubheading; diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js new file mode 100644 index 000000000..05f9f3fb5 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +class ColumnsArea extends React.PureComponent { + + render () { + return ( + <div className='columns-area'> + {this.props.children} + </div> + ); + } + +} + +ColumnsArea.propTypes = { + children: PropTypes.node +}; + +export default ColumnsArea; diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.js b/app/javascript/mastodon/features/ui/components/confirmation_modal.js new file mode 100644 index 000000000..499993207 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/confirmation_modal.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Button from '../../../components/button'; + +class ConfirmationModal extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + this.handleCancel = this.handleCancel.bind(this); + } + + handleClick () { + this.props.onClose(); + this.props.onConfirm(); + } + + handleCancel (e) { + e.preventDefault(); + this.props.onClose(); + } + + render () { + const { intl, message, confirm, onConfirm, onClose } = this.props; + + return ( + <div className='modal-root__modal confirmation-modal'> + <div className='confirmation-modal__container'> + {message} + </div> + + <div className='confirmation-modal__action-bar'> + <div><a href='#' onClick={this.handleCancel}><FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /></a></div> + <Button text={confirm} onClick={this.handleClick} /> + </div> + </div> + ); + } + +} + +ConfirmationModal.propTypes = { + message: PropTypes.node.isRequired, + confirm: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(ConfirmationModal); diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js new file mode 100644 index 000000000..a8fb3858a --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -0,0 +1,103 @@ +import React from 'react'; +import LoadingIndicator from '../../../components/loading_indicator'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ExtendedVideoPlayer from '../../../components/extended_video_player'; +import ImageLoader from 'react-imageloader'; +import { defineMessages, injectIntl } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' } +}); + +class MediaModal extends ImmutablePureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + index: null + }; + this.handleNextClick = this.handleNextClick.bind(this); + this.handlePrevClick = this.handlePrevClick.bind(this); + this.handleKeyUp = this.handleKeyUp.bind(this); + } + + handleNextClick () { + this.setState({ index: (this.getIndex() + 1) % this.props.media.size}); + } + + handlePrevClick () { + this.setState({ index: (this.getIndex() - 1) % this.props.media.size}); + } + + handleKeyUp (e) { + switch(e.key) { + case 'ArrowLeft': + this.handlePrevClick(); + break; + case 'ArrowRight': + this.handleNextClick(); + break; + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + getIndex () { + return this.state.index !== null ? this.state.index : this.props.index; + } + + render () { + const { media, intl, onClose } = this.props; + + const index = this.getIndex(); + const attachment = media.get(index); + const url = attachment.get('url'); + + let leftNav, rightNav, content; + + leftNav = rightNav = content = ''; + + if (media.size > 1) { + leftNav = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>; + rightNav = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>; + } + + if (attachment.get('type') === 'image') { + content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />; + } else if (attachment.get('type') === 'gifv') { + content = <ExtendedVideoPlayer src={url} muted={true} controls={false} />; + } + + return ( + <div className='modal-root__modal media-modal'> + {leftNav} + + <div className='media-modal__content'> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> + {content} + </div> + + {rightNav} + </div> + ); + } + +} + +MediaModal.propTypes = { + media: ImmutablePropTypes.list.isRequired, + index: PropTypes.number.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(MediaModal); diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js new file mode 100644 index 000000000..5cde65907 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -0,0 +1,93 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import MediaModal from './media_modal'; +import OnboardingModal from './onboarding_modal'; +import VideoModal from './video_modal'; +import BoostModal from './boost_modal'; +import ConfirmationModal from './confirmation_modal'; +import { TransitionMotion, spring } from 'react-motion'; + +const MODAL_COMPONENTS = { + 'MEDIA': MediaModal, + 'ONBOARDING': OnboardingModal, + 'VIDEO': VideoModal, + 'BOOST': BoostModal, + 'CONFIRM': ConfirmationModal +}; + +class ModalRoot extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.handleKeyUp = this.handleKeyUp.bind(this); + } + + handleKeyUp (e) { + if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) + && !!this.props.type) { + this.props.onClose(); + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + willEnter () { + return { opacity: 0, scale: 0.98 }; + } + + willLeave () { + return { opacity: spring(0), scale: spring(0.98) }; + } + + render () { + const { type, props, onClose } = this.props; + const items = []; + + if (!!type) { + items.push({ + key: type, + data: { type, props }, + style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) } + }); + } + + return ( + <TransitionMotion + styles={items} + willEnter={this.willEnter} + willLeave={this.willLeave}> + {interpolatedStyles => + <div className='modal-root'> + {interpolatedStyles.map(({ key, data: { type, props }, style }) => { + const SpecificComponent = MODAL_COMPONENTS[type]; + + return ( + <div key={key}> + <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} /> + <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}> + <SpecificComponent {...props} onClose={onClose} /> + </div> + </div> + ); + })} + </div> + } + </TransitionMotion> + ); + } + +} + +ModalRoot.propTypes = { + type: PropTypes.string, + props: PropTypes.object, + onClose: PropTypes.func.isRequired +}; + +export default ModalRoot; diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js new file mode 100644 index 000000000..7cdd3527a --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -0,0 +1,264 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Permalink from '../../../components/permalink'; +import { TransitionMotion, spring } from 'react-motion'; +import ComposeForm from '../../compose/components/compose_form'; +import Search from '../../compose/components/search'; +import NavigationBar from '../../compose/components/navigation_bar'; +import ColumnHeader from './column_header'; +import Immutable from 'immutable'; + +const messages = defineMessages({ + home_title: { id: 'column.home', defaultMessage: 'Home' }, + notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + local_title: { id: 'column.community', defaultMessage: 'Local timeline' }, + federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' } +}); + +const PageOne = ({ acct, domain }) => ( + <div className='onboarding-modal__page onboarding-modal__page-one'> + <div style={{ flex: '0 0 auto' }}> + <div className='onboarding-modal__page-one__elephant-friend' /> + </div> + + <div> + <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1> + <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p> + <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }}/></p> + </div> + </div> +); + +PageOne.propTypes = { + acct: PropTypes.string.isRequired, + domain: PropTypes.string.isRequired +}; + +const PageTwo = ({ me }) => ( + <div className='onboarding-modal__page onboarding-modal__page-two'> + <div className='figure non-interactive'> + <div className='pseudo-drawer'> + <NavigationBar account={me} /> + </div> + <ComposeForm + text='Awoo! #introductions' + suggestions={Immutable.List()} + mentionedDomains={[]} + spoiler={false} + onChange={() => {}} + onSubmit={() => {}} + onPaste={() => {}} + onPickEmoji={() => {}} + onChangeSpoilerText={() => {}} + onClearSuggestions={() => {}} + onFetchSuggestions={() => {}} + onSuggestionSelected={() => {}} + /> + </div> + + <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p> + </div> +); + +PageTwo.propTypes = { + me: ImmutablePropTypes.map.isRequired, +}; + +const PageThree = ({ me, domain }) => ( + <div className='onboarding-modal__page onboarding-modal__page-three'> + <div className='figure non-interactive'> + <Search + value='' + onChange={() => {}} + onSubmit={() => {}} + onClear={() => {}} + onShow={() => {}} + /> + + <div className='pseudo-drawer'> + <NavigationBar account={me} /> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/timelines/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/timelines/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }}/></p> + <p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p> + </div> +); + +PageThree.propTypes = { + me: ImmutablePropTypes.map.isRequired, + domain: PropTypes.string.isRequired +}; + +const PageFour = ({ domain, intl }) => ( + <div className='onboarding-modal__page onboarding-modal__page-four'> + <div className='onboarding-modal__page-four__columns'> + <div className='row'> + <div> + <div className='figure non-interactive'><ColumnHeader icon='home' type={intl.formatMessage(messages.home_title)} /></div> + <p><FormattedMessage id='onboarding.page_four.home' defaultMessage='The home timeline shows posts from people you follow.'/></p> + </div> + + <div> + <div className='figure non-interactive'><ColumnHeader icon='bell' type={intl.formatMessage(messages.notifications_title)} /></div> + <p><FormattedMessage id='onboarding.page_four.notifications' defaultMessage='The notifications column shows when someone interacts with you.' /></p> + </div> + </div> + + <div className='row'> + <div> + <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='users' type={intl.formatMessage(messages.local_title)} /></div> + </div> + + <div> + <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='globe' type={intl.formatMessage(messages.federated_title)} /></div> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_five.public_timelines' defaultMessage='The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.' values={{ domain }} /></p> + </div> + </div> +); + +PageFour.propTypes = { + domain: PropTypes.string.isRequired, + intl: PropTypes.object.isRequired +}; + +const PageSix = ({ admin, domain }) => { + let adminSection = ''; + + if (admin) { + adminSection = ( + <p> + <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/accounts/${admin.get('id')}`}>@{admin.get('acct')}</Permalink> }} /> + <br /> + <FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage="Please read {domain}'s {guidelines}!" values={{domain, guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }}/> + </p> + ); + } + + return ( + <div className='onboarding-modal__page onboarding-modal__page-six'> + <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1> + {adminSection} + <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p> + <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p> + <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p> + </div> + ); +}; + +PageSix.propTypes = { + admin: ImmutablePropTypes.map, + domain: PropTypes.string.isRequired +}; + +const mapStateToProps = state => ({ + me: state.getIn(['accounts', state.getIn(['meta', 'me'])]), + admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]), + domain: state.getIn(['meta', 'domain']) +}); + +class OnboardingModal extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + currentIndex: 0 + }; + this.handleSkip = this.handleSkip.bind(this); + this.handleDot = this.handleDot.bind(this); + this.handleNext = this.handleNext.bind(this); + } + + handleSkip (e) { + e.preventDefault(); + this.props.onClose(); + } + + handleDot (i, e) { + e.preventDefault(); + this.setState({ currentIndex: i }); + } + + handleNext (maxNum, e) { + e.preventDefault(); + + if (this.state.currentIndex < maxNum - 1) { + this.setState({ currentIndex: this.state.currentIndex + 1 }); + } else { + this.props.onClose(); + } + } + + render () { + const { me, admin, domain, intl } = this.props; + + const pages = [ + <PageOne acct={me.get('acct')} domain={domain} />, + <PageTwo me={me} />, + <PageThree me={me} domain={domain} />, + <PageFour domain={domain} intl={intl} />, + <PageSix admin={admin} domain={domain} /> + ]; + + const { currentIndex } = this.state; + const hasMore = currentIndex < pages.length - 1; + + let nextOrDoneBtn; + + if(hasMore) { + nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__next'><FormattedMessage id='onboarding.next' defaultMessage='Next' /></a>; + } else { + nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__done'><FormattedMessage id='onboarding.done' defaultMessage='Done' /></a>; + } + + const styles = pages.map((page, i) => ({ + key: `page-${i}`, + style: { opacity: spring(i === currentIndex ? 1 : 0) } + })); + + return ( + <div className='modal-root__modal onboarding-modal'> + <TransitionMotion styles={styles}> + {interpolatedStyles => + <div className='onboarding-modal__pager'> + {pages.map((page, i) => + <div key={`page-${i}`} style={{ opacity: interpolatedStyles[i].style.opacity, pointerEvents: i === currentIndex ? 'auto' : 'none' }}>{page}</div> + )} + </div> + } + </TransitionMotion> + + <div className='onboarding-modal__paginator'> + <div> + <a href='#' className='onboarding-modal__skip' onClick={this.handleSkip}><FormattedMessage id='onboarding.skip' defaultMessage='Skip' /></a> + </div> + + <div className='onboarding-modal__dots'> + {pages.map((_, i) => <div key={i} onClick={this.handleDot.bind(null, i)} className={`onboarding-modal__dot ${i === currentIndex ? 'active' : ''}`} />)} + </div> + + <div> + {nextOrDoneBtn} + </div> + </div> + </div> + ); + } + +} + +OnboardingModal.propTypes = { + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + me: ImmutablePropTypes.map.isRequired, + domain: PropTypes.string.isRequired, + admin: ImmutablePropTypes.map +} + +export default connect(mapStateToProps)(injectIntl(OnboardingModal)); diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js new file mode 100644 index 000000000..b6a30bc11 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { Link } from 'react-router'; +import { FormattedMessage } from 'react-intl'; + +class TabsBar extends React.Component { + + render () { + return ( + <div className='tabs-bar'> + <Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link> + <Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link> + <Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link> + + <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link> + <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link> + + <Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link> + </div> + ); + } + +} + +export default TabsBar; diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js new file mode 100644 index 000000000..c5710ee69 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/upload_area.js @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Motion, spring } from 'react-motion'; +import { FormattedMessage } from 'react-intl'; + +class UploadArea extends React.PureComponent { + + constructor (props, context) { + super(props, context); + + this.handleKeyUp = this.handleKeyUp.bind(this); + } + + handleKeyUp (e) { + e.preventDefault(); + e.stopPropagation(); + + const keyCode = e.keyCode + if (this.props.active) { + switch(keyCode) { + case 27: + this.props.onClose(); + break; + } + } + } + + componentDidMount () { + window.addEventListener('keyup', this.handleKeyUp, false); + } + + componentWillUnmount () { + window.removeEventListener('keyup', this.handleKeyUp); + } + + render () { + const { active } = this.props; + + return ( + <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}> + {({ backgroundOpacity, backgroundScale }) => + <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}> + <div className='upload-area__drop'> + <div className='upload-area__background' style={{ transform: `translateZ(0) scale(${backgroundScale})` }} /> + <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div> + </div> + </div> + } + </Motion> + ); + } + +} + +UploadArea.propTypes = { + active: PropTypes.bool, + onClose: PropTypes.func +}; + +export default UploadArea; diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js new file mode 100644 index 000000000..8e2e4a533 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -0,0 +1,40 @@ +import React from 'react'; +import LoadingIndicator from '../../../components/loading_indicator'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ExtendedVideoPlayer from '../../../components/extended_video_player'; +import { defineMessages, injectIntl } from 'react-intl'; +import IconButton from '../../../components/icon_button'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' } +}); + +class VideoModal extends ImmutablePureComponent { + + render () { + const { media, intl, time, onClose } = this.props; + + const url = media.get('url'); + + return ( + <div className='modal-root__modal media-modal'> + <div> + <div className='media-modal__close'><IconButton title={intl.formatMessage(messages.close)} icon='times' overlay onClick={onClose} /></div> + <ExtendedVideoPlayer src={url} muted={false} controls={true} time={time} /> + </div> + </div> + ); + } + +} + +VideoModal.propTypes = { + media: ImmutablePropTypes.map.isRequired, + time: PropTypes.number, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired +}; + +export default injectIntl(VideoModal); diff --git a/app/javascript/mastodon/features/ui/containers/loading_bar_container.js b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js new file mode 100644 index 000000000..6c4e73e38 --- /dev/null +++ b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import LoadingBar from 'react-redux-loading-bar'; + +const mapStateToProps = (state) => ({ + loading: state.get('loadingBar') +}); + +export default connect(mapStateToProps)(LoadingBar.WrappedComponent); diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js new file mode 100644 index 000000000..26d77818c --- /dev/null +++ b/app/javascript/mastodon/features/ui/containers/modal_container.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { closeModal } from '../../../actions/modal'; +import ModalRoot from '../components/modal_root'; + +const mapStateToProps = state => ({ + type: state.get('modal').modalType, + props: state.get('modal').modalProps +}); + +const mapDispatchToProps = dispatch => ({ + onClose () { + dispatch(closeModal()); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot); diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js new file mode 100644 index 000000000..529ebf6c8 --- /dev/null +++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { NotificationStack } from 'react-notification'; +import { + dismissAlert, + clearAlerts +} from '../../../actions/alerts'; +import { getAlerts } from '../../../selectors'; + +const mapStateToProps = (state, props) => ({ + notifications: getAlerts(state) +}); + +const mapDispatchToProps = (dispatch) => { + return { + onDismiss: alert => { + dispatch(dismissAlert(alert)); + } + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack); diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js new file mode 100644 index 000000000..1599000b5 --- /dev/null +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -0,0 +1,74 @@ +import { connect } from 'react-redux'; +import StatusList from '../../../components/status_list'; +import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines'; +import Immutable from 'immutable'; +import { createSelector } from 'reselect'; +import { debounce } from 'react-decoration'; + +const makeGetStatusIds = () => createSelector([ + (state, { type }) => state.getIn(['settings', type], Immutable.Map()), + (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()), + (state) => state.get('statuses'), + (state) => state.getIn(['meta', 'me']) +], (columnSettings, statusIds, statuses, me) => statusIds.filter(id => { + const statusForId = statuses.get(id); + let showStatus = true; + + if (columnSettings.getIn(['shows', 'reblog']) === false) { + showStatus = showStatus && statusForId.get('reblog') === null; + } + + if (columnSettings.getIn(['shows', 'reply']) === false) { + showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me); + } + + if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) { + try { + if (showStatus) { + const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i'); + showStatus = !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'unescaped_content']) : statusForId.get('unescaped_content')); + } + } catch(e) { + // Bad regex, don't affect filters + } + } + + return showStatus; +})); + +const makeMapStateToProps = () => { + const getStatusIds = makeGetStatusIds(); + + const mapStateToProps = (state, props) => ({ + scrollKey: props.scrollKey, + shouldUpdateScroll: props.shouldUpdateScroll, + statusIds: getStatusIds(state, props), + isLoading: state.getIn(['timelines', props.type, 'isLoading'], true), + isUnread: state.getIn(['timelines', props.type, 'unread']) > 0, + hasMore: !!state.getIn(['timelines', props.type, 'next']) + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { type, id }) => ({ + + @debounce(300, true) + onScrollToBottom () { + dispatch(scrollTopTimeline(type, false)); + dispatch(expandTimeline(type, id)); + }, + + @debounce(100) + onScrollToTop () { + dispatch(scrollTopTimeline(type, true)); + }, + + @debounce(100) + onScroll () { + dispatch(scrollTopTimeline(type, false)); + } + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js new file mode 100644 index 000000000..d096cb882 --- /dev/null +++ b/app/javascript/mastodon/features/ui/index.js @@ -0,0 +1,169 @@ +import React from 'react'; +import ColumnsArea from './components/columns_area'; +import NotificationsContainer from './containers/notifications_container'; +import PropTypes from 'prop-types'; +import LoadingBarContainer from './containers/loading_bar_container'; +import HomeTimeline from '../home_timeline'; +import Compose from '../compose'; +import TabsBar from './components/tabs_bar'; +import ModalContainer from './containers/modal_container'; +import Notifications from '../notifications'; +import { connect } from 'react-redux'; +import { isMobile } from '../../is_mobile'; +import { debounce } from 'react-decoration'; +import { uploadCompose } from '../../actions/compose'; +import { refreshTimeline } from '../../actions/timelines'; +import { refreshNotifications } from '../../actions/notifications'; +import UploadArea from './components/upload_area'; + +const noOp = () => false; + +class UI extends React.PureComponent { + + constructor (props, context) { + super(props, context); + this.state = { + width: window.innerWidth, + draggingOver: false + }; + this.handleResize = this.handleResize.bind(this); + this.handleDragEnter = this.handleDragEnter.bind(this); + this.handleDragOver = this.handleDragOver.bind(this); + this.handleDrop = this.handleDrop.bind(this); + this.handleDragLeave = this.handleDragLeave.bind(this); + this.handleDragEnd = this.handleDragLeave.bind(this) + this.closeUploadModal = this.closeUploadModal.bind(this) + this.setRef = this.setRef.bind(this); + } + + @debounce(500) + handleResize () { + this.setState({ width: window.innerWidth }); + } + + handleDragEnter (e) { + e.preventDefault(); + + if (!this.dragTargets) { + this.dragTargets = []; + } + + if (this.dragTargets.indexOf(e.target) === -1) { + this.dragTargets.push(e.target); + } + + if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { + this.setState({ draggingOver: true }); + } + } + + handleDragOver (e) { + e.preventDefault(); + e.stopPropagation(); + + try { + e.dataTransfer.dropEffect = 'copy'; + } catch (err) { + + } + + return false; + } + + handleDrop (e) { + e.preventDefault(); + + this.setState({ draggingOver: false }); + + if (e.dataTransfer && e.dataTransfer.files.length === 1) { + this.props.dispatch(uploadCompose(e.dataTransfer.files)); + } + } + + handleDragLeave (e) { + e.preventDefault(); + e.stopPropagation(); + + this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el)); + + if (this.dragTargets.length > 0) { + return; + } + + this.setState({ draggingOver: false }); + } + + closeUploadModal() { + this.setState({ draggingOver: false }); + } + + componentWillMount () { + window.addEventListener('resize', this.handleResize, { passive: true }); + document.addEventListener('dragenter', this.handleDragEnter, false); + document.addEventListener('dragover', this.handleDragOver, false); + document.addEventListener('drop', this.handleDrop, false); + document.addEventListener('dragleave', this.handleDragLeave, false); + document.addEventListener('dragend', this.handleDragEnd, false); + + this.props.dispatch(refreshTimeline('home')); + this.props.dispatch(refreshNotifications()); + } + + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + document.removeEventListener('dragenter', this.handleDragEnter); + document.removeEventListener('dragover', this.handleDragOver); + document.removeEventListener('drop', this.handleDrop); + document.removeEventListener('dragleave', this.handleDragLeave); + document.removeEventListener('dragend', this.handleDragEnd); + } + + setRef (c) { + this.node = c; + } + + render () { + const { width, draggingOver } = this.state; + const { children } = this.props; + + let mountedColumns; + + if (isMobile(width)) { + mountedColumns = ( + <ColumnsArea> + {children} + </ColumnsArea> + ); + } else { + mountedColumns = ( + <ColumnsArea> + <Compose withHeader={true} /> + <HomeTimeline shouldUpdateScroll={noOp} /> + <Notifications shouldUpdateScroll={noOp} /> + <div style={{display: 'flex', flex: '1 1 auto', position: 'relative'}}>{children}</div> + </ColumnsArea> + ); + } + + return ( + <div className='ui' ref={this.setRef}> + <TabsBar /> + + {mountedColumns} + + <NotificationsContainer /> + <LoadingBarContainer className="loading-bar" /> + <ModalContainer /> + <UploadArea active={draggingOver} onClose={this.closeUploadModal} /> + </div> + ); + } + +} + +UI.propTypes = { + dispatch: PropTypes.func.isRequired, + children: PropTypes.node +}; + +export default connect()(UI); diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js new file mode 100644 index 000000000..992e63727 --- /dev/null +++ b/app/javascript/mastodon/is_mobile.js @@ -0,0 +1,11 @@ +const LAYOUT_BREAKPOINT = 1024; + +export function isMobile(width) { + return width <= LAYOUT_BREAKPOINT; +}; + +const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + +export function isIOS() { + return iOS; +}; diff --git a/app/javascript/mastodon/link_header.js b/app/javascript/mastodon/link_header.js new file mode 100644 index 000000000..b872dc24a --- /dev/null +++ b/app/javascript/mastodon/link_header.js @@ -0,0 +1,33 @@ +import Link from 'http-link-header'; +import querystring from 'querystring'; + +Link.parseAttrs = (link, parts) => { + let match = null + let attr = '' + let value = '' + let attrs = '' + + let uriAttrs = /<(.*)>;\s*(.*)/gi.exec(parts) + + if(uriAttrs) { + attrs = uriAttrs[2] + link = Link.parseParams(link, uriAttrs[1]) + } + + while(match = Link.attrPattern.exec(attrs)) { // eslint-disable-line no-cond-assign + attr = match[1].toLowerCase() + value = match[4] || match[3] || match[2] + + if( /\*$/.test(attr)) { + Link.setAttr(link, attr, Link.parseExtendedValue(value)) + } else if(/%/.test(value)) { + Link.setAttr(link, attr, querystring.decode(value)) + } else { + Link.setAttr(link, attr, value) + } + } + + return link +}; + +export default Link; diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json new file mode 100644 index 000000000..b4f05a73e --- /dev/null +++ b/app/javascript/mastodon/locales/ar.json @@ -0,0 +1,172 @@ +{ + "account.block": "حظر @{name}", + "account.disclaimer": "هذا المستخدم من مثيل خادم آخر. قد يكون هذا الرقم أكبر.", + "account.edit_profile": "تعديل الملف الشخصي", + "account.follow": "إتبع", + "account.followers": "المتابعون", + "account.follows": "يتبع", + "account.follows_you": "يتابعك", + "account.mention": "أُذكُر @{name}", + "account.mute": "أكتم @{name}", + "account.posts": "المشاركات", + "account.report": "أبلغ عن @{name}", + "account.requested": "في انتظار الموافقة", + "account.unblock": "إلغاء الحظر عن @{name}", + "account.unfollow": "إلغاء المتابعة", + "account.unmute": "إلغاء الكتم عن @{name}", + "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة", + "column.blocks": "الحسابات المحجوبة", + "column.community": "الخيط العام المحلي", + "column.favourites": "المفضلة", + "column.follow_requests": "طلبات المتابعة", + "column.home": "الرئيسية", + "column.mutes": "الحسابات المكتومة", + "column.notifications": "الإشعارات", + "column.public": "الخيط العام الموحد", + "column_back_button.label": "العودة", + "column_subheading.navigation": "التصفح", + "column_subheading.settings": "الإعدادات", + "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.", + "compose_form.lock_disclaimer.lock": "مقفل", + "compose_form.placeholder": "فيمَ تفكّر؟", + "compose_form.publish": "بوّق !", + "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس", + "compose_form.spoiler": "أخفِ النص واعرض تحذيرا", + "compose_form.spoiler_placeholder": "تنبيه عن المحتوى", + "confirmation_modal.cancel": "إلغاء", + "confirmations.block.confirm": "حجب", + "confirmations.block.message": "هل أنت متأكد أنك تريد حجب {name} ؟", + "confirmations.delete.confirm": "حذف", + "confirmations.delete.message": "هل أنت متأكد أنك تريد حذف هذا المنشور ؟", + "confirmations.mute.confirm": "أكتم", + "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟", + "emoji_button.activity": "الأنشطة", + "emoji_button.flags": "الأعلام", + "emoji_button.food": "الطعام والشراب", + "emoji_button.label": "أدرج إيموجي", + "emoji_button.nature": "الطبيعة", + "emoji_button.objects": "أشياء", + "emoji_button.people": "الناس", + "emoji_button.search": "ابحث...", + "emoji_button.symbols": "رموز", + "emoji_button.travel": "أماكن و أسفار", + "empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.", + "empty_column.hashtag": "ليس هناك بعدُ أي محتوى ذو علاقة بهذا الوسم.", + "empty_column.home": "إنك لا تتبع بعد أي شخص إلى حد الآن. زر {public} أو استخدام حقل البحث لكي تبدأ على التعرف على مستخدمين آخرين.", + "empty_column.home.public_timeline": "الخيط العام", + "empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.", + "empty_column.public": "لا يوجد شيء هنا ! قم بتحرير شيء ما بشكل عام، أو اتبع مستخدمين آخرين في الخوادم المثيلة الأخرى لملء خيط المحادثات العام.", + "follow_request.authorize": "ترخيص", + "follow_request.reject": "رفض", + "getting_started.apps": "عدة تطبيقات مختلفة متوفرة", + "getting_started.heading": "إستعدّ للبدء", + "getting_started.open_source_notice": "ماستدون برنامج مفتوح المصدر. يمكنك المساهمة، أو الإبلاغ عن تقارير الأخطاء، على GitHub {github}. {apps}.", + "home.column_settings.advanced": "متقدمة", + "home.column_settings.basic": "أساسية", + "home.column_settings.filter_regex": "تصفية حسب التعبيرات العادية", + "home.column_settings.show_reblogs": "عرض الترقيات", + "home.column_settings.show_replies": "عرض الردود", + "home.settings": "إعدادات العمود", + "lightbox.close": "إغلاق", + "loading_indicator.label": "تحميل ...", + "media_gallery.toggle_visible": "Toggle visibility", + "missing_indicator.label": "تعذر العثور عليه", + "navigation_bar.blocks": "الحسابات المحجوبة", + "navigation_bar.community_timeline": "الخيط العام المحلي", + "navigation_bar.edit_profile": "تعديل الملف الشخصي", + "navigation_bar.favourites": "Favourites", + "navigation_bar.follow_requests": "Follow requests", + "navigation_bar.info": "Extended information", + "navigation_bar.logout": "خروج", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "التفضيلات", + "navigation_bar.public_timeline": "الخيط العام الموحد", + "notification.favourite": "{name} أعجب بمنشورك", + "notification.follow": "{name} يتبعك", + "notification.reblog": "{name} قام بترقية تبويقك", + "notifications.clear": "Clear notifications", + "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.alert": "إشعارات سطح المكتب", + "notifications.column_settings.favourite": "المُفَضَّلة :", + "notifications.column_settings.follow": "متابعُون جُدُد :", + "notifications.column_settings.mention": "الإشارات :", + "notifications.column_settings.reblog": "الترقيّات:", + "notifications.column_settings.show": "إعرِضها في عمود", + "notifications.column_settings.sound": "Play sound", + "notifications.settings": "Column settings", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Adjust status privacy", + "privacy.direct.long": "Post to mentioned users only", + "privacy.direct.short": "Direct", + "privacy.private.long": "Post to followers only", + "privacy.private.short": "Followers-only", + "privacy.public.long": "Post to public timelines", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Do not show in public timelines", + "privacy.unlisted.short": "Unlisted", + "reply_indicator.cancel": "إلغاء", + "report.heading": "New report", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting", + "search.placeholder": "ابحث", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "إحذف", + "status.favourite": "أضف إلى المفضلة", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "أذكُر @{name}", + "status.open": "وسع هذه المشاركة", + "status.reblog": "رَقِّي", + "status.reblogged_by": "{name} رقى", + "status.reply": "ردّ", + "status.replyAll": "Reply to thread", + "status.report": "إبلِغ عن @{name}", + "status.sensitive_toggle": "اضغط للعرض", + "status.sensitive_warning": "محتوى حساس", + "status.show_less": "إعرض أقلّ", + "status.show_more": "أظهر المزيد", + "tabs_bar.compose": "تحرير", + "tabs_bar.federated_timeline": "Federated", + "tabs_bar.home": "الرئيسية", + "tabs_bar.mentions": "الإشارات", + "tabs_bar.public": "الخيط العام الموحد", + "tabs_bar.notifications": "الإخطارات", + "upload_button.label": "إضافة وسائط", + "upload_form.undo": "إلغاء", + "upload_progress.label": "يرفع...", + "notification.follow": "{name} يتبعك", + "notification.favourite": "{name} أعجب بمنشورك", + "notification.reblog": "{name} قام بترقية تبويقك", + "notification.mention": "{name} ذكرك", + "notifications.column_settings.alert": "إشعارات سطح المكتب", + "notifications.column_settings.show": "إعرِضها في عمود", + "notifications.column_settings.follow": "متابعُون جُدُد :", + "notifications.column_settings.favourite": "المُفَضَّلة :", + "notifications.column_settings.mention": "الإشارات :", + "notifications.column_settings.reblog": "الترقيّات:", + "video_player.toggle_sound": "تبديل الصوت", + "video_player.toggle_visible": "إظهار / إخفاء الفيديو", + "video_player.expand": "وسّع الفيديو", + "video_player.video_error": "تعذر تشغيل الفيديو" +} diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json new file mode 100644 index 000000000..38dbb8b61 --- /dev/null +++ b/app/javascript/mastodon/locales/bg.json @@ -0,0 +1,163 @@ +{ + "account.block": "Блокирай", + "account.disclaimer": "This user is from another instance. This number may be larger.", + "account.edit_profile": "Редактирай профила си", + "account.follow": "Последвай", + "account.followers": "Последователи", + "account.follows": "Следвам", + "account.follows_you": "Твой последовател", + "account.mention": "Споменаване", + "account.mute": "Mute @{name}", + "account.posts": "Публикации", + "account.report": "Report @{name}", + "account.requested": "В очакване на одобрение", + "account.unblock": "Не блокирай", + "account.unfollow": "Не следвай", + "account.unmute": "Unmute @{name}", + "boost_modal.combo": "You can press {combo} to skip this next time", + "column.blocks": "Blocked users", + "column.community": "Local timeline", + "column.favourites": "Favourites", + "column.follow_requests": "Follow requests", + "column.home": "Начало", + "column.mutes": "Muted users", + "column.notifications": "Известия", + "column.public": "Публичен канал", + "column_back_button.label": "Назад", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Какво си мислиш?", + "compose_form.privacy_disclaimer": "Поверителни публикации ще бъдат изпратени до споменатите потребители на {domains}. Доверяваш ли се на {domainsCount, plural, one {that server} other {those servers}}, че няма да издаде твоята публикация?", + "compose_form.publish": "Раздумай", + "compose_form.sensitive": "Отбележи съдържанието като деликатно", + "compose_form.spoiler": "Скрий текста зад предупреждение", + "compose_form.spoiler_placeholder": "Content warning", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insert emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.hashtag": "There is nothing in this hashtag yet.", + "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.", + "empty_column.home.public_timeline": "the public timeline", + "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up", + "follow_request.authorize": "Authorize", + "follow_request.reject": "Reject", + "getting_started.apps": "Various apps are available", + "getting_started.heading": "Първи стъпки", + "getting_started.open_source_notice": "Mastodon е софтуер с отворен код. Можеш да помогнеш или да докладваш за проблеми в Github: {github}.", + "home.column_settings.advanced": "Advanced", + "home.column_settings.basic": "Basic", + "home.column_settings.filter_regex": "Filter out by regular expressions", + "home.column_settings.show_reblogs": "Show boosts", + "home.column_settings.show_replies": "Show replies", + "home.settings": "Column settings", + "lightbox.close": "Затвори", + "loading_indicator.label": "Зареждане...", + "media_gallery.toggle_visible": "Toggle visibility", + "missing_indicator.label": "Not found", + "navigation_bar.blocks": "Blocked users", + "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.edit_profile": "Редактирай профил", + "navigation_bar.favourites": "Favourites", + "navigation_bar.follow_requests": "Follow requests", + "navigation_bar.info": "Extended information", + "navigation_bar.logout": "Излизане", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "Предпочитания", + "navigation_bar.public_timeline": "Публичен канал", + "notification.favourite": "{name} хареса твоята публикация", + "notification.follow": "{name} те последва", + "notification.reblog": "{name} сподели твоята публикация", + "notifications.clear": "Clear notifications", + "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.alert": "Десктоп известия", + "notifications.column_settings.favourite": "Предпочитани:", + "notifications.column_settings.follow": "Нови последователи:", + "notifications.column_settings.mention": "Споменавания:", + "notifications.column_settings.reblog": "Споделяния:", + "notifications.column_settings.show": "Покажи в колона", + "notifications.column_settings.sound": "Play sound", + "notifications.settings": "Column settings", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Adjust status privacy", + "privacy.direct.long": "Post to mentioned users only", + "privacy.direct.short": "Direct", + "privacy.private.long": "Post to followers only", + "privacy.private.short": "Followers-only", + "privacy.public.long": "Post to public timelines", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Do not show in public timelines", + "privacy.unlisted.short": "Unlisted", + "reply_indicator.cancel": "Отказ", + "report.heading": "New report", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting", + "search.placeholder": "Търсене", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Изтриване", + "status.favourite": "Предпочитани", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "Споменаване", + "status.open": "Expand this status", + "status.reblog": "Споделяне", + "status.reblogged_by": "{name} сподели", + "status.reply": "Отговор", + "status.replyAll": "Reply to thread", + "status.report": "Report @{name}", + "status.sensitive_toggle": "Покажи", + "status.sensitive_warning": "Деликатно съдържание", + "status.show_less": "Show less", + "status.show_more": "Show more", + "tabs_bar.compose": "Съставяне", + "tabs_bar.federated_timeline": "Federated", + "tabs_bar.home": "Начало", + "tabs_bar.local_timeline": "Local", + "tabs_bar.notifications": "Известия", + "upload_area.title": "Drag & drop to upload", + "upload_button.label": "Добави медия", + "upload_form.undo": "Отмяна", + "upload_progress.label": "Uploading...", + "video_player.expand": "Expand video", + "video_player.toggle_sound": "Звук", + "video_player.toggle_visible": "Toggle visibility", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json new file mode 100644 index 000000000..1d79f0cb8 --- /dev/null +++ b/app/javascript/mastodon/locales/de.json @@ -0,0 +1,163 @@ +{ + "account.block": "@{name} blocken", + "account.disclaimer": "Dieser Benutzer ist von einer anderen Instanz. Diese Zahl könnte größer sein.", + "account.edit_profile": "Profil bearbeiten", + "account.follow": "Folgen", + "account.followers": "Folgende", + "account.follows": "Folgt", + "account.follows_you": "Folgt dir", + "account.mention": "@{name} erwähnen", + "account.mute": "@{name} stummschalten", + "account.posts": "Beiträge", + "account.report": "@{name} melden", + "account.requested": "Warte auf Erlaubnis", + "account.unblock": "@{name} entblocken", + "account.unfollow": "Entfolgen", + "account.unmute": "@{name} nicht mehr stummschalten", + "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen", + "column.blocks": "Blockierte Benutzer", + "column.community": "Lokale Zeitleiste", + "column.favourites": "Favoriten", + "column.follow_requests": "Folgeanfragen", + "column.home": "Startseite", + "column.mutes": "Stummgeschaltete Benutzer", + "column.notifications": "Mitteilungen", + "column.public": "Gesamtes bekanntes Netz", + "column_back_button.label": "Zurück", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Worüber möchtest du schreiben?", + "compose_form.privacy_disclaimer": "Dein privater Status wird an die genannten Benutzer auf den Domains {domains} zugestellt. Vertraust du {domainsCount, plural, one {diesem Server} other {diesen Servern}}? Private Beiträge funktionieren nur auf Mastodon-Instanzen. Wenn {domains} {domainsCount, plural, one {keine Mastodon-Instanz ist} other {keine Mastodon-Instanzen sind}}, wird es dort kein Anzeichen geben, dass dein Beitrag privat ist und er könnte geteilt oder anderweitig für unerwünschte Empfänger sichtbar gemacht werden.", + "compose_form.publish": "Tröt", + "compose_form.sensitive": "Medien als heikel markieren", + "compose_form.spoiler": "Text hinter Warnung verbergen", + "compose_form.spoiler_placeholder": "Inhaltswarnung", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Emoji einfügen", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!", + "empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.", + "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Benutzer anzutreffen.", + "empty_column.home.public_timeline": "die öffentliche Zeitleiste", + "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.", + "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Benutzern von anderen Instanzen, um es aufzufüllen.", + "follow_request.authorize": "Erlauben", + "follow_request.reject": "Ablehnen", + "getting_started.apps": "Es sind verschiedene Apps verfügbar", + "getting_started.heading": "Erste Schritte", + "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", + "home.column_settings.advanced": "Fortgeschritten", + "home.column_settings.basic": "Einfach", + "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke", + "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen", + "home.column_settings.show_replies": "Antworten anzeigen", + "home.settings": "Spalteneinstellungen", + "lightbox.close": "Schließen", + "loading_indicator.label": "Lade…", + "media_gallery.toggle_visible": "Sichtbarkeit einstellen", + "missing_indicator.label": "Nicht gefunden", + "navigation_bar.blocks": "Blockierte Benutzer", + "navigation_bar.community_timeline": "Lokale Zeitleiste", + "navigation_bar.edit_profile": "Profil bearbeiten", + "navigation_bar.favourites": "Favoriten", + "navigation_bar.follow_requests": "Folgeanfragen", + "navigation_bar.info": "Erweiterte Informationen", + "navigation_bar.logout": "Abmelden", + "navigation_bar.mutes": "Stummgeschaltete Benutzer", + "navigation_bar.preferences": "Einstellungen", + "navigation_bar.public_timeline": "Föderierte Zeitleiste", + "notification.favourite": "{name} favorisierte deinen Status", + "notification.follow": "{name} folgt dir", + "notification.reblog": "{name} teilte deinen Status", + "notifications.clear": "Mitteilungen beseitigen", + "notifications.clear_confirmation": "Bist du sicher, dass du alle Mitteilungen beseitigen willst?", + "notifications.column_settings.alert": "Desktop-Benachrichtigungen", + "notifications.column_settings.favourite": "Favorisierungen:", + "notifications.column_settings.follow": "Neue Folgende:", + "notifications.column_settings.mention": "Erwähnungen:", + "notifications.column_settings.reblog": "Geteilte Beiträge:", + "notifications.column_settings.show": "In der Spalte anzeigen", + "notifications.column_settings.sound": "Ton abspielen", + "notifications.settings": "Spalteneinstellungen", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Privatsphäre des Status anpassen", + "privacy.direct.long": "Beitrag nur an erwähnte Benutzer", + "privacy.direct.short": "Direkt", + "privacy.private.long": "Beitrag nur an Folgende", + "privacy.private.short": "Privat", + "privacy.public.long": "Beitrag an öffentliche Zeitleisten", + "privacy.public.short": "Öffentlich", + "privacy.unlisted.long": "Nicht in öffentlichen Zeitleisten anzeigen", + "privacy.unlisted.short": "Nicht gelistet", + "reply_indicator.cancel": "Abbrechen", + "report.heading": "Neue Meldung", + "report.placeholder": "Zusätzliche Kommentare", + "report.submit": "Absenden", + "report.target": "Melden", + "search.placeholder": "Suche", + "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Löschen", + "status.favourite": "Favorisieren", + "status.load_more": "Weitere laden", + "status.media_hidden": "Medien versteckt", + "status.mention": "Erwähnen", + "status.open": "Öffnen", + "status.reblog": "Teilen", + "status.reblogged_by": "{name} teilte", + "status.reply": "Antworten", + "status.replyAll": "Auf Thread antworten", + "status.report": "@{name} melden", + "status.sensitive_toggle": "Klicke, um sie zu sehen", + "status.sensitive_warning": "Heikle Inhalte", + "status.show_less": "Weniger anzeigen", + "status.show_more": "Mehr anzeigen", + "tabs_bar.compose": "Schreiben", + "tabs_bar.federated_timeline": "Föderation", + "tabs_bar.home": "Home", + "tabs_bar.local_timeline": "Lokal", + "tabs_bar.notifications": "Mitteilungen", + "upload_area.title": "Hereinziehen zum Hochladen", + "upload_button.label": "Mediendatei hinzufügen", + "upload_form.undo": "Entfernen", + "upload_progress.label": "Lade hoch…", + "video_player.expand": "Videoanzeige vergrößern", + "video_player.toggle_sound": "Ton umschalten", + "video_player.toggle_visible": "Sichtbarkeit umschalten", + "video_player.video_error": "Video konnte nicht abgespielt werden" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json new file mode 100644 index 000000000..ea481e154 --- /dev/null +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -0,0 +1,1068 @@ +[ + { + "descriptors": [ + { + "defaultMessage": "Follow", + "id": "account.follow" + }, + { + "defaultMessage": "Unfollow", + "id": "account.unfollow" + }, + { + "defaultMessage": "Awaiting approval", + "id": "account.requested" + }, + { + "defaultMessage": "Unblock @{name}", + "id": "account.unblock" + }, + { + "defaultMessage": "Unmute @{name}", + "id": "account.unmute" + } + ], + "path": "app/javascript/mastodon/components/account.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Back", + "id": "column_back_button.label" + } + ], + "path": "app/javascript/mastodon/components/column_back_button_slim.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Back", + "id": "column_back_button.label" + } + ], + "path": "app/javascript/mastodon/components/column_back_button.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Load more", + "id": "status.load_more" + } + ], + "path": "app/javascript/mastodon/components/load_more.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Loading...", + "id": "loading_indicator.label" + } + ], + "path": "app/javascript/mastodon/components/loading_indicator.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Toggle visibility", + "id": "media_gallery.toggle_visible" + }, + { + "defaultMessage": "Sensitive content", + "id": "status.sensitive_warning" + }, + { + "defaultMessage": "Media hidden", + "id": "status.media_hidden" + }, + { + "defaultMessage": "Click to view", + "id": "status.sensitive_toggle" + } + ], + "path": "app/javascript/mastodon/components/media_gallery.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Not found", + "id": "missing_indicator.label" + } + ], + "path": "app/javascript/mastodon/components/missing_indicator.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Delete", + "id": "status.delete" + }, + { + "defaultMessage": "Mention @{name}", + "id": "status.mention" + }, + { + "defaultMessage": "Mute @{name}", + "id": "account.mute" + }, + { + "defaultMessage": "Block @{name}", + "id": "account.block" + }, + { + "defaultMessage": "Reply", + "id": "status.reply" + }, + { + "defaultMessage": "Reply to thread", + "id": "status.replyAll" + }, + { + "defaultMessage": "Boost", + "id": "status.reblog" + }, + { + "defaultMessage": "This post cannot be boosted", + "id": "status.cannot_reblog" + }, + { + "defaultMessage": "Favourite", + "id": "status.favourite" + }, + { + "defaultMessage": "Expand this status", + "id": "status.open" + }, + { + "defaultMessage": "Report @{name}", + "id": "status.report" + } + ], + "path": "app/javascript/mastodon/components/status_action_bar.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Show more", + "id": "status.show_more" + }, + { + "defaultMessage": "Show less", + "id": "status.show_less" + } + ], + "path": "app/javascript/mastodon/components/status_content.json" + }, + { + "descriptors": [ + { + "defaultMessage": "{name} boosted", + "id": "status.reblogged_by" + } + ], + "path": "app/javascript/mastodon/components/status.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Toggle sound", + "id": "video_player.toggle_sound" + }, + { + "defaultMessage": "Toggle visibility", + "id": "video_player.toggle_visible" + }, + { + "defaultMessage": "Expand video", + "id": "video_player.expand" + }, + { + "defaultMessage": "Video could not be played", + "id": "video_player.video_error" + }, + { + "defaultMessage": "Sensitive content", + "id": "status.sensitive_warning" + }, + { + "defaultMessage": "Click to view", + "id": "status.sensitive_toggle" + }, + { + "defaultMessage": "Media hidden", + "id": "status.media_hidden" + } + ], + "path": "app/javascript/mastodon/components/video_player.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Delete", + "id": "confirmations.delete.confirm" + }, + { + "defaultMessage": "Are you sure you want to delete this status?", + "id": "confirmations.delete.message" + }, + { + "defaultMessage": "Block", + "id": "confirmations.block.confirm" + }, + { + "defaultMessage": "Mute", + "id": "confirmations.mute.confirm" + }, + { + "defaultMessage": "Are you sure you want to block {name}?", + "id": "confirmations.block.message" + }, + { + "defaultMessage": "Are you sure you want to mute {name}?", + "id": "confirmations.mute.message" + } + ], + "path": "app/javascript/mastodon/containers/status_container.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Block", + "id": "confirmations.block.confirm" + }, + { + "defaultMessage": "Mute", + "id": "confirmations.mute.confirm" + }, + { + "defaultMessage": "Are you sure you want to block {name}?", + "id": "confirmations.block.message" + }, + { + "defaultMessage": "Are you sure you want to mute {name}?", + "id": "confirmations.mute.message" + } + ], + "path": "app/javascript/mastodon/features/account_timeline/containers/header_container.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Mention @{name}", + "id": "account.mention" + }, + { + "defaultMessage": "Edit profile", + "id": "account.edit_profile" + }, + { + "defaultMessage": "Unblock @{name}", + "id": "account.unblock" + }, + { + "defaultMessage": "Unfollow", + "id": "account.unfollow" + }, + { + "defaultMessage": "Unmute @{name}", + "id": "account.unmute" + }, + { + "defaultMessage": "Block @{name}", + "id": "account.block" + }, + { + "defaultMessage": "Mute @{name}", + "id": "account.mute" + }, + { + "defaultMessage": "Follow", + "id": "account.follow" + }, + { + "defaultMessage": "Report @{name}", + "id": "account.report" + }, + { + "defaultMessage": "This user is from another instance. This number may be larger.", + "id": "account.disclaimer" + }, + { + "defaultMessage": "Posts", + "id": "account.posts" + }, + { + "defaultMessage": "Follows", + "id": "account.follows" + }, + { + "defaultMessage": "Followers", + "id": "account.followers" + } + ], + "path": "app/javascript/mastodon/features/account/components/action_bar.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Unfollow", + "id": "account.unfollow" + }, + { + "defaultMessage": "Follow", + "id": "account.follow" + }, + { + "defaultMessage": "Awaiting approval", + "id": "account.requested" + }, + { + "defaultMessage": "Follows you", + "id": "account.follows_you" + } + ], + "path": "app/javascript/mastodon/features/account/components/header.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Blocked users", + "id": "column.blocks" + } + ], + "path": "app/javascript/mastodon/features/blocks/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Local timeline", + "id": "column.community" + }, + { + "defaultMessage": "The local timeline is empty. Write something publicly to get the ball rolling!", + "id": "empty_column.community" + } + ], + "path": "app/javascript/mastodon/features/community_timeline/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "What is on your mind?", + "id": "compose_form.placeholder" + }, + { + "defaultMessage": "Content warning", + "id": "compose_form.spoiler_placeholder" + }, + { + "defaultMessage": "Toot", + "id": "compose_form.publish" + } + ], + "path": "app/javascript/mastodon/features/compose/components/compose_form.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Insert emoji", + "id": "emoji_button.label" + }, + { + "defaultMessage": "Search...", + "id": "emoji_button.search" + }, + { + "defaultMessage": "People", + "id": "emoji_button.people" + }, + { + "defaultMessage": "Nature", + "id": "emoji_button.nature" + }, + { + "defaultMessage": "Food & Drink", + "id": "emoji_button.food" + }, + { + "defaultMessage": "Activity", + "id": "emoji_button.activity" + }, + { + "defaultMessage": "Travel & Places", + "id": "emoji_button.travel" + }, + { + "defaultMessage": "Objects", + "id": "emoji_button.objects" + }, + { + "defaultMessage": "Symbols", + "id": "emoji_button.symbols" + }, + { + "defaultMessage": "Flags", + "id": "emoji_button.flags" + } + ], + "path": "app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Edit profile", + "id": "navigation_bar.edit_profile" + } + ], + "path": "app/javascript/mastodon/features/compose/components/navigation_bar.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Public", + "id": "privacy.public.short" + }, + { + "defaultMessage": "Post to public timelines", + "id": "privacy.public.long" + }, + { + "defaultMessage": "Unlisted", + "id": "privacy.unlisted.short" + }, + { + "defaultMessage": "Do not show in public timelines", + "id": "privacy.unlisted.long" + }, + { + "defaultMessage": "Followers-only", + "id": "privacy.private.short" + }, + { + "defaultMessage": "Post to followers only", + "id": "privacy.private.long" + }, + { + "defaultMessage": "Direct", + "id": "privacy.direct.short" + }, + { + "defaultMessage": "Post to mentioned users only", + "id": "privacy.direct.long" + }, + { + "defaultMessage": "Adjust status privacy", + "id": "privacy.change" + } + ], + "path": "app/javascript/mastodon/features/compose/components/privacy_dropdown.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Cancel", + "id": "reply_indicator.cancel" + } + ], + "path": "app/javascript/mastodon/features/compose/components/reply_indicator.json" + }, + { + "descriptors": [ + { + "defaultMessage": "{count, number} {count, plural, one {result} other {results}}", + "id": "search_results.total" + } + ], + "path": "app/javascript/mastodon/features/compose/components/search_results.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Search", + "id": "search.placeholder" + } + ], + "path": "app/javascript/mastodon/features/compose/components/search.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Add media", + "id": "upload_button.label" + } + ], + "path": "app/javascript/mastodon/features/compose/components/upload_button.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Undo", + "id": "upload_form.undo" + } + ], + "path": "app/javascript/mastodon/features/compose/components/upload_form.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Uploading...", + "id": "upload_progress.label" + } + ], + "path": "app/javascript/mastodon/features/compose/components/upload_progress.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Mark media as sensitive", + "id": "compose_form.sensitive" + } + ], + "path": "app/javascript/mastodon/features/compose/containers/sensitive_button_container.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Hide text behind warning", + "id": "compose_form.spoiler" + } + ], + "path": "app/javascript/mastodon/features/compose/containers/spoiler_button_container.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "id": "compose_form.lock_disclaimer" + }, + { + "defaultMessage": "locked", + "id": "compose_form.lock_disclaimer.lock" + }, + { + "defaultMessage": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", + "id": "compose_form.privacy_disclaimer" + } + ], + "path": "app/javascript/mastodon/features/compose/containers/warning_container.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Getting started", + "id": "getting_started.heading" + }, + { + "defaultMessage": "Federated timeline", + "id": "navigation_bar.public_timeline" + }, + { + "defaultMessage": "Local timeline", + "id": "navigation_bar.community_timeline" + }, + { + "defaultMessage": "Preferences", + "id": "navigation_bar.preferences" + }, + { + "defaultMessage": "Logout", + "id": "navigation_bar.logout" + } + ], + "path": "app/javascript/mastodon/features/compose/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Favourites", + "id": "column.favourites" + } + ], + "path": "app/javascript/mastodon/features/favourited_statuses/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Authorize", + "id": "follow_request.authorize" + }, + { + "defaultMessage": "Reject", + "id": "follow_request.reject" + } + ], + "path": "app/javascript/mastodon/features/follow_requests/components/account_authorize.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Follow requests", + "id": "column.follow_requests" + } + ], + "path": "app/javascript/mastodon/features/follow_requests/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Getting started", + "id": "getting_started.heading" + }, + { + "defaultMessage": "Federated timeline", + "id": "navigation_bar.public_timeline" + }, + { + "defaultMessage": "Navigation", + "id": "column_subheading.navigation" + }, + { + "defaultMessage": "Settings", + "id": "column_subheading.settings" + }, + { + "defaultMessage": "Local timeline", + "id": "navigation_bar.community_timeline" + }, + { + "defaultMessage": "Preferences", + "id": "navigation_bar.preferences" + }, + { + "defaultMessage": "Follow requests", + "id": "navigation_bar.follow_requests" + }, + { + "defaultMessage": "Logout", + "id": "navigation_bar.logout" + }, + { + "defaultMessage": "Favourites", + "id": "navigation_bar.favourites" + }, + { + "defaultMessage": "Blocked users", + "id": "navigation_bar.blocks" + }, + { + "defaultMessage": "Muted users", + "id": "navigation_bar.mutes" + }, + { + "defaultMessage": "Extended information", + "id": "navigation_bar.info" + }, + { + "defaultMessage": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.", + "id": "getting_started.open_source_notice" + }, + { + "defaultMessage": "Various apps are available", + "id": "getting_started.apps" + } + ], + "path": "app/javascript/mastodon/features/getting_started/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "There is nothing in this hashtag yet.", + "id": "empty_column.hashtag" + } + ], + "path": "app/javascript/mastodon/features/hashtag_timeline/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Filter out by regular expressions", + "id": "home.column_settings.filter_regex" + }, + { + "defaultMessage": "Column settings", + "id": "home.settings" + }, + { + "defaultMessage": "Basic", + "id": "home.column_settings.basic" + }, + { + "defaultMessage": "Show boosts", + "id": "home.column_settings.show_reblogs" + }, + { + "defaultMessage": "Show replies", + "id": "home.column_settings.show_replies" + }, + { + "defaultMessage": "Advanced", + "id": "home.column_settings.advanced" + } + ], + "path": "app/javascript/mastodon/features/home_timeline/components/column_settings.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Home", + "id": "column.home" + }, + { + "defaultMessage": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.", + "id": "empty_column.home" + }, + { + "defaultMessage": "the public timeline", + "id": "empty_column.home.public_timeline" + } + ], + "path": "app/javascript/mastodon/features/home_timeline/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Muted users", + "id": "column.mutes" + } + ], + "path": "app/javascript/mastodon/features/mutes/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Clear notifications", + "id": "notifications.clear" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/clear_column_button.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Column settings", + "id": "notifications.settings" + }, + { + "defaultMessage": "Desktop notifications", + "id": "notifications.column_settings.alert" + }, + { + "defaultMessage": "Show in column", + "id": "notifications.column_settings.show" + }, + { + "defaultMessage": "Play sound", + "id": "notifications.column_settings.sound" + }, + { + "defaultMessage": "New followers:", + "id": "notifications.column_settings.follow" + }, + { + "defaultMessage": "Favourites:", + "id": "notifications.column_settings.favourite" + }, + { + "defaultMessage": "Mentions:", + "id": "notifications.column_settings.mention" + }, + { + "defaultMessage": "Boosts:", + "id": "notifications.column_settings.reblog" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/column_settings.json" + }, + { + "descriptors": [ + { + "defaultMessage": "{name} followed you", + "id": "notification.follow" + }, + { + "defaultMessage": "{name} favourited your status", + "id": "notification.favourite" + }, + { + "defaultMessage": "{name} boosted your status", + "id": "notification.reblog" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/notification.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Notifications", + "id": "column.notifications" + }, + { + "defaultMessage": "Are you sure you want to permanently clear all your notifications?", + "id": "notifications.clear_confirmation" + }, + { + "defaultMessage": "Clear notifications", + "id": "notifications.clear" + }, + { + "defaultMessage": "You don't have any notifications yet. Interact with others to start the conversation.", + "id": "empty_column.notifications" + } + ], + "path": "app/javascript/mastodon/features/notifications/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Federated timeline", + "id": "column.public" + }, + { + "defaultMessage": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up", + "id": "empty_column.public" + } + ], + "path": "app/javascript/mastodon/features/public_timeline/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "New report", + "id": "report.heading" + }, + { + "defaultMessage": "Additional comments", + "id": "report.placeholder" + }, + { + "defaultMessage": "Submit", + "id": "report.submit" + }, + { + "defaultMessage": "Reporting", + "id": "report.target" + } + ], + "path": "app/javascript/mastodon/features/report/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Delete", + "id": "status.delete" + }, + { + "defaultMessage": "Mention @{name}", + "id": "status.mention" + }, + { + "defaultMessage": "Reply", + "id": "status.reply" + }, + { + "defaultMessage": "Boost", + "id": "status.reblog" + }, + { + "defaultMessage": "This post cannot be boosted", + "id": "status.cannot_reblog" + }, + { + "defaultMessage": "Favourite", + "id": "status.favourite" + }, + { + "defaultMessage": "Report @{name}", + "id": "status.report" + } + ], + "path": "app/javascript/mastodon/features/status/components/action_bar.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Delete", + "id": "confirmations.delete.confirm" + }, + { + "defaultMessage": "Are you sure you want to delete this status?", + "id": "confirmations.delete.message" + } + ], + "path": "app/javascript/mastodon/features/status/index.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Boost", + "id": "status.reblog" + }, + { + "defaultMessage": "You can press {combo} to skip this next time", + "id": "boost_modal.combo" + } + ], + "path": "app/javascript/mastodon/features/ui/components/boost_modal.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Cancel", + "id": "confirmation_modal.cancel" + } + ], + "path": "app/javascript/mastodon/features/ui/components/confirmation_modal.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Close", + "id": "lightbox.close" + } + ], + "path": "app/javascript/mastodon/features/ui/components/media_modal.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Home", + "id": "column.home" + }, + { + "defaultMessage": "Notifications", + "id": "column.notifications" + }, + { + "defaultMessage": "Local timeline", + "id": "column.community" + }, + { + "defaultMessage": "Federated timeline", + "id": "column.public" + }, + { + "defaultMessage": "Welcome to Mastodon!", + "id": "onboarding.page_one.welcome" + }, + { + "defaultMessage": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "id": "onboarding.page_one.federation" + }, + { + "defaultMessage": "You are on {domain}, so your full handle is {handle}", + "id": "onboarding.page_one.handle" + }, + { + "defaultMessage": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "id": "onboarding.page_two.compose" + }, + { + "defaultMessage": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "id": "onboarding.page_three.search" + }, + { + "defaultMessage": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "id": "onboarding.page_three.profile" + }, + { + "defaultMessage": "The home timeline shows posts from people you follow.", + "id": "onboarding.page_four.home" + }, + { + "defaultMessage": "The notifications column shows when someone interacts with you.", + "id": "onboarding.page_four.notifications" + }, + { + "defaultMessage": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "id": "onboarding.page_five.public_timelines" + }, + { + "defaultMessage": "Your instance's admin is {admin}.", + "id": "onboarding.page_six.admin" + }, + { + "defaultMessage": "Please read {domain}'s {guidelines}!", + "id": "onboarding.page_six.read_guidelines" + }, + { + "defaultMessage": "community guidelines", + "id": "onboarding.page_six.guidelines" + }, + { + "defaultMessage": "Almost done...", + "id": "onboarding.page_six.almost_done" + }, + { + "defaultMessage": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "id": "onboarding.page_six.github" + }, + { + "defaultMessage": "There are {apps} available for iOS, Android and other platforms.", + "id": "onboarding.page_six.apps_available" + }, + { + "defaultMessage": "mobile apps", + "id": "onboarding.page_six.various_app" + }, + { + "defaultMessage": "Bon Appetoot!", + "id": "onboarding.page_six.appetoot" + }, + { + "defaultMessage": "Next", + "id": "onboarding.next" + }, + { + "defaultMessage": "Done", + "id": "onboarding.done" + }, + { + "defaultMessage": "Skip", + "id": "onboarding.skip" + } + ], + "path": "app/javascript/mastodon/features/ui/components/onboarding_modal.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Compose", + "id": "tabs_bar.compose" + }, + { + "defaultMessage": "Home", + "id": "tabs_bar.home" + }, + { + "defaultMessage": "Notifications", + "id": "tabs_bar.notifications" + }, + { + "defaultMessage": "Local", + "id": "tabs_bar.local_timeline" + }, + { + "defaultMessage": "Federated", + "id": "tabs_bar.federated_timeline" + } + ], + "path": "app/javascript/mastodon/features/ui/components/tabs_bar.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Drag & drop to upload", + "id": "upload_area.title" + } + ], + "path": "app/javascript/mastodon/features/ui/components/upload_area.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Close", + "id": "lightbox.close" + } + ], + "path": "app/javascript/mastodon/features/ui/components/video_modal.json" + } +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json new file mode 100644 index 000000000..797a1caf2 --- /dev/null +++ b/app/javascript/mastodon/locales/en.json @@ -0,0 +1,163 @@ +{ + "account.block": "Block @{name}", + "account.disclaimer": "This user is from another instance. This number may be larger.", + "account.edit_profile": "Edit profile", + "account.follow": "Follow", + "account.followers": "Followers", + "account.follows": "Follows", + "account.follows_you": "Follows you", + "account.mention": "Mention @{name}", + "account.mute": "Mute @{name}", + "account.posts": "Posts", + "account.report": "Report @{name}", + "account.requested": "Awaiting approval", + "account.unblock": "Unblock @{name}", + "account.unfollow": "Unfollow", + "account.unmute": "Unmute @{name}", + "boost_modal.combo": "You can press {combo} to skip this next time", + "column.blocks": "Blocked users", + "column.community": "Local timeline", + "column.favourites": "Favourites", + "column.follow_requests": "Follow requests", + "column.home": "Home", + "column.mutes": "Muted users", + "column.notifications": "Notifications", + "column.public": "Federated timeline", + "column_back_button.label": "Back", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "What is on your mind?", + "compose_form.privacy_disclaimer": "Your post will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is not a public post, and it may be boosted or otherwise made visible to unintended recipients.", + "compose_form.publish": "Toot", + "compose_form.sensitive": "Mark media as sensitive", + "compose_form.spoiler": "Hide text behind warning", + "compose_form.spoiler_placeholder": "Content warning", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insert emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.hashtag": "There is nothing in this hashtag yet.", + "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.", + "empty_column.home.public_timeline": "the public timeline", + "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up", + "follow_request.authorize": "Authorize", + "follow_request.reject": "Reject", + "getting_started.apps": "Various apps are available", + "getting_started.heading": "Getting started", + "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.", + "home.column_settings.advanced": "Advanced", + "home.column_settings.basic": "Basic", + "home.column_settings.filter_regex": "Filter out by regular expressions", + "home.column_settings.show_reblogs": "Show boosts", + "home.column_settings.show_replies": "Show replies", + "home.settings": "Column settings", + "lightbox.close": "Close", + "loading_indicator.label": "Loading...", + "media_gallery.toggle_visible": "Toggle visibility", + "missing_indicator.label": "Not found", + "navigation_bar.blocks": "Blocked users", + "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.edit_profile": "Edit profile", + "navigation_bar.favourites": "Favourites", + "navigation_bar.follow_requests": "Follow requests", + "navigation_bar.info": "Extended information", + "navigation_bar.logout": "Logout", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "Preferences", + "navigation_bar.public_timeline": "Federated timeline", + "notification.favourite": "{name} favourited your status", + "notification.follow": "{name} followed you", + "notification.reblog": "{name} boosted your status", + "notifications.clear": "Clear notifications", + "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.alert": "Desktop notifications", + "notifications.column_settings.favourite": "Favourites:", + "notifications.column_settings.follow": "New followers:", + "notifications.column_settings.mention": "Mentions:", + "notifications.column_settings.reblog": "Boosts:", + "notifications.column_settings.show": "Show in column", + "notifications.column_settings.sound": "Play sound", + "notifications.settings": "Column settings", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Adjust status privacy", + "privacy.direct.long": "Post to mentioned users only", + "privacy.direct.short": "Direct", + "privacy.private.long": "Post to followers only", + "privacy.private.short": "Followers-only", + "privacy.public.long": "Post to public timelines", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Do not post to public timelines", + "privacy.unlisted.short": "Unlisted", + "reply_indicator.cancel": "Cancel", + "report.heading": "New report", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting", + "search.placeholder": "Search", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Delete", + "status.favourite": "Favourite", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "Mention @{name}", + "status.open": "Expand this status", + "status.reblog": "Boost", + "status.reblogged_by": "{name} boosted", + "status.reply": "Reply", + "status.replyAll": "Reply to thread", + "status.report": "Report @{name}", + "status.sensitive_toggle": "Click to view", + "status.sensitive_warning": "Sensitive content", + "status.show_less": "Show less", + "status.show_more": "Show more", + "tabs_bar.compose": "Compose", + "tabs_bar.federated_timeline": "Federated", + "tabs_bar.home": "Home", + "tabs_bar.local_timeline": "Local", + "tabs_bar.notifications": "Notifications", + "upload_area.title": "Drag & drop to upload", + "upload_button.label": "Add media", + "upload_form.undo": "Undo", + "upload_progress.label": "Uploading...", + "video_player.expand": "Expand video", + "video_player.toggle_sound": "Toggle sound", + "video_player.toggle_visible": "Toggle visibility", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json new file mode 100644 index 000000000..b71088490 --- /dev/null +++ b/app/javascript/mastodon/locales/eo.json @@ -0,0 +1,163 @@ +{ + "account.block": "Bloki @{name}", + "account.disclaimer": "This user is from another instance. This number may be larger.", + "account.edit_profile": "Redakti la profilon", + "account.follow": "Sekvi", + "account.followers": "Sekvantoj", + "account.follows": "Sekvatoj", + "account.follows_you": "Sekvas vin", + "account.mention": "Mencii @{name}", + "account.mute": "Mute @{name}", + "account.posts": "Mesaĝoj", + "account.report": "Report @{name}", + "account.requested": "Atendas aprobon", + "account.unblock": "Malbloki @{name}", + "account.unfollow": "Malsekvi", + "account.unmute": "Unmute @{name}", + "boost_modal.combo": "You can press {combo} to skip this next time", + "column.blocks": "Blocked users", + "column.community": "Loka tempolinio", + "column.favourites": "Favourites", + "column.follow_requests": "Follow requests", + "column.home": "Hejmo", + "column.mutes": "Muted users", + "column.notifications": "Sciigoj", + "column.public": "Fratara tempolinio", + "column_back_button.label": "Reveni", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Pri kio vi pensas?", + "compose_form.privacy_disclaimer": "Via privata mesaĝo estos sendita nur al menciitaj uzantoj en {domains}. Ĉu vi fidas {domainsCount, plural, one {tiun servilon} other {tiujn servilojn}}? Mesaĝa privateco funkcias nur en aperaĵoj de Mastodon. Se {domains} {domainsCount, plural, one {ne estas aperaĵo de Mastodon} other {ne estas aperaĵoj de Mastodon}}, estos neniu indiko ke via mesaĝo estas privata, kaj ĝi povus esti diskonigita aŭ videbligita al necelitaj ricevantoj.", + "compose_form.publish": "Hup", + "compose_form.sensitive": "Marki ke la enhavo estas tikla", + "compose_form.spoiler": "Kaŝi la tekston malantaŭ averto", + "compose_form.spoiler_placeholder": "Content warning", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insert emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.hashtag": "There is nothing in this hashtag yet.", + "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.", + "empty_column.home.public_timeline": "the public timeline", + "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up", + "follow_request.authorize": "Authorize", + "follow_request.reject": "Reject", + "getting_started.apps": "Various apps are available", + "getting_started.heading": "Por komenci", + "getting_started.open_source_notice": "Mastodon estas malfermitkoda programo. Vi povas kontribui aŭ raporti problemojn en github je {github}. {apps}.", + "home.column_settings.advanced": "Advanced", + "home.column_settings.basic": "Basic", + "home.column_settings.filter_regex": "Filter out by regular expressions", + "home.column_settings.show_reblogs": "Show boosts", + "home.column_settings.show_replies": "Show replies", + "home.settings": "Column settings", + "lightbox.close": "Fermi", + "loading_indicator.label": "Ŝarĝanta...", + "media_gallery.toggle_visible": "Toggle visibility", + "missing_indicator.label": "Not found", + "navigation_bar.blocks": "Blocked users", + "navigation_bar.community_timeline": "Loka tempolinio", + "navigation_bar.edit_profile": "Redakti la profilon", + "navigation_bar.favourites": "Favourites", + "navigation_bar.follow_requests": "Follow requests", + "navigation_bar.info": "Extended information", + "navigation_bar.logout": "Elsaluti", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "Preferoj", + "navigation_bar.public_timeline": "Fratara tempolinio", + "notification.favourite": "{name} favoris vian mesaĝon", + "notification.follow": "{name} sekvis vin", + "notification.reblog": "{name} diskonigis vian mesaĝon", + "notifications.clear": "Clear notifications", + "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.alert": "Retumilaj atentigoj", + "notifications.column_settings.favourite": "Favoroj:", + "notifications.column_settings.follow": "Novaj sekvantoj:", + "notifications.column_settings.mention": "Mencioj:", + "notifications.column_settings.reblog": "Diskonigoj:", + "notifications.column_settings.show": "Montri en kolono", + "notifications.column_settings.sound": "Play sound", + "notifications.settings": "Column settings", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Adjust status privacy", + "privacy.direct.long": "Post to mentioned users only", + "privacy.direct.short": "Direct", + "privacy.private.long": "Post to followers only", + "privacy.private.short": "Followers-only", + "privacy.public.long": "Post to public timelines", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Do not show in public timelines", + "privacy.unlisted.short": "Unlisted", + "reply_indicator.cancel": "Rezigni", + "report.heading": "New report", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting", + "search.placeholder": "Serĉi", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Forigi", + "status.favourite": "Favori", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "Mencii @{name}", + "status.open": "Expand this status", + "status.reblog": "Diskonigi", + "status.reblogged_by": "{name} diskonigita", + "status.reply": "Respondi", + "status.replyAll": "Reply to thread", + "status.report": "Report @{name}", + "status.sensitive_toggle": "Alklaki por vidi", + "status.sensitive_warning": "Tikla enhavo", + "status.show_less": "Show less", + "status.show_more": "Show more", + "tabs_bar.compose": "Ekskribi", + "tabs_bar.federated_timeline": "Federated", + "tabs_bar.home": "Hejmo", + "tabs_bar.local_timeline": "Local", + "tabs_bar.notifications": "Sciigoj", + "upload_area.title": "Drag & drop to upload", + "upload_button.label": "Aldoni enhavaĵon", + "upload_form.undo": "Malfari", + "upload_progress.label": "Uploading...", + "video_player.expand": "Expand video", + "video_player.toggle_sound": "Aktivigi sonojn", + "video_player.toggle_visible": "Toggle visibility", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json new file mode 100644 index 000000000..c023a4b7e --- /dev/null +++ b/app/javascript/mastodon/locales/es.json @@ -0,0 +1,163 @@ +{ + "account.block": "Bloquear", + "account.disclaimer": "This user is from another instance. This number may be larger.", + "account.edit_profile": "Editar perfil", + "account.follow": "Seguir", + "account.followers": "Seguidores", + "account.follows": "Seguir", + "account.follows_you": "Te sigue", + "account.mention": "Mencionar", + "account.mute": "Silenciar", + "account.posts": "Publicaciones", + "account.report": "Report @{name}", + "account.requested": "Esperando aprobación", + "account.unblock": "Desbloquear", + "account.unfollow": "Dejar de seguir", + "account.unmute": "Unmute @{name}", + "boost_modal.combo": "You can press {combo} to skip this next time", + "column.blocks": "Usuarios bloqueados", + "column.community": "Historia local", + "column.favourites": "Favoritos", + "column.follow_requests": "Solicitudes para seguirte", + "column.home": "Inicio", + "column.mutes": "Usuarios silenciados", + "column.notifications": "Notificaciones", + "column.public": "Historia federada", + "column_back_button.label": "Atrás", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "¿En qué estás pensando?", + "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", + "compose_form.publish": "Tootear", + "compose_form.sensitive": "Marcar contenido como sensible", + "compose_form.spoiler": "Ocultar texto tras advertencia", + "compose_form.spoiler_placeholder": "Advertencia de contenido", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insertar emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.hashtag": "There is nothing in this hashtag yet.", + "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.", + "empty_column.home.public_timeline": "the public timeline", + "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up", + "follow_request.authorize": "Authorize", + "follow_request.reject": "Reject", + "getting_started.apps": "Various apps are available", + "getting_started.heading": "Primeros pasos", + "getting_started.open_source_notice": "Mastodon es software libre. Puedes contribuir o reportar errores en {github}. {apps}.", + "home.column_settings.advanced": "Advanced", + "home.column_settings.basic": "Basic", + "home.column_settings.filter_regex": "Filter out by regular expressions", + "home.column_settings.show_reblogs": "Show boosts", + "home.column_settings.show_replies": "Show replies", + "home.settings": "Column settings", + "lightbox.close": "Cerrar", + "loading_indicator.label": "Cargando...", + "media_gallery.toggle_visible": "Toggle visibility", + "missing_indicator.label": "Not found", + "navigation_bar.blocks": "Usuarios bloqueados", + "navigation_bar.community_timeline": "Historia local", + "navigation_bar.edit_profile": "Editar perfil", + "navigation_bar.favourites": "Favoritos", + "navigation_bar.follow_requests": "Solicitudes para seguirte", + "navigation_bar.info": "Información adicional", + "navigation_bar.logout": "Cerrar sesión", + "navigation_bar.mutes": "Usuarios silenciados", + "navigation_bar.preferences": "Preferencias", + "navigation_bar.public_timeline": "Historia federada", + "notification.favourite": "{name} marcó tu estado como favorito", + "notification.follow": "{name} te empezó a seguir", + "notification.reblog": "{name} ha retooteado tu estado", + "notifications.clear": "Clear notifications", + "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.alert": "Notificaciones de escritorio", + "notifications.column_settings.favourite": "Favoritos:", + "notifications.column_settings.follow": "Nuevos seguidores:", + "notifications.column_settings.mention": "Menciones:", + "notifications.column_settings.reblog": "Retoots:", + "notifications.column_settings.show": "Mostrar en columna", + "notifications.column_settings.sound": "Play sound", + "notifications.settings": "Column settings", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Ajustar privacidad", + "privacy.direct.long": "Sólo mostrar a los usuarios mencionados", + "privacy.direct.short": "Directo", + "privacy.private.long": "Sólo mostrar a seguidores", + "privacy.private.short": "Privado", + "privacy.public.long": "Mostrar en la historia federada", + "privacy.public.short": "Público", + "privacy.unlisted.long": "No mostrar en la historia federada", + "privacy.unlisted.short": "Sin federar", + "reply_indicator.cancel": "Cancelar", + "report.heading": "New report", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting", + "search.placeholder": "Buscar", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Borrar", + "status.favourite": "Favorito", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "Mencionar", + "status.open": "Expandir estado", + "status.reblog": "Retoot", + "status.reblogged_by": "Retooteado por {name}", + "status.reply": "Responder", + "status.replyAll": "Reply to thread", + "status.report": "Reportar", + "status.sensitive_toggle": "Click para ver", + "status.sensitive_warning": "Contenido sensible", + "status.show_less": "Mostrar menos", + "status.show_more": "Mostrar más", + "tabs_bar.compose": "Redactar", + "tabs_bar.federated_timeline": "Federated", + "tabs_bar.home": "Inicio", + "tabs_bar.local_timeline": "Local", + "tabs_bar.notifications": "Notificaciones", + "upload_area.title": "Drag & drop to upload", + "upload_button.label": "Subir multimedia", + "upload_form.undo": "Deshacer", + "upload_progress.label": "Uploading...", + "video_player.expand": "Expand video", + "video_player.toggle_sound": "Act/Desac. sonido", + "video_player.toggle_visible": "Toggle visibility", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json new file mode 100644 index 000000000..7f6585884 --- /dev/null +++ b/app/javascript/mastodon/locales/fa.json @@ -0,0 +1,163 @@ +{ + "account.block": "@{name} را مسدود کن", + "account.disclaimer": "این کاربر عضو سرور متفاوتی است. شاید عدد واقعی بیشتر از این باشد.", + "account.edit_profile": "ویرایش نمایه", + "account.follow": "پی بگیرید", + "account.followers": "پیگیران", + "account.follows": "پی میگیرد", + "account.follows_you": "پیگیر شماست", + "account.mention": "نامبردن از @{name}", + "account.mute": "بیصدا کردن @{name}", + "account.posts": "نوشتهها", + "account.report": "گزارش @{name}", + "account.requested": "در انتظار پذیرش", + "account.unblock": "رفع انسداد @{name}", + "account.unfollow": "پایان پیگیری", + "account.unmute": "باصدا کردن @{name}", + "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید", + "column.blocks": "کاربران مسدودشده", + "column.community": "نوشتههای محلی", + "column.favourites": "پسندیدهها", + "column.follow_requests": "درخواستهای پیگیری", + "column.home": "خانه", + "column.mutes": "کاربران بیصداشده", + "column.notifications": "اعلانها", + "column.public": "نوشتههای همهجا", + "column_back_button.label": "بازگشت", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "تازه چه خبر؟", + "compose_form.privacy_disclaimer": "نوشتهٔ خصوصی شما به کاربران نامبردهشده در {domains} فرستاده میشود. آیا به {domainsCount, plural, one {آن سرور} other {آن سرورها}} اعتماد دارید؟ تنظیمات حریم خصوصی نوشتهها تنها در سرورهای ماستدون کار میکند. اگر {domains} {domainsCount, plural, one {یک سرور ماستدون نباشد} other {سرورهای ماستدون نباشند}}، اشارهای به خصوصیبودن نوشتهٔ شما نخواهد شد و شاید نوشتهٔ شما همرسان شود یا برای کاربرانی که نمیخواهید نمایش یابد.", + "compose_form.publish": "بوق", + "compose_form.sensitive": "تصاویر حساس هستند", + "compose_form.spoiler": "نوشته را پشت هشدار پنهان کنید", + "compose_form.spoiler_placeholder": "هشدار محتوا", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "فعالیت", + "emoji_button.flags": "پرچمها", + "emoji_button.food": "غذا و نوشیدنی", + "emoji_button.label": "افزودن شکلک", + "emoji_button.nature": "طبیعت", + "emoji_button.objects": "اشیا", + "emoji_button.people": "مردم", + "emoji_button.search": "جستجو...", + "emoji_button.symbols": "نمادها", + "emoji_button.travel": "سفر و مکان", + "empty_column.community": "فهرست نوشتههای محلی خالی است. چیزی بنویسید تا چرخش بچرخد!", + "empty_column.hashtag": "هنوز هیچ چیزی با این هشتگ نیست.", + "empty_column.home": "شما هنوز پیگیر کسی نیستید. {public} را ببینید یا چیزی را جستجو کنید تا کاربران دیگر را ببینید.", + "empty_column.home.public_timeline": "فهرست نوشتههای همهجا", + "empty_column.notifications": "هنوز هیچ اعلانی ندارید. به نوشتههای دیگران واکنش نشان دهید تا گفتگو آغاز شود.", + "empty_column.public": "اینجا هنوز چیزی نیست! خودتان چیزی بنویسید یا کاربران دیگر را پی بگیرید تا اینجا پر شود", + "follow_request.authorize": "اجازه دهید", + "follow_request.reject": "اجازه ندهید", + "getting_started.apps": "اپهای گوناگونی در دسترساند", + "getting_started.heading": "آغاز کنید", + "getting_started.open_source_notice": "ماستدون یک نرمافزار آزاد است. میتوانید در ساخت آن مشارکت کنید یا مشکلاتش را در {github} گزارش دهید. {apps}.", + "home.column_settings.advanced": "پیشرفته", + "home.column_settings.basic": "اصلی", + "home.column_settings.filter_regex": "با عبارتهای باقاعده فیلتر کنید", + "home.column_settings.show_reblogs": "نمایش بازبوقها", + "home.column_settings.show_replies": "نمایش پاسخها", + "home.settings": "تنظیمات ستون", + "lightbox.close": "بستن", + "loading_indicator.label": "بارگیری...", + "media_gallery.toggle_visible": "تغییر پیدایی", + "missing_indicator.label": "پیدا نشد", + "navigation_bar.blocks": "کاربران مسدودشده", + "navigation_bar.community_timeline": "نوشتههای محلی", + "navigation_bar.edit_profile": "ویرایش نمایه", + "navigation_bar.favourites": "پسندیدهها", + "navigation_bar.follow_requests": "درخواستهای پیگیری", + "navigation_bar.info": "اطلاعات تکمیلی", + "navigation_bar.logout": "خروج", + "navigation_bar.mutes": "کاربران بیصداشده", + "navigation_bar.preferences": "ترجیحات", + "navigation_bar.public_timeline": "نوشتههای همهجا", + "notification.favourite": "{name} نوشتهٔ شما را پسندید", + "notification.follow": "{name} پیگیر شما شد", + "notification.reblog": "{name} نوشتهٔ شما را بازبوقید", + "notifications.clear": "پاککردن اعلانها", + "notifications.clear_confirmation": "واقعاً میخواهید همهٔ اعلانهایتان را برای همیشه پاک کنید؟", + "notifications.column_settings.alert": "اعلان در کامپیوتر", + "notifications.column_settings.favourite": "پسندیدهها:", + "notifications.column_settings.follow": "پیگیران تازه:", + "notifications.column_settings.mention": "نامبردنها:", + "notifications.column_settings.reblog": "بازبوقها:", + "notifications.column_settings.show": "در ستون نشان بده", + "notifications.column_settings.sound": "صدا را پخش کن", + "notifications.settings": "تنظیمات ستون", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "تنظیم حریم خصوصی نوشتهها", + "privacy.direct.long": "تنها به کاربران نامبردهشده نشان بده", + "privacy.direct.short": "مستقیم", + "privacy.private.long": "تنها به پیگیران نشان بده", + "privacy.private.short": "خصوصی", + "privacy.public.long": "در فهرست نوشتههای عمومی نشان بده", + "privacy.public.short": "عمومی", + "privacy.unlisted.long": "در فهرست نوشتههای همهجا نشان نده", + "privacy.unlisted.short": "فهرستنشده", + "reply_indicator.cancel": "لغو", + "report.heading": "گزارش تازه", + "report.placeholder": "توضیح اضافه", + "report.submit": "بفرست", + "report.target": "گزارشدادن", + "search.placeholder": "جستجو", + "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}", + "status.cannot_reblog": "این نوشته را نمیشود بازبوقید", + "status.delete": "پاککردن", + "status.favourite": "پسندیدن", + "status.load_more": "بیشتر نشان بده", + "status.media_hidden": "تصویر پنهان شده", + "status.mention": "از @{name} نام ببرید", + "status.open": "این نوشته را باز کن", + "status.reblog": "بوق", + "status.reblogged_by": "{name} بازبوقید", + "status.reply": "پاسخ", + "status.replyAll": "به نوشته پاسخ دهید", + "status.report": "@{name} را گزارش دهید", + "status.sensitive_toggle": "برای دیدن کلیک کنید", + "status.sensitive_warning": "محتوای حساس", + "status.show_less": "نهفتن", + "status.show_more": "نمایش", + "tabs_bar.compose": "بنویسید", + "tabs_bar.federated_timeline": "همگانی", + "tabs_bar.home": "خانه", + "tabs_bar.local_timeline": "محلی", + "tabs_bar.notifications": "اعلانها", + "upload_area.title": "برای بارگذاری به اینجا بکشید", + "upload_button.label": "افزودن تصویر", + "upload_form.undo": "واگردانی", + "upload_progress.label": "بارگذاری...", + "video_player.expand": "بازکردن ویدیو", + "video_player.toggle_sound": "تغییر صداداری", + "video_player.toggle_visible": "تغییر پیدایی", + "video_player.video_error": "ویدیو نمیتواند پخش شود" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json new file mode 100644 index 000000000..148a371ae --- /dev/null +++ b/app/javascript/mastodon/locales/fi.json @@ -0,0 +1,163 @@ +{ + "account.block": "Estä @{name}", + "account.disclaimer": "This user is from another instance. This number may be larger.", + "account.edit_profile": "Muokkaa", + "account.follow": "Seuraa", + "account.followers": "Seuraajia", + "account.follows": "Seuraa", + "account.follows_you": "Seuraa sinua", + "account.mention": "Mainitse @{name}", + "account.mute": "Mute @{name}", + "account.posts": "Postit", + "account.report": "Report @{name}", + "account.requested": "Odottaa hyväksyntää", + "account.unblock": "Salli @{name}", + "account.unfollow": "Lopeta seuraaminen", + "account.unmute": "Unmute @{name}", + "boost_modal.combo": "You can press {combo} to skip this next time", + "column.blocks": "Blocked users", + "column.community": "Paikallinen aikajana", + "column.favourites": "Favourites", + "column.follow_requests": "Follow requests", + "column.home": "Koti", + "column.mutes": "Muted users", + "column.notifications": "Ilmoitukset", + "column.public": "Yleinen aikajana", + "column_back_button.label": "Takaisin", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Mitä sinulla on mielessä?", + "compose_form.privacy_disclaimer": "Sinun yksityinen status toimitetaan mainitsemallesi käyttäjille domaineissa {domains}. Luotatko {domainsCount, plural, one {tähän palvelimeen} other {näihin palvelimiin}}? Postauksen yksityisyys toimii van Mastodon palvelimilla. Jos {domains} {domainsCount, plural, one {ei ole Mastodon palvelin} other {eivät ole Mastodon palvelin}}, viestiin ei tule Yksityinen-merkintää, ja sitä voidaan boostata tai muuten tehdä näkyväksi muille vastaanottajille.", + "compose_form.publish": "Toot", + "compose_form.sensitive": "Merkitse media herkäksi", + "compose_form.spoiler": "Piiloita teksti varoituksen taakse", + "compose_form.spoiler_placeholder": "Content warning", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insert emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.hashtag": "There is nothing in this hashtag yet.", + "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.", + "empty_column.home.public_timeline": "the public timeline", + "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up", + "follow_request.authorize": "Authorize", + "follow_request.reject": "Reject", + "getting_started.apps": "Various apps are available", + "getting_started.heading": "Aloitus", + "getting_started.open_source_notice": "Mastodon Mastodon on avoimen lähdekoodin ohjelma. Voit avustaa tai raportoida ongelmia GitHub palvelussa {github}. {apps}.", + "home.column_settings.advanced": "Advanced", + "home.column_settings.basic": "Basic", + "home.column_settings.filter_regex": "Filter out by regular expressions", + "home.column_settings.show_reblogs": "Show boosts", + "home.column_settings.show_replies": "Show replies", + "home.settings": "Column settings", + "lightbox.close": "Sulje", + "loading_indicator.label": "Ladataan...", + "media_gallery.toggle_visible": "Toggle visibility", + "missing_indicator.label": "Not found", + "navigation_bar.blocks": "Blocked users", + "navigation_bar.community_timeline": "Paikallinen aikajana", + "navigation_bar.edit_profile": "Muokkaa profiilia", + "navigation_bar.favourites": "Favourites", + "navigation_bar.follow_requests": "Follow requests", + "navigation_bar.info": "Extended information", + "navigation_bar.logout": "Kirjaudu ulos", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "Ominaisuudet", + "navigation_bar.public_timeline": "Yleinen aikajana", + "notification.favourite": "{name} tykkäsi statuksestasi", + "notification.follow": "{name} seurasi sinua", + "notification.reblog": "{name} buustasi statustasi", + "notifications.clear": "Clear notifications", + "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.alert": "Työpöytä ilmoitukset", + "notifications.column_settings.favourite": "Tykkäyksiä:", + "notifications.column_settings.follow": "Uusia seuraajia:", + "notifications.column_settings.mention": "Mainintoja:", + "notifications.column_settings.reblog": "Buusteja:", + "notifications.column_settings.show": "Näytä sarakkeessa", + "notifications.column_settings.sound": "Play sound", + "notifications.settings": "Column settings", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Adjust status privacy", + "privacy.direct.long": "Post to mentioned users only", + "privacy.direct.short": "Direct", + "privacy.private.long": "Post to followers only", + "privacy.private.short": "Followers-only", + "privacy.public.long": "Post to public timelines", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Do not show in public timelines", + "privacy.unlisted.short": "Unlisted", + "reply_indicator.cancel": "Peruuta", + "report.heading": "New report", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting", + "search.placeholder": "Hae", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Poista", + "status.favourite": "Tykkää", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "Mainitse @{name}", + "status.open": "Expand this status", + "status.reblog": "Buustaa", + "status.reblogged_by": "{name} buustasi", + "status.reply": "Vastaa", + "status.replyAll": "Reply to thread", + "status.report": "Report @{name}", + "status.sensitive_toggle": "Klikkaa nähdäksesi", + "status.sensitive_warning": "Arkaluontoista sisältöä", + "status.show_less": "Show less", + "status.show_more": "Show more", + "tabs_bar.compose": "Luo", + "tabs_bar.federated_timeline": "Federated", + "tabs_bar.home": "Koti", + "tabs_bar.local_timeline": "Local", + "tabs_bar.notifications": "Ilmoitukset", + "upload_area.title": "Drag & drop to upload", + "upload_button.label": "Lisää mediaa", + "upload_form.undo": "Peru", + "upload_progress.label": "Uploading...", + "video_player.expand": "Expand video", + "video_player.toggle_sound": "Äänet päälle/pois", + "video_player.toggle_visible": "Toggle visibility", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json new file mode 100644 index 000000000..36a5b04c6 --- /dev/null +++ b/app/javascript/mastodon/locales/fr.json @@ -0,0 +1,163 @@ +{ + "account.block": "Bloquer", + "account.disclaimer": "Ce compte est situé sur une autre instance. Les nombres peuvent être plus grands.", + "account.edit_profile": "Modifier le profil", + "account.follow": "Suivre", + "account.followers": "Abonné⋅e⋅s", + "account.follows": "Abonnements", + "account.follows_you": "Vous suit", + "account.mention": "Mentionner", + "account.mute": "Masquer", + "account.posts": "Statuts", + "account.report": "Signaler", + "account.requested": "Invitation envoyée", + "account.unblock": "Débloquer", + "account.unfollow": "Ne plus suivre", + "account.unmute": "Ne plus masquer", + "boost_modal.combo": "You can press {combo} to skip this next time", + "column.blocks": "Comptes bloqués", + "column.community": "Fil public local", + "column.favourites": "Favoris", + "column.follow_requests": "Demandes de suivi", + "column.home": "Accueil", + "column.mutes": "Muted users", + "column.notifications": "Notifications", + "column.public": "Fil public global", + "column_back_button.label": "Retour", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Qu’avez-vous en tête ?", + "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodon. Si {domains} {domainsCount, plural, one {n’est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n’y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d’une autre manière à d’autres personnes imprévues.", + "compose_form.private": "Rendre privé", + "compose_form.publish": "Pouet", + "compose_form.sensitive": "Marquer le média comme délicat", + "compose_form.spoiler": "Masquer le texte derrière un avertissement", + "compose_form.spoiler_placeholder": "Avertissement", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insérer un emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "Le fil public local est vide. Écrivez-donc quelque chose pour le remplir !", + "empty_column.hashtag": "Il n’y a encore aucun contenu relatif à ce hashtag", + "empty_column.home.public_timeline": "le fil public", + "empty_column.home": "Vous ne suivez encore personne. Visitez {public} ou bien utilisez la recherche pour vous connecter à d’autres utilisateurs⋅trices.", + "empty_column.notifications": "Vous n’avez pas encore de notification. Interagissez avec d’autres utilisateurs⋅trices pour débuter la conversation.", + "empty_column.public": "Il n’y a rien ici ! Écrivez quelque chose publiquement, ou bien suivez manuellement des utilisateurs⋅trices d’autres instances pour remplir le fil public.", + "follow_request.authorize": "Autoriser", + "follow_request.reject": "Rejeter", + "getting_started.apps": "Various apps are available", + "getting_started.heading": "Pour commencer", + "getting_started.open_source_notice": "Mastodon est un logiciel libre. Vous pouvez contribuer et envoyer vos commentaires et rapports de bogues via {github} sur GitHub.", + "home.column_settings.advanced": "Avancé", + "home.column_settings.basic": "Basique", + "home.column_settings.filter_regex": "Filtrer avec une expression rationnelle", + "home.column_settings.show_reblogs": "Afficher les partages", + "home.column_settings.show_replies": "Afficher les réponses", + "home.settings": "Paramètres de la colonne", + "lightbox.close": "Fermer", + "loading_indicator.label": "Chargement…", + "media_gallery.toggle_visible": "Modifier la visibilité", + "missing_indicator.label": "Non trouvé", + "navigation_bar.blocks": "Comptes bloqués", + "navigation_bar.community_timeline": "Fil public local", + "navigation_bar.edit_profile": "Modifier le profil", + "navigation_bar.favourites": "Favoris", + "navigation_bar.follow_requests": "Demandes de suivi", + "navigation_bar.info": "Plus d’informations", + "navigation_bar.logout": "Déconnexion", + "navigation_bar.mutes": "Comptes silencés", + "navigation_bar.preferences": "Préférences", + "navigation_bar.public_timeline": "Fil public global", + "notification.favourite": "{name} a ajouté à ses favoris :", + "notification.follow": "{name} vous suit.", + "notification.reblog": "{name} a partagé votre statut :", + "notifications.clear": "Nettoyer", + "notifications.clear_confirmation": "Voulez-vous vraiment supprimer toutes vos notifications ?", + "notifications.column_settings.alert": "Notifications locales", + "notifications.column_settings.favourite": "Favoris :", + "notifications.column_settings.follow": "Nouveaux abonné⋅e⋅s :", + "notifications.column_settings.mention": "Mentions :", + "notifications.column_settings.reblog": "Partages :", + "notifications.column_settings.show": "Afficher dans la colonne", + "notifications.column_settings.sound": "Émettre un son", + "notifications.settings": "Paramètres de la colonne", + "onboarding.done": "Done", + "onboarding.next": "Suivant", + "onboarding.page_five.public_timelines": "Le fil public global affiche les posts de tou⋅te⋅s les utilisateurs⋅trices suivi⋅es par les membres de {domain}. Le fil public local est identique mais se limite aux utilisateurs⋅trices de {domain}.", + "onboarding.page_four.home": "L’Accueil affiche les posts de tou⋅te⋅s les utilisateurs⋅trices que vous suivez", + "onboarding.page_four.notifications": "Les Notifications vous informent lorsque quelqu’un interagit avec vous", + "onboarding.page_one.federation": "Mastodon est un réseau social qui appartient à tou⋅te⋅s.", + "onboarding.page_one.handle": "Vous êtes sur {domain}, une des nombreuses instances indépendantes de Mastodon. Votre nom d’utilisateur⋅trice complet est {handle}", + "onboarding.page_one.welcome": "Bienvenue sur Mastodon !", + "onboarding.page_six.admin": "L’administrateur⋅trice de votre instance est {admin}", + "onboarding.page_six.almost_done": "Nous y sommes presque…", + "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon Appetoot!", + "onboarding.page_six.github": "Mastodon est un logiciel libre, gratuit et open-source. Vous pouvez rapporter des bogues, suggérer des fonctionnalités, ou contribuer à son développement sur {github}.", + "onboarding.page_six.guidelines": "règles de la communauté", + "onboarding.page_six.read_guidelines": "S’il vous plaît, n’oubliez pas de lire les {guidelines} !", + "onboarding.page_six.various_app": "applications mobiles", + "onboarding.page_three.profile": "Modifiez votre profil pour changer votre avatar, votre description ainsi que votre nom. Vous y trouverez également d’autres préférences.", + "onboarding.page_three.search": "Utilisez la barre de recherche pour trouver des utilisateurs⋅trices et regarder des hashtags tels que {illustration} et {introductions}. Pour trouver quelqu’un qui n’est pas sur cette instance, utilisez son nom d’utilisateur⋅trice complet.", + "onboarding.page_two.compose": "Écrivez depuis la colonne de composition. Vous pouvez ajouter des images, changer les réglages de confidentialité, et ajouter des avertissements de contenu (Content Warning) grâce aux icônes en dessous.", + "onboarding.skip": "Passer", + "privacy.change": "Ajuster la confidentialité du message", + "privacy.direct.long": "N’afficher que pour les personnes mentionnées", + "privacy.direct.short": "Direct", + "privacy.private.long": "N’afficher que pour vos abonné⋅e⋅s", + "privacy.private.short": "Privé", + "privacy.public.long": "Afficher dans les fils publics", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Ne pas afficher dans les fils publics", + "privacy.unlisted.short": "Non-listé", + "reply_indicator.cancel": "Annuler", + "report.heading": "Nouveau signalement", + "report.placeholder": "Commentaires additionnels", + "report.submit": "Envoyer", + "report.target": "Signalement", + "search.placeholder": "Rechercher", + "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Effacer", + "status.favourite": "Ajouter aux favoris", + "status.load_more": "Charger plus", + "status.media_hidden": "Média caché", + "status.mention": "Mentionner", + "status.open": "Déplier ce statut", + "status.reblog": "Partager", + "status.reblogged_by": "{name} a partagé :", + "status.reply": "Répondre", + "status.replyAll": "Reply to thread", + "status.report": "Signaler @{name}", + "status.sensitive_toggle": "Cliquer pour dévoiler", + "status.sensitive_warning": "Contenu délicat", + "status.show_less": "Replier", + "status.show_more": "Déplier", + "tabs_bar.compose": "Composer", + "tabs_bar.federated_timeline": "Fil public global", + "tabs_bar.home": "Accueil", + "tabs_bar.local_timeline": "Fil public local", + "tabs_bar.notifications": "Notifications", + "upload_area.title": "Glissez et déposez pour envoyer", + "upload_button.label": "Joindre un média", + "upload_form.undo": "Annuler", + "upload_progress.label": "Envoi en cours…", + "video_player.expand": "Expand video", + "video_player.toggle_sound": "Mettre/Couper le son", + "video_player.toggle_visible": "Afficher/Cacher la vidéo", + "video_player.video_error": "Video could not be played" +} diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json new file mode 100644 index 000000000..f8945dc1c --- /dev/null +++ b/app/javascript/mastodon/locales/he.json @@ -0,0 +1,165 @@ +{ + "account.block": "חסימת @{name}", + "account.disclaimer": "משתמש זה מגיע מקהילה אחרת. המספר הזה עשוי להיות גדול יותר.", + "account.edit_profile": "עריכת פרופיל", + "account.follow": "מעקב", + "account.followers": "עוקבים", + "account.follows_you": "במעקב אחריך", + "account.follows": "נעקבים", + "account.mention": "אזכור של @{name}", + "account.mute": "להשתיק את @{name}", + "account.posts": "הודעות", + "account.report": "לדווח על @{name}", + "account.requested": "בהמתנה לאישור", + "account.unblock": "הסרת חסימה מעל @{name}", + "account.unfollow": "הפסקת מעקב", + "account.unmute": "הפסקת השתקת @{name}", + "boost_modal.combo": "ניתן להקיש {combo} כדי לדלג בפעם הבאה", + "column.blocks": "חסימות", + "column.community": "פיד מקומי", + "column.favourites": "חיבובים", + "column.follow_requests": "בקשות מעקב", + "column.home": "בבית", + "column.mutes": "השתקות", + "column.notifications": "התראות", + "column.public": "בפרהסיה", + "column_back_button.label": "אחורה", + "column_subheading.navigation": "ניווט", + "column_subheading.settings": "אפשרויות", + "compose_form.lock_disclaimer": "חשבונך אינו {locked}. כל אחד יוכל לעקוב אחריך כדי לקרוא את הודעותיך המיועדות לעוקבים בלבד.", + "compose_form.lock_disclaimer.lock": "נעול", + "compose_form.placeholder": "מה עובר לך בראש?", + "compose_form.privacy_disclaimer": "הודעתך הפרטית תשלח למשתמשים על {domains}. האם ניתן לסמוך על {domainsCount, plural, one {שרת זה} other {שרתים אלו}}? פרטיות ההודעה קיימת רק על שרתי מסטודון. אם {domains} {domainsCount, plural, one {הוא לא שרת מסטודון} other {הם לא שרתי מסטודון}}, לא יהיה שום סימן שההודעה פרטית, והוא עשוי להיות מקודם או להחשף למשתמשים שלא ברשימת היעד.", + "compose_form.publish": "לחצרץ", + "compose_form.sensitive": "סימון תוכן כרגיש", + "compose_form.spoiler": "הסתרה מאחורי אזהרת תוכן", + "compose_form.spoiler_placeholder": "אזהרת תוכן", + "confirmation_modal.cancel": "ביטול", + "confirmations.block.confirm": "לחסום", + "confirmations.block.message": "לחסום את {name}?", + "confirmations.delete.confirm": "למחוק", + "confirmations.delete.message": "למחוק את ההודעה?", + "confirmations.mute.confirm": "להשתיק", + "confirmations.mute.message": "להשתיק את {name}?", + "emoji_button.activity": "פעילות", + "emoji_button.flags": "דגלים", + "emoji_button.food": "אוכל ושתיה", + "emoji_button.label": "הוספת אמוג'י", + "emoji_button.nature": "טבע", + "emoji_button.objects": "חפצים", + "emoji_button.people": "אנשים", + "emoji_button.search": "חיפוש...", + "emoji_button.symbols": "סמלים", + "emoji_button.travel": "טיולים ואתרים", + "empty_column.community": "טור הסביבה ריק. יש לפרסם משהו כדי שדברים יתרחילו להתגלגל!", + "empty_column.hashtag": "אין כלום בהאשתג הזה עדיין.", + "empty_column.home.public_timeline": "בפרהסיה", + "empty_column.home": "אף אחד לא במעקב עדיין. אפשר לבקר ב{public} או להשתמש בחיפוש כדי להתחיל ולהכיר חצוצרנים אחרים.", + "empty_column.notifications": "אין התראות עדיין. יאללה, הגיע הזמן להתחיל להתערבב!", + "empty_column.public": "אין פה כלום! כדי למלא את הטור הזה אפשר לכתוב משהו, או להתחיל לעקוב אחרי אנשים מקהילות אחרות.", + "follow_request.authorize": "קבלה", + "follow_request.reject": "דחיה", + "getting_started.apps": "קיים מבחר יישומונים לניידים", + "getting_started.heading": "על ההתחלה", + "getting_started.open_source_notice": "מסטודון היא תוכנה חופשית (בקוד פתוח). ניתן לתרום או לדווח על בעיות בגיטהאב: {github}. {apps}.", + "home.column_settings.advanced": "למתקדמים", + "home.column_settings.basic": "למתחילים", + "home.column_settings.filter_regex": "סינון באמצעות ביטויים רגולריים (regular expressions)", + "home.column_settings.show_reblogs": "הצגת הדהודים", + "home.column_settings.show_replies": "הצגת תגובות", + "home.settings": "הגדרות טור", + "lightbox.close": "סגירה", + "loading_indicator.label": "טוען...", + "media_gallery.toggle_visible": "נראה\\בלתי נראה", + "missing_indicator.label": "לא נמצא", + "navigation_bar.blocks": "חסימות", + "navigation_bar.community_timeline": "פיד מקומי", + "navigation_bar.edit_profile": "עריכת פרופיל", + "navigation_bar.favourites": "חיבובים", + "navigation_bar.follow_requests": "בקשות מעקב", + "navigation_bar.info": "מידע נוסף", + "navigation_bar.logout": "יציאה", + "navigation_bar.mutes": "השתקות", + "navigation_bar.preferences": "העדפות", + "navigation_bar.public_timeline": "בפרהסיה", + "notification.favourite": "חצרוצך חובב על ידי {name}", + "notification.follow": "{name} במעקב אחרייך", + "notification.mention": "אוזכרת ע\"י {name}", + "notification.reblog": "חצרוצך הודהד על ידי {name}", + "notifications.clear": "הסרת התראות", + "notifications.clear_confirmation": "להסיר את כל ההתראות? בטוח?", + "notifications.column_settings.alert": "התראות לשולחן העבודה", + "notifications.column_settings.favourite": "מחובבים:", + "notifications.column_settings.follow": "עוקבים חדשים:", + "notifications.column_settings.mention": "פניות:", + "notifications.column_settings.reblog": "הדהודים:", + "notifications.column_settings.show": "הצגה בטור", + "notifications.column_settings.sound": "שמע מופעל", + "notifications.settings": "הגדרות טור", + "onboarding.done": "יציאה", + "onboarding.next": "הלאה", + "onboarding.page_five.public_timelines": "ציר הזמן המקומי מראה הודעות פומביות מכל באי קהילת {domain}. ציר הזמן העולמי מראה הודעות פומביות מאת כי מי שבאי קהילת {domain} עוקבים אחריו. אלו צירי הזמן הפומביים, דרך נהדרת לגלות אנשים חדשים.", + "onboarding.page_four.home": "ציר זמן הבית מראה הודעות מהנעקבים שלך.", + "onboarding.page_four.notifications": "טור ההתראות מראה כשמישהו מתייחס להודעות שלך.", + "onboarding.page_one.federation": "מסטודון היא רשת של שרתים עצמאיים מצורפים ביחד לכדי רשת חברתית אחת גדולה. אנחנו מכנים את השרתים האלו: קהילות", + "onboarding.page_one.handle": "אתם בקהילה {domain}, ולכן מזהה המשתמש המלא שלכם הוא {handle}", + "onboarding.page_one.welcome": "ברוכים הבאים למסטודון!", + "onboarding.page_six.admin": "הקהילה מנוהלת בידי {admin}.", + "onboarding.page_six.almost_done": "כמעט סיימנו...", + "onboarding.page_six.appetoot": "בתותאבון!", + "onboarding.page_six.apps_available": "קיימים {apps} זמינים עבור אנדרואיד, אייפון ופלטפורמות נוספות.", + "onboarding.page_six.github": "מסטודון הוא תוכנה חופשית. ניתן לדווח על באגים, לבקש יכולות, או לתרום לקוד באתר {github}.", + "onboarding.page_six.guidelines": "חוקי הקהילה", + "onboarding.page_six.read_guidelines": "נא לקרוא את {guidelines} של {domain}!", + "onboarding.page_six.various_app": "יישומונים ניידים", + "onboarding.page_three.profile": "ץתחת 'עריכת פרופיל' ניתן להחליף את תמונת הפרופיל שלך, תיאור קצר, והשם המוצג. שם גם ניתן למצוא אפשרויות והעדפות נוספות.", + "onboarding.page_three.search": "בחלונית החיפוש ניתן לחפש אנשים והאשתגים, כמו למשל {illustration} או {introductions}. כדי למצוא מישהו שלא על האינסטנס המקומי, יש להשתמש בכינוי המשתמש המלא.", + "onboarding.page_two.compose": "הודעות כותבים מטור הכתיבה. ניתן לנעלות תמונות, לשנות הגדרות פרטיות, ולהוסיף אזהרות תוכן בעזרת האייקונים שמתחת.", + "onboarding.skip": "לדלג", + "privacy.change": "שינוי פרטיות ההודעה", + "privacy.direct.long": "הצג רק למי שהודעה זו פונה אליו", + "privacy.direct.short": "הודעה ישירה", + "privacy.private.long": "הצג לעוקבים מקומיים בלבד", + "privacy.private.short": "לעוקבים בלבד", + "privacy.public.long": "פרסם בפומבי", + "privacy.public.short": "פומבי", + "privacy.unlisted.long": "לא יופיע בפידים הציבוריים המשותפים", + "privacy.unlisted.short": "לא לפיד הכללי", + "reply_indicator.cancel": "ביטול", + "report.heading": "דווח חדש", + "report.placeholder": "הערות נוספות", + "report.submit": "שליחה", + "report.target": "דיווח", + "search.placeholder": "חיפוש", + "search.status_by": "הודעה מאת {name}", + "search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}", + "status.cannot_reblog": "לא ניתן להדהד הודעה זו", + "status.delete": "מחיקה", + "status.favourite": "חיבוב", + "status.load_more": "עוד", + "status.media_hidden": "מדיה מוסתרת", + "status.mention": "פניה אל @{name}", + "status.open": "הרחבת הודעה", + "status.reblog": "הדהוד", + "status.reblogged_by": "הודהד על ידי {name}", + "status.reply": "תגובה", + "status.replyAll": "תגובה לכולם", + "status.report": "דיווח על @{name}", + "status.sensitive_warning": "תוכן רגיש", + "status.sensitive_toggle": "לחצו כדי לראות", + "status.show_less": "הראה פחות", + "status.show_more": "הראה יותר", + "tabs_bar.compose": "חיבור", + "tabs_bar.federated_timeline": "בפדרציה", + "tabs_bar.home": "בבית", + "tabs_bar.local_timeline": "פיד מקומי", + "tabs_bar.notifications": "התראות", + "upload_area.title": "ניתן להעלות על ידי Drag & drop", + "upload_button.label": "הוספת מדיה", + "upload_form.undo": "ביטול", + "upload_progress.label": "עולה...", + "video_player.expand": "הרחבת וידאו", + "video_player.toggle_sound": "הפעלת\\ביטול שמע", + "video_player.toggle_visible": "הפעלת\\ביטול תצוגה", + "video_player.video_error": "לא ניתן לנגן וידאו" +} diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json new file mode 100644 index 000000000..45c3cb7f6 --- /dev/null +++ b/app/javascript/mastodon/locales/hr.json @@ -0,0 +1,163 @@ +{ + "account.block": "Blokiraj @{name}", + "account.disclaimer": "Ovaj korisnik je sa druge instance. Ovaj broj bi mogao biti veći.", + "account.edit_profile": "Uredi profil", + "account.follow": "Slijedi", + "account.followers": "Sljedbenici", + "account.follows": "Slijedi", + "account.follows_you": "te slijedi", + "account.mention": "Spomeni @{name}", + "account.mute": "Utišaj @{name}", + "account.posts": "Postovi", + "account.report": "Prijavi @{name}", + "account.requested": "Čeka pristanak", + "account.unblock": "Deblokiraj @{name}", + "account.unfollow": "Prestani slijediti", + "account.unmute": "Poništi utišavanje @{name}", + "boost_modal.combo": "Možeš pritisnuti {combo} kako bi ovo preskočio sljedeći put", + "column.blocks": "Blokirani korisnici", + "column.community": "Lokalni timeline", + "column.favourites": "Favoriti", + "column.follow_requests": "Zahtjevi za slijeđenje", + "column.home": "Dom", + "column.mutes": "Muted users", + "column.notifications": "Notifikacije", + "column.public": "Federalni timeline", + "column_back_button.label": "Natrag", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Što ti je na umu?", + "compose_form.privacy_disclaimer": "Tvoj privatni status će biti dostavljen spomenutim korisnicima na {domains}. Vjeruješ li {domainsCount, plural, one {that server} drugim {those servers}}? Privatnost postova radi samo na Mastodon instancama. Ako {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, neće biti indikacije da je tvoj post privatan, i mogao bit biti podignut ili biti učinjen vidljivim na drugi način neželjenim primateljima.", + "compose_form.publish": "Toot", + "compose_form.sensitive": "Označi media sadržaj kao osjetljiv", + "compose_form.spoiler": "Sakrij text iza upozorenja", + "compose_form.spoiler_placeholder": "Upozorenje o sadržaju", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Umetni smajlije", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "Lokalni timeline je prazan. Napiši nešto javno kako bi pokrenuo stvari!", + "empty_column.hashtag": "Još ne postoji ništa s ovim hashtagom.", + "empty_column.home": "Još ne slijediš nikoga. Posjeti {public} ili koristi tražilicu kako bi počeo i upoznao druge korisnike.", + "empty_column.home.public_timeline": "javni timeline", + "empty_column.notifications": "Još nemaš notifikacija. Komuniciraj sa drugima kako bi započeo razgovor.", + "empty_column.public": "Ovdje nema ništa! Napiši nešto javno, ili ručno slijedi korisnike sa drugih instanci kako bi popunio", + "follow_request.authorize": "Authoriziraj", + "follow_request.reject": "Odbij", + "getting_started.apps": "Dostupne su razne aplikacije", + "getting_started.heading": "Počnimo", + "getting_started.open_source_notice": "Mastodon je softver otvorenog koda. Možeš pridonijeti ili prijaviti probleme na GitHubu {github}. {apps}.", + "home.column_settings.advanced": "Napredno", + "home.column_settings.basic": "Osnovno", + "home.column_settings.filter_regex": "Filtriraj s regularnim izrazima", + "home.column_settings.show_reblogs": "Pokaži boosts", + "home.column_settings.show_replies": "Pokaži odgovore", + "home.settings": "Postavke Stupca", + "lightbox.close": "Zatvori", + "loading_indicator.label": "Učitavam...", + "media_gallery.toggle_visible": "Preklopi vidljivost", + "missing_indicator.label": "Nije nađen", + "navigation_bar.blocks": "Blokirani korisnici", + "navigation_bar.community_timeline": "Lokalni timeline", + "navigation_bar.edit_profile": "Uredi profil", + "navigation_bar.favourites": "Favoriti", + "navigation_bar.follow_requests": "Zahtjevi za sljeđenje", + "navigation_bar.info": "Proširena informacija", + "navigation_bar.logout": "Odjavi se", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "Postavke", + "navigation_bar.public_timeline": "Federalni timeline", + "notification.favourite": "{name} je lajkao tvoj status", + "notification.follow": "{name} te sada slijedi", + "notification.reblog": "{name} je podigao tvoj status", + "notifications.clear": "Očisti notifikacije", + "notifications.clear_confirmation": "Želiš li zaista obrisati sve svoje notifikacije?", + "notifications.column_settings.alert": "Desktop notifikacije", + "notifications.column_settings.favourite": "Favoriti:", + "notifications.column_settings.follow": "Novi sljedbenici:", + "notifications.column_settings.mention": "Spominjanja:", + "notifications.column_settings.reblog": "Boosts:", + "notifications.column_settings.show": "Prikaži u stupcu", + "notifications.column_settings.sound": "Sviraj zvuk", + "notifications.settings": "Postavke rubrike", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Podesi status privatnosti", + "privacy.direct.long": "Prikaži samo spomenutim korisnicima", + "privacy.direct.short": "Direktno", + "privacy.private.long": "Prikaži samo sljedbenicima", + "privacy.private.short": "Privatno", + "privacy.public.long": "Postaj na javne timeline", + "privacy.public.short": "Javno", + "privacy.unlisted.long": "Ne prikazuj u javnim timelineovima", + "privacy.unlisted.short": "Unlisted", + "reply_indicator.cancel": "Otkaži", + "report.heading": "Nova prijava", + "report.placeholder": "Dodatni komentari", + "report.submit": "Pošalji", + "report.target": "Prijavljivanje", + "search.placeholder": "Traži", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Obriši", + "status.favourite": "Označi omiljenim", + "status.load_more": "Učitaj više", + "status.media_hidden": "Sakriven media sadržaj", + "status.mention": "Spomeni @{name}", + "status.open": "Proširi ovaj status", + "status.reblog": "Podigni", + "status.reblogged_by": "{name} je podigao", + "status.reply": "Odgovori", + "status.replyAll": "Reply to thread", + "status.report": "Prijavi @{name}", + "status.sensitive_toggle": "Klikni da bi vidio", + "status.sensitive_warning": "Osjetljiv sadržaj", + "status.show_less": "Pokaži manje", + "status.show_more": "Pokaži više", + "tabs_bar.compose": "Sastavi", + "tabs_bar.federated_timeline": "Federalni", + "tabs_bar.home": "Dom", + "tabs_bar.local_timeline": "Lokalno", + "tabs_bar.notifications": "Notifikacije", + "upload_area.title": "Povuci & spusti kako bi uploadao", + "upload_button.label": "Dodaj media", + "upload_form.undo": "Poništi", + "upload_progress.label": "Uploadam...", + "video_player.expand": "Proširi video", + "video_player.toggle_sound": "Toggle zvuk", + "video_player.toggle_visible": "Preklopi vidljivost", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json new file mode 100644 index 000000000..e1d9d36be --- /dev/null +++ b/app/javascript/mastodon/locales/hu.json @@ -0,0 +1,163 @@ +{ + "account.block": "Blokkolás", + "account.disclaimer": "This user is from another instance. This number may be larger.", + "account.edit_profile": "Profil szerkesztése", + "account.follow": "Követés", + "account.followers": "Követők", + "account.follows": "Követve", + "account.follows_you": "Követnek téged", + "account.mention": "Említés", + "account.mute": "Mute @{name}", + "account.posts": "Posts", + "account.report": "Report @{name}", + "account.requested": "Awaiting approval", + "account.unblock": "Blokkolás levétele", + "account.unfollow": "Követés abbahagyása", + "account.unmute": "Unmute @{name}", + "boost_modal.combo": "You can press {combo} to skip this next time", + "column.blocks": "Blocked users", + "column.community": "Local timeline", + "column.favourites": "Favourites", + "column.follow_requests": "Follow requests", + "column.home": "Kezdőlap", + "column.mutes": "Muted users", + "column.notifications": "Értesítések", + "column.public": "Nyilvános", + "column_back_button.label": "Vissza", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Mire gondolsz?", + "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", + "compose_form.publish": "Tülk!", + "compose_form.sensitive": "Tartalom érzékenynek jelölése", + "compose_form.spoiler": "Hide text behind warning", + "compose_form.spoiler_placeholder": "Content warning", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insert emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.hashtag": "There is nothing in this hashtag yet.", + "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.", + "empty_column.home.public_timeline": "the public timeline", + "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up", + "follow_request.authorize": "Authorize", + "follow_request.reject": "Reject", + "getting_started.apps": "Various apps are available", + "getting_started.heading": "Első lépések", + "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.", + "home.column_settings.advanced": "Advanced", + "home.column_settings.basic": "Basic", + "home.column_settings.filter_regex": "Filter out by regular expressions", + "home.column_settings.show_reblogs": "Show boosts", + "home.column_settings.show_replies": "Show replies", + "home.settings": "Column settings", + "lightbox.close": "Bezárás", + "loading_indicator.label": "Betöltés...", + "media_gallery.toggle_visible": "Toggle visibility", + "missing_indicator.label": "Not found", + "navigation_bar.blocks": "Blocked users", + "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.edit_profile": "Profil szerkesztése", + "navigation_bar.favourites": "Favourites", + "navigation_bar.follow_requests": "Follow requests", + "navigation_bar.info": "Extended information", + "navigation_bar.logout": "Kijelentkezés", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "Beállítások", + "navigation_bar.public_timeline": "Nyilvános időfolyam", + "notification.favourite": "{name} kedvencnek jelölte az állapotod", + "notification.follow": "{name} követ téged", + "notification.reblog": "{name} reblogolta az állapotod", + "notifications.clear": "Clear notifications", + "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.alert": "Desktop notifications", + "notifications.column_settings.favourite": "Favourites:", + "notifications.column_settings.follow": "New followers:", + "notifications.column_settings.mention": "Mentions:", + "notifications.column_settings.reblog": "Boosts:", + "notifications.column_settings.show": "Show in column", + "notifications.column_settings.sound": "Play sound", + "notifications.settings": "Column settings", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Adjust status privacy", + "privacy.direct.long": "Post to mentioned users only", + "privacy.direct.short": "Direct", + "privacy.private.long": "Post to followers only", + "privacy.private.short": "Followers-only", + "privacy.public.long": "Post to public timelines", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Do not show in public timelines", + "privacy.unlisted.short": "Unlisted", + "reply_indicator.cancel": "Mégsem", + "report.heading": "New report", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting", + "search.placeholder": "Keresés", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Törlés", + "status.favourite": "Kedvenc", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "Említés", + "status.open": "Expand this status", + "status.reblog": "Reblog", + "status.reblogged_by": "{name} reblogolta", + "status.reply": "Válasz", + "status.replyAll": "Reply to thread", + "status.report": "Report @{name}", + "status.sensitive_toggle": "Katt a megtekintéshez", + "status.sensitive_warning": "Érzékeny tartalom", + "status.show_less": "Show less", + "status.show_more": "Show more", + "tabs_bar.compose": "Összeállítás", + "tabs_bar.federated_timeline": "Federated", + "tabs_bar.home": "Kezdőlap", + "tabs_bar.local_timeline": "Local", + "tabs_bar.notifications": "Notifications", + "upload_area.title": "Drag & drop to upload", + "upload_button.label": "Média hozzáadása", + "upload_form.undo": "Mégsem", + "upload_progress.label": "Uploading...", + "video_player.expand": "Expand video", + "video_player.toggle_sound": "Hang kapcsolása", + "video_player.toggle_visible": "Toggle visibility", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json new file mode 100644 index 000000000..d73915a36 --- /dev/null +++ b/app/javascript/mastodon/locales/id.json @@ -0,0 +1,167 @@ +{ + "account.block": "Blokir @{name}", + "account.disclaimer": "Pengguna ini berasal dari server lain. Angka berikut mungkin lebih besar.", + "account.edit_profile": "Ubah profil", + "account.follow": "Ikuti", + "account.followers": "Pengikut", + "account.follows": "Mengikuti", + "account.follows_you": "Mengikuti anda", + "account.mention": "Balasan @{name}", + "account.mute": "Bisukan @{name}", + "account.posts": "Postingan", + "account.report": "Laporkan @{name}", + "account.requested": "Menunggu persetujuan", + "account.unblock": "Hapus blokir @{name}", + "account.unfollow": "Berhenti mengikuti", + "account.unmute": "Berhenti membisukan @{name}", + "boost_modal.combo": "Anda dapat menekan {combo} untuk melewati ini", + "column.blocks": "Pengguna diblokir", + "column.community": "Linimasa Lokal", + "column.favourites": "Favorit", + "column.follow_requests": "Permintaan mengikuti", + "column.home": "Beranda", + "column.mutes": "Pengguna dibisukan", + "column.notifications": "Notifikasi", + "column.public": "Linimasa gabunggan", + "column_back_button.label": "Kembali", + "column_subheading.navigation": "Navigasi", + "column_subheading.settings": "Pengaturan", + "compose_form.lock_disclaimer": "Akun anda tidak {locked}. Semua orang dapat mengikuti anda untuk melihat postingan khusus untuk pengikut anda.", + "compose_form.lock_disclaimer.lock": "dikunci", + "compose_form.placeholder": "Apa yang ada di pikiran anda?", + "compose_form.privacy_disclaimer": "Status pribadi anda akan dikirim ke pengguna yang disebut dalam {domains}. Apa anda mempercayai {domainsCount, plural, one {server tersebut} other {server tersebut}}? Privasi postingan hanya bekerja dalam server Mastodon. Jika {domains} {domainsCount, plural, one {bukan server Mastodon} other {bukan server Mastodon}}, akan ada indikasi bahwa postingan anda adalah postingan pribadi, dan dapat di-boost atau dapat dilihat oleh orang lain.", + "compose_form.publish": "Toot", + "compose_form.sensitive": "Tandai media sensitif", + "compose_form.spoiler": "Sembunyikan teks dibalik peringatan", + "compose_form.spoiler_placeholder": "Peringatan konten", + "confirmation_modal.cancel": "Batal", + "confirmations.block.confirm": "Blokir", + "confirmations.block.message": "Apa anda yakin ingin memblokir {name}?", + "confirmations.delete.confirm": "Hapus", + "confirmations.delete.message": "Apa anda yakin akan menghapus status ini?", + "confirmations.mute.confirm": "Bisukan", + "confirmations.mute.message": "Apa anda yakin ingin membisukan {name}?", + "emoji_button.activity": "Aktivitas", + "emoji_button.flags": "Bendera", + "emoji_button.food": "Makanan & Minuman", + "emoji_button.label": "Tambahkan emoji", + "emoji_button.nature": "Alam", + "emoji_button.objects": "Benda-benda", + "emoji_button.people": "Orang", + "emoji_button.search": "Cari...", + "emoji_button.symbols": "Simbol", + "emoji_button.travel": "Tempat Wisata", + "empty_column.community": "Linimasa lokal masih kosong. Tulis sesuatu secara publik dan buat roda berputar!", + "empty_column.hashtag": "Tidak ada apapun dalam hashtag ini.", + "empty_column.home": "Anda sedang tidak mengikuti siapapun. Kunjungi {public} atau gunakan pencarian untuk memulai dan bertemu pengguna lain.", + "empty_column.home.public_timeline": "linimasa publik", + "empty_column.notifications": "Anda tidak memiliki notifikasi apapun. Berinteraksi dengan orang lain untuk memulai percakapan.", + "empty_column.public": "Tidak ada apapun disini! Tulis sesuatu, atau ikuti pengguna lain dari server lain untuk mengisinya secara manual", + "follow_request.authorize": "Izinkan", + "follow_request.reject": "Tolak", + "getting_started.apps": "Tersedia dalam berbagai aplikasi", + "getting_started.heading": "Mulai", + "getting_started.open_source_notice": "Mastodon adalah perangkat lunak yang bersifat open source. Anda dapat berkontribusi atau melaporkan permasalahan/bug di Github {github}. {apps}.", + "home.column_settings.advanced": "Tingkat Lanjut", + "home.column_settings.basic": "Dasar", + "home.column_settings.filter_regex": "Penyaringan dengan Regular Expression", + "home.column_settings.show_reblogs": "Tampilkan Boost", + "home.column_settings.show_replies": "Tampilkan balasan", + "home.settings": "Pengaturan kolom", + "lightbox.close": "Tutup", + "loading_indicator.label": "Tunggu sebentar...", + "media_gallery.toggle_visible": "Tampil/Sembunyikan", + "missing_indicator.label": "Tidak ditemukan", + "navigation_bar.blocks": "Pengguna diblokir", + "navigation_bar.community_timeline": "Linimasa lokal", + "navigation_bar.edit_profile": "Ubah profil", + "navigation_bar.favourites": "Favorit", + "navigation_bar.follow_requests": "Permintaan mengikuti", + "navigation_bar.info": "Informasi selengkapnya", + "navigation_bar.logout": "Keluar", + "navigation_bar.mutes": "Pengguna dibisukan", + "navigation_bar.preferences": "Pengaturan", + "navigation_bar.public_timeline": "Linimasa gabungan", + "notification.favourite": "{name} menyukai status anda", + "notification.follow": "{name} mengikuti anda", + "notification.reblog": "{name} mem-boost status anda", + "notifications.clear": "Hapus notifikasi", + "notifications.clear_confirmation": "Apa anda yakin hendak menghapus semua notifikasi anda?", + "notifications.column_settings.alert": "Notifikasi desktop", + "notifications.column_settings.favourite": "Favorit:", + "notifications.column_settings.follow": "Pengikut baru:", + "notifications.column_settings.mention": "Balasan:", + "notifications.column_settings.reblog": "Boost:", + "notifications.column_settings.show": "Tampilkan dalam kolom", + "notifications.column_settings.sound": "Mainkan suara", + "notifications.settings": "Pengaturan kolom", + "onboarding.done": "Selesei", + "onboarding.next": "Selanjutnya", + "onboarding.page_five.public_timelines": "Linimasa lokal menampilkan semua postingan publik dari semua orang di {domain}. Linimasa gabungan menampilkan postingan publik dari semua orang yang diikuti oleh {domain}. Ini semua adalah Linimasa Publik, cara terbaik untuk bertemu orang lain.", + "onboarding.page_four.home": "Linimasa beranda menampilkan postingan dari orang-orang yang anda ikuti.", + "onboarding.page_four.notifications": "Kolom notifikasi menampilkan ketika seseorang berinteraksi dengan anda.", + "onboarding.page_one.federation": "Mastodon adalah jaringan dari beberapa server independen yang bergabung untuk membuat jejaring sosial yang besar.", + "onboarding.page_one.handle": "Ada berada dalam {domain}, jadi nama user lengkap anda adalah {handle}", + "onboarding.page_one.welcome": "Selamat datang di Mastodon!", + "onboarding.page_six.admin": "Admin serveer anda adalah {admin}.", + "onboarding.page_six.almost_done": "Hampir selesei...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "Ada beberapa apl yang tersedia untuk iOS, Android, dan platform lainnya.", + "onboarding.page_six.github": "Mastodon adalah software open-source. Anda bisa melaporkan bug, meminta fitur, atau berkontribusi dengan kode di {github}.", + "onboarding.page_six.guidelines": "pedoman komunitas", + "onboarding.page_six.read_guidelines": "Silakan baca {guidelines} {domain}!", + "onboarding.page_six.various_app": "apl handphone", + "onboarding.page_three.profile": "Ubah profil anda untuk mengganti avatar, bio, dan nama pengguna anda. Disitu, anda juga bisa mengatur opsi lainnya.", + "onboarding.page_three.search": "Gunakan kolom pencarian untuk mencari orang atau melihat hashtag, seperti {illustration} dan {introductions}. Untuk mencari pengguna yang tidak berada dalam server ini, gunakan nama pengguna mereka selengkapnya.", + "onboarding.page_two.compose": "Tulis postingan melalui kolom posting. Anda dapat mengunggah gambar, mengganti pengaturan privasi, dan menambahkan peringatan konten dengan ikon-ikon dibawah ini.", + "onboarding.skip": "Lewati", + "privacy.change": "Tentukan privasi status", + "privacy.direct.long": "Kirim hanya ke pengguna yang disebut", + "privacy.direct.short": "Langsung", + "privacy.private.long": "Kirim hanya ke pengikut", + "privacy.private.short": "Pribadi", + "privacy.public.long": "Kirim ke linimasa publik", + "privacy.public.short": "Publik", + "privacy.unlisted.long": "Tidak ditampilkan di linimasa publik", + "privacy.unlisted.short": "Tak Terdaftar", + "reply_indicator.cancel": "Batal", + "report.heading": "Laporan baru", + "report.placeholder": "Komentar tambahan", + "report.submit": "Kirim", + "report.target": "Melaporkan", + "search.status_by": "Status yang dibuat oleh {name}", + "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}", + "status.cannot_reblog": "Postingan ini tidak dapat di-boost", + "search.placeholder": "Pencarian", + "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Hapus", + "status.favourite": "Difavoritkan", + "status.load_more": "Tampilkan semua", + "status.media_hidden": "Media disembunyikan", + "status.mention": "Balasan @{name}", + "status.open": "Tampilkan status ini", + "status.reblog": "Boost", + "status.reblogged_by": "di-boost {name}", + "status.reply": "Balas", + "status.replyAll": "Balas ke semua", + "status.report": "Laporkan @{name}", + "status.sensitive_toggle": "Klik untuk menampilkan", + "status.sensitive_warning": "Konten sensitif", + "status.show_less": "Tampilkan lebih sedikit", + "status.show_more": "Tampilkan semua", + "tabs_bar.compose": "Tulis", + "tabs_bar.federated_timeline": "Gabungan", + "tabs_bar.home": "Beranda", + "tabs_bar.local_timeline": "Lokal", + "tabs_bar.notifications": "Notifikasi", + "upload_area.title": "Seret & lepaskan untuk mengunggah", + "upload_button.label": "Tambahkan media", + "upload_form.undo": "Undo", + "upload_progress.label": "Mengunggah...", + "video_player.expand": "Tampilkan video", + "video_player.toggle_sound": "Suara", + "video_player.toggle_visible": "Tampilan", + "video_player.expand": "Tampilkan video", + "video_player.video_error": "Video tidak dapat diputar" +} diff --git a/app/javascript/mastodon/locales/index.js b/app/javascript/mastodon/locales/index.js new file mode 100644 index 000000000..c4d580ff5 --- /dev/null +++ b/app/javascript/mastodon/locales/index.js @@ -0,0 +1,57 @@ +import ar from './ar.json'; +import en from './en.json'; +import de from './de.json'; +import es from './es.json'; +import fa from './fa.json'; +import he from './he.json'; +import hr from './hr.json'; +import hu from './hu.json'; +import io from './io.json'; +import it from './it.json'; +import fr from './fr.json'; +import nl from './nl.json'; +import no from './no.json'; +import oc from './oc.json'; +import pt from './pt.json'; +import pt_br from './pt-BR.json'; +import uk from './uk.json'; +import fi from './fi.json'; +import eo from './eo.json'; +import ru from './ru.json'; +import ja from './ja.json'; +import zh_hk from './zh-HK.json'; +import zh_cn from './zh-CN.json'; +import bg from './bg.json'; +import id from './id.json'; + +const locales = { + ar, + en, + de, + es, + fa, + he, + hr, + hu, + io, + it, + fr, + nl, + no, + oc, + pt, + 'pt-BR': pt_br, + uk, + fi, + eo, + ru, + ja, + 'zh-HK': zh_hk, + 'zh-CN': zh_cn, + bg, + id, +}; + +export default function getMessagesForLocale(locale) { + return locales[locale]; +}; diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json new file mode 100644 index 000000000..bcf89cfc2 --- /dev/null +++ b/app/javascript/mastodon/locales/io.json @@ -0,0 +1,163 @@ +{ + "account.block": "Blokusar @{name}", + "account.disclaimer": "Ca uzero esas de altra instaluro. Ca nombro forsan esas plu granda.", + "account.edit_profile": "Modifikar profilo", + "account.follow": "Sequar", + "account.followers": "Sequanti", + "account.follows": "Sequas", + "account.follows_you": "Sequas tu", + "account.mention": "Mencionar @{name}", + "account.mute": "Celar @{name}", + "account.posts": "Mesaji", + "account.report": "Denuncar @{name}", + "account.requested": "Vartante aprobo", + "account.unblock": "Desblokusar @{name}", + "account.unfollow": "Ne plus sequar", + "account.unmute": "Ne plus celar @{name}", + "boost_modal.combo": "Tu povas presar sur {combo} por omisar co en la venonta foyo", + "column.blocks": "Blokusita uzeri", + "column.community": "Lokala tempolineo", + "column.favourites": "Favorati", + "column.follow_requests": "Demandi di sequado", + "column.home": "Hemo", + "column.mutes": "Celita uzeri", + "column.notifications": "Savigi", + "column.public": "Federata tempolineo", + "column_back_button.label": "Retro", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Quo esas en tua spirito?", + "compose_form.privacy_disclaimer": "Tua privata mesajo livresos a mencionata uzeri en {domains}. Ka tu fidas {domainsCount, plural, one {ta servero} other {ta serveri}}? Privateso di mesaji funcionas nur en instaluri di Mastodon. Se {domains} {domainsCount, plural, one {ne esas instaluro di Mastodon} other {ne esas instaluri di Mastodon}}, esos nula indiko, ke tua mesajo esas privata, ed ol povos repetesar od altre divenar videbla da nedezirinda recevanti.", + "compose_form.publish": "Siflar", + "compose_form.sensitive": "Markizar kontenajo kom trubliva", + "compose_form.spoiler": "Celar texto dop averto", + "compose_form.spoiler_placeholder": "Averto di kontenajo", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insertar emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "La lokala tempolineo esas vakua. Skribez ulo publike por iniciar la agiveso!", + "empty_column.hashtag": "Esas ankore nulo en ta gretovorto.", + "empty_column.home": "Tu sequas ankore nulu. Vizitez {public} od uzez la serchilo por komencar e renkontrar altra uzeri.", + "empty_column.home.public_timeline": "la publika tempolineo", + "empty_column.notifications": "Tu havas ankore nula savigo. Komunikez kun altri por debutar la konverso.", + "empty_column.public": "Esas nulo hike! Skribez ulo publike, o manuale sequez uzeri de altra instaluri por plenigar ol.", + "follow_request.authorize": "Yurizar", + "follow_request.reject": "Refuzar", + "getting_started.apps": "Apliki diversa esas disponebla", + "getting_started.heading": "Debuto", + "getting_started.open_source_notice": "Mastodon esas programaro kun apertita kodexo. Tu povas kontributar o signalar problemi en GitHub ye {github}. {apps}.", + "home.column_settings.advanced": "Komplexa", + "home.column_settings.basic": "Simpla", + "home.column_settings.filter_regex": "Ekfiltrar per reguloza expresuri", + "home.column_settings.show_reblogs": "Montrar repeti", + "home.column_settings.show_replies": "Montrar respondi", + "home.settings": "Aranji di la kolumno", + "lightbox.close": "Klozar", + "loading_indicator.label": "Kargante...", + "media_gallery.toggle_visible": "Chanjar videbleso", + "missing_indicator.label": "Ne trovita", + "navigation_bar.blocks": "Blokusita uzeri", + "navigation_bar.community_timeline": "Lokala tempolineo", + "navigation_bar.edit_profile": "Modifikar profilo", + "navigation_bar.favourites": "Favorati", + "navigation_bar.follow_requests": "Demandi di sequado", + "navigation_bar.info": "Detaloza informi", + "navigation_bar.logout": "Ekirar", + "navigation_bar.mutes": "Celita uzeri", + "navigation_bar.preferences": "Preferi", + "navigation_bar.public_timeline": "Federata tempolineo", + "notification.favourite": "{name} favorizis tua mesajo", + "notification.follow": "{name} sequeskis tu", + "notification.reblog": "{name} repetis tua mesajo", + "notifications.clear": "Efacar savigi", + "notifications.clear_confirmation": "Ka tu esas certa, ke tu volas efacar omna tua savigi?", + "notifications.column_settings.alert": "Surtabla savigi", + "notifications.column_settings.favourite": "Favorati:", + "notifications.column_settings.follow": "Nova sequanti:", + "notifications.column_settings.mention": "Mencioni:", + "notifications.column_settings.reblog": "Repeti:", + "notifications.column_settings.show": "Montrar en kolumno", + "notifications.column_settings.sound": "Plear sono", + "notifications.settings": "Aranji di kolumno", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Aranjar privateso di mesaji", + "privacy.direct.long": "Sendar nur a mencionata uzeri", + "privacy.direct.short": "Direte", + "privacy.private.long": "Sendar nur a sequanti", + "privacy.private.short": "Private", + "privacy.public.long": "Sendar a publika tempolinei", + "privacy.public.short": "Publike", + "privacy.unlisted.long": "Ne montrar en publika tempolinei", + "privacy.unlisted.short": "Ne enlistigota", + "reply_indicator.cancel": "Nihiligar", + "report.heading": "Nova denunco", + "report.placeholder": "Plusa komenti", + "report.submit": "Sendar", + "report.target": "Denuncante", + "search.placeholder": "Serchez", + "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Efacar", + "status.favourite": "Favorizar", + "status.load_more": "Kargar pluse", + "status.media_hidden": "Kontenajo celita", + "status.mention": "Mencionar @{name}", + "status.open": "Detaligar ca mesajo", + "status.reblog": "Repetar", + "status.reblogged_by": "{name} repetita", + "status.reply": "Respondar", + "status.replyAll": "Respondar a filo", + "status.report": "Denuncar @{name}", + "status.sensitive_toggle": "Kliktar por vidar", + "status.sensitive_warning": "Trubliva kontenajo", + "status.show_less": "Montrar mine", + "status.show_more": "Montrar plue", + "tabs_bar.compose": "Kompozar", + "tabs_bar.federated_timeline": "Federata", + "tabs_bar.home": "Hemo", + "tabs_bar.local_timeline": "Lokala", + "tabs_bar.notifications": "Savigi", + "upload_area.title": "Tranar faligar por kargar", + "upload_button.label": "Adjuntar kontenajo", + "upload_form.undo": "Desfacar", + "upload_progress.label": "Kargante...", + "video_player.expand": "Extensar video", + "video_player.toggle_sound": "Acendar sono", + "video_player.toggle_visible": "Chanjar videbleso", + "video_player.video_error": "Video ne povus pleesar" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json new file mode 100644 index 000000000..0342f1e1f --- /dev/null +++ b/app/javascript/mastodon/locales/it.json @@ -0,0 +1,163 @@ +{ + "account.block": "Blocca @{name}", + "account.disclaimer": "Questo utente si trova su un altro server. Questo numero potrebbe essere maggiore.", + "account.edit_profile": "Modifica profilo", + "account.follow": "Segui", + "account.followers": "Seguaci", + "account.follows": "Segue", + "account.follows_you": "Ti segue", + "account.mention": "Menziona @{name}", + "account.mute": "Silenzia @{name}", + "account.posts": "Posts", + "account.report": "Segnala @{name}", + "account.requested": "In attesa di approvazione", + "account.unblock": "Sblocca @{name}", + "account.unfollow": "Non seguire", + "account.unmute": "Non silenziare @{name}", + "boost_modal.combo": "Puoi premere {combo} per saltare questo passaggio la prossima volta", + "column.blocks": "Utenti bloccati", + "column.community": "Timeline locale", + "column.favourites": "Apprezzati", + "column.follow_requests": "Richieste di amicizia", + "column.home": "Home", + "column.mutes": "Utenti silenziati", + "column.notifications": "Notifiche", + "column.public": "Timeline federata", + "column_back_button.label": "Indietro", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "A cosa stai pensando?", + "compose_form.privacy_disclaimer": "Il tuo status privato verrà condiviso con gli utenti menzionati su {domains}. Ti fidi di {domainsCount, plural, one {quel server} other {quei server}}? Le impostazioni sulla privacy valgono solo su server Mastodon. Se {domains} {domainsCount, plural, one {non è un server Mastodon} other {non sono server Mastodon}}, non ci saranno indicazioni sulla privacy del tuo status, e potrebbe essere condiviso o reso visibile a destinatari indesiderati.", + "compose_form.publish": "Toot", + "compose_form.sensitive": "Segnala file come sensibile", + "compose_form.spoiler": "Nascondi testo con avvertimento", + "compose_form.spoiler_placeholder": "Content warning", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Inserisci emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "La timeline locale è vuota. Condividi qualcosa pubblicamente per dare inizio alla festa!", + "empty_column.hashtag": "Non c'è ancora nessun post con questo hashtag.", + "empty_column.home": "Non stai ancora seguendo nessuno. Visita {public} o usa la ricerca per incontrare nuove persone.", + "empty_column.home.public_timeline": "la timeline pubblica", + "empty_column.notifications": "Non hai ancora nessuna notifica. Interagisci con altri per iniziare conversazioni.", + "empty_column.public": "Qui non c'è nulla! Scrivi qualcosa pubblicamente, o aggiungi utenti da altri server per riempire questo spazio.", + "follow_request.authorize": "Autorizza", + "follow_request.reject": "Rifiuta", + "getting_started.apps": "Sono disponibili diverse app", + "getting_started.heading": "Come iniziare", + "getting_started.open_source_notice": "Mastodon è un software open source. Puoi contribuire o segnalare errori su GitHub all'indirizzo {github}. {apps}.", + "home.column_settings.advanced": "Avanzato", + "home.column_settings.basic": "Semplice", + "home.column_settings.filter_regex": "Filtra con espressioni regolari", + "home.column_settings.show_reblogs": "Mostra post condivisi", + "home.column_settings.show_replies": "Mostra risposte", + "home.settings": "Impostazioni colonna", + "lightbox.close": "Chiudi", + "loading_indicator.label": "Carico...", + "media_gallery.toggle_visible": "Imposta visibilità", + "missing_indicator.label": "Non trovato", + "navigation_bar.blocks": "Utenti bloccati", + "navigation_bar.community_timeline": "Timeline locale", + "navigation_bar.edit_profile": "Modifica profilo", + "navigation_bar.favourites": "Apprezzati", + "navigation_bar.follow_requests": "Richieste di amicizia", + "navigation_bar.info": "Informazioni estese", + "navigation_bar.logout": "Logout", + "navigation_bar.mutes": "Utenti silenziati", + "navigation_bar.preferences": "Impostazioni", + "navigation_bar.public_timeline": "Timeline federata", + "notification.favourite": "{name} ha apprezzato il tuo post", + "notification.follow": "{name} ha iniziato a seguirti", + "notification.reblog": "{name} ha condiviso il tuo post", + "notifications.clear": "Cancella notifiche", + "notifications.clear_confirmation": "Vuoi davvero cancellare tutte le notifiche?", + "notifications.column_settings.alert": "Notifiche desktop", + "notifications.column_settings.favourite": "Apprezzati:", + "notifications.column_settings.follow": "Nuovi seguaci:", + "notifications.column_settings.mention": "Menzioni:", + "notifications.column_settings.reblog": "Post condivisi:", + "notifications.column_settings.show": "Mostra in colonna", + "notifications.column_settings.sound": "Riproduci suono", + "notifications.settings": "Impostazioni colonna", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Modifica privacy post", + "privacy.direct.long": "Invia solo a utenti menzionati", + "privacy.direct.short": "Diretto", + "privacy.private.long": "Invia solo ai seguaci", + "privacy.private.short": "Privato", + "privacy.public.long": "Invia alla timeline pubblica", + "privacy.public.short": "Pubblico", + "privacy.unlisted.long": "Non mostrare sulla timeline pubblica", + "privacy.unlisted.short": "Non elencato", + "reply_indicator.cancel": "Annulla", + "report.heading": "Nuova segnalazione", + "report.placeholder": "Commenti aggiuntivi", + "report.submit": "Invia", + "report.target": "Invio la segnalazione", + "search.placeholder": "Cerca", + "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Elimina", + "status.favourite": "Apprezzato", + "status.load_more": "Mostra di più", + "status.media_hidden": "Allegato nascosto", + "status.mention": "Nomina @{name}", + "status.open": "Espandi questo post", + "status.reblog": "Condividi", + "status.reblogged_by": "{name} ha condiviso", + "status.reply": "Rispondi", + "status.replyAll": "Reply to thread", + "status.report": "Segnala @{name}", + "status.sensitive_toggle": "Clicca per vedere", + "status.sensitive_warning": "Materiale sensibile", + "status.show_less": "Mostra meno", + "status.show_more": "Mostra di più", + "tabs_bar.compose": "Scrivi", + "tabs_bar.federated_timeline": "Federazione", + "tabs_bar.home": "Home", + "tabs_bar.local_timeline": "Locale", + "tabs_bar.notifications": "Notifiche", + "upload_area.title": "Trascina per caricare", + "upload_button.label": "Aggiungi file multimediale", + "upload_form.undo": "Annulla", + "upload_progress.label": "Sto caricando...", + "video_player.expand": "Espandi video", + "video_player.toggle_sound": "Attiva suono", + "video_player.toggle_visible": "Attiva visibilità", + "video_player.video_error": "Il video non può essere riprodotto" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json new file mode 100644 index 000000000..c9ddde083 --- /dev/null +++ b/app/javascript/mastodon/locales/ja.json @@ -0,0 +1,163 @@ +{ + "account.block": "ブロック", + "account.disclaimer": "このユーザーは他のインスタンスに所属しているため、数字が正確で無い場合があります。", + "account.edit_profile": "プロフィールを編集", + "account.follow": "フォロー", + "account.followers": "フォロワー", + "account.follows": "フォロー", + "account.follows_you": "フォローされています", + "account.mention": "返信", + "account.mute": "ミュート", + "account.posts": "投稿", + "account.report": "通報", + "account.requested": "承認待ち", + "account.unblock": "ブロック解除", + "account.unfollow": "フォロー解除", + "account.unmute": "ミュート解除", + "boost_modal.combo": "次からは{combo}を押せば、これをスキップできます。", + "column.blocks": "ブロックしたユーザー", + "column.community": "ローカルタイムライン", + "column.favourites": "お気に入り", + "column.follow_requests": "フォローリクエスト", + "column.home": "ホーム", + "column.mutes": "ミュートしたユーザー", + "column.notifications": "通知", + "column.public": "連合タイムライン", + "column_back_button.label": "戻る", + "column_subheading.navigation": "ナビゲーション", + "column_subheading.settings": "設定", + "compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。", + "compose_form.lock_disclaimer.lock": "非公開", + "compose_form.placeholder": "今なにしてる?", + "compose_form.privacy_disclaimer": "あなたの非公開トゥートは返信先ユーザーが所属する {domains} に送信されます。{domainsCount, plural, one {このサーバー} other {これらのサーバー}}は信頼できますか?投稿のプライバシー保護はMastodonサーバー内でのみ有効です。 {domains} {domainsCount, plural, one {がMastodonインスタンス} other {がMastodonインスタンス}}でない場合、あなたの投稿がプライベートなものとして扱われず、ブーストされたり予期しないユーザーに見られる可能性があります。", + "compose_form.publish": "トゥート", + "compose_form.sensitive": "メディアを閲覧注意としてマークする", + "compose_form.spoiler": "テキストを隠す", + "compose_form.spoiler_placeholder": "警告", + "confirmation_modal.cancel": "キャンセル", + "confirmations.block.confirm": "ブロック", + "confirmations.block.message": "本当に {name} をブロックしますか?", + "confirmations.delete.confirm": "削除", + "confirmations.delete.message": "本当に削除しますか?", + "confirmations.mute.confirm": "ミュート", + "confirmations.mute.message": "本当に {name} をミュートしますか?", + "emoji_button.activity": "活動", + "emoji_button.flags": "国旗", + "emoji_button.food": "食べ物", + "emoji_button.label": "絵文字を追加", + "emoji_button.nature": "自然", + "emoji_button.objects": "物", + "emoji_button.people": "人々", + "emoji_button.search": "検索...", + "emoji_button.symbols": "記号", + "emoji_button.travel": "旅行と場所", + "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!", + "empty_column.hashtag": "このハッシュタグはまだ使われていません。", + "empty_column.home": "まだ誰もフォローしていません。{public}を見に行くか、検索を使って他のユーザーを見つけましょう。", + "empty_column.home.public_timeline": "連合タイムライン", + "empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。", + "empty_column.public": "ここにはまだ何もありません!公開で何かを投稿したり、他のインスタンスのユーザーをフォローしたりしていっぱいにしましょう!", + "follow_request.authorize": "許可", + "follow_request.reject": "拒否", + "getting_started.apps": "さまざまなアプリで利用できます。", + "getting_started.heading": "スタート", + "getting_started.open_source_notice": "Mastodon はオープンソースソフトウェアです。誰でも GitHub({github})から開発に参加したり、問題を報告したりできます。 {apps}", + "home.column_settings.advanced": "上級者向け", + "home.column_settings.basic": "シンプル", + "home.column_settings.filter_regex": "正規表現でフィルター", + "home.column_settings.show_reblogs": "ブースト表示", + "home.column_settings.show_replies": "返信表示", + "home.settings": "カラム設定", + "lightbox.close": "閉じる", + "loading_indicator.label": "読み込み中...", + "media_gallery.toggle_visible": "表示切り替え", + "missing_indicator.label": "見つかりません", + "navigation_bar.blocks": "ブロックしたユーザー", + "navigation_bar.community_timeline": "ローカルタイムライン", + "navigation_bar.edit_profile": "プロフィールを編集", + "navigation_bar.favourites": "お気に入り", + "navigation_bar.follow_requests": "フォローリクエスト", + "navigation_bar.info": "サーバー情報", + "navigation_bar.logout": "ログアウト", + "navigation_bar.mutes": "ミュートしたユーザー", + "navigation_bar.preferences": "ユーザー設定", + "navigation_bar.public_timeline": "連合タイムライン", + "notification.favourite": "{name} さんがあなたのトゥートをお気に入りに登録しました", + "notification.follow": "{name} さんにフォローされました", + "notification.reblog": "{name} さんがあなたのトゥートをブーストしました", + "notifications.clear": "通知を消去", + "notifications.clear_confirmation": "本当に通知を消去しますか?", + "notifications.column_settings.alert": "デスクトップ通知", + "notifications.column_settings.favourite": "お気に入り", + "notifications.column_settings.follow": "新しいフォロワー", + "notifications.column_settings.mention": "返信", + "notifications.column_settings.reblog": "ブースト", + "notifications.column_settings.show": "カラムに表示", + "notifications.column_settings.sound": "通知音を再生", + "notifications.settings": "カラム設定", + "onboarding.done": "完了", + "onboarding.next": "次へ", + "onboarding.page_five.public_timelines": "連合タイムラインでは{domain}の人がフォローしているMastodon全体での公開投稿を表示します。同じくローカルタイムラインでは{domain}のみの公開投稿を表示します。", + "onboarding.page_four.home": "「ホーム」タイムラインではあなたがフォローしている人の投稿を表示します。", + "onboarding.page_four.notifications": "「通知」ではあなたへの他の人からの関わりを表示します。", + "onboarding.page_one.federation": "Mastodonは誰でも参加できるSNSです。", + "onboarding.page_one.handle": "あなたは今数あるMastodonインスタンスの1つである{domain}にいます。あなたのフルハンドルは{handle}です。", + "onboarding.page_one.welcome": "Mastodonへようこそ!", + "onboarding.page_six.admin": "あなたのインスタンスの管理者は{admin}です。", + "onboarding.page_six.almost_done": "以上です。", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "iOS、Androidあるいは他のプラットフォームで使える{apps}があります。", + "onboarding.page_six.github": "MastodonはOSSです。バグ報告や機能要望あるいは貢献を{github}から行なえます。", + "onboarding.page_six.guidelines": "コミュニティガイドライン", + "onboarding.page_six.read_guidelines": "{guidelines}を読むことを忘れないようにしてください。", + "onboarding.page_six.various_app": "様々なモバイルアプリ", + "onboarding.page_three.profile": "「プロフィールを編集」から、あなたの自己紹介や表示名を変更できます。またそこでは他の設定ができます。", + "onboarding.page_three.search": "検索バーで、{illustration}や{introductions}のように特定のハッシュタグの投稿を見たり、ユーザーを探したりできます。", + "onboarding.page_two.compose": "フォームから投稿できます。イメージや、公開範囲の設定や、表示時の警告の設定は下部のアイコンから行なえます。", + "onboarding.skip": "スキップ", + "privacy.change": "投稿のプライバシーを変更", + "privacy.direct.long": "メンションしたユーザーだけに公開", + "privacy.direct.short": "ダイレクト", + "privacy.private.long": "フォロワーだけに公開", + "privacy.private.short": "非公開", + "privacy.public.long": "公開TLに投稿する", + "privacy.public.short": "公開", + "privacy.unlisted.long": "公開TLで表示しない", + "privacy.unlisted.short": "未収載", + "reply_indicator.cancel": "キャンセル", + "report.heading": "新規通報", + "report.placeholder": "コメント", + "report.submit": "通報する", + "report.target": "問題のユーザー", + "search.placeholder": "検索", + "search_results.total": "{count, number} 件の結果", + "status.cannot_reblog": "この投稿はブーストできません", + "status.delete": "削除", + "status.favourite": "お気に入り", + "status.load_more": "もっと見る", + "status.media_hidden": "非表示のメデイア", + "status.mention": "返信", + "status.open": "詳細を表示", + "status.reblog": "ブースト", + "status.reblogged_by": "{name} さんにブーストされました", + "status.reply": "返信", + "status.replyAll": "全員に返信", + "status.report": "通報", + "status.sensitive_toggle": "クリックして表示", + "status.sensitive_warning": "閲覧注意", + "status.show_less": "隠す", + "status.show_more": "もっと見る", + "tabs_bar.compose": "投稿", + "tabs_bar.federated_timeline": "連合", + "tabs_bar.home": "ホーム", + "tabs_bar.local_timeline": "ローカル", + "tabs_bar.notifications": "通知", + "upload_area.title": "ドラッグ&ドロップでアップロード", + "upload_button.label": "メディアを追加", + "upload_form.undo": "やり直す", + "upload_progress.label": "アップロード中…", + "video_player.expand": "動画の詳細", + "video_player.toggle_sound": "音の切り替え", + "video_player.toggle_visible": "表示切り替え", + "video_player.video_error": "動画の再生に失敗しました" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json new file mode 100644 index 000000000..730ef246d --- /dev/null +++ b/app/javascript/mastodon/locales/nl.json @@ -0,0 +1,163 @@ +{ + "account.block": "Blokkeer @{name}", + "account.disclaimer": "This user is from another instance. This number may be larger.", + "account.edit_profile": "Profiel bewerken", + "account.follow": "Volgen", + "account.followers": "Volgers", + "account.follows": "Volgt", + "account.follows_you": "Volgt jou", + "account.mention": "Vermeld @{name}", + "account.mute": "Negeer @{name}", + "account.posts": "Berichten", + "account.report": "Rapporteer @{name}", + "account.requested": "Wacht op goedkeuring", + "account.unblock": "Deblokkeer @{name}", + "account.unfollow": "Ontvolgen", + "account.unmute": "Negeer @{name} niet meer", + "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan", + "column.blocks": "Geblokkeerde gebruikers", + "column.community": "Lokale tijdlijn", + "column.favourites": "Favorieten", + "column.follow_requests": "Follow requests", + "column.home": "Jouw tijdlijn", + "column.mutes": "Genegeerde gebruikers", + "column.notifications": "Meldingen", + "column.public": "Globale tijdlijn", + "column_back_button.label": "terug", + "column_subheading.navigation": "Navigatie", + "column_subheading.settings": "Instellingen", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Wat wil je kwijt?", + "compose_form.privacy_disclaimer": "Jouw privétoot wordt afgeleverd aan de vermelde gebruikers op {domains}. Vertrouw jij {domainsCount, plural, one {die server} other {die servers}}? Het privé plaatsen van toots werkt alleen op Mastodon-servers. Wanneer {domains} {domainsCount, plural, one {geen Mastodon-server is} other {geen Mastodon-servers zijn}}, dan wordt er niet aangegeven dat de toot privé is, waardoor het kan worden geboost of op een andere manier zichtbaar wordt gemaakt voor mensen waarvoor het niet was bedoeld.", + "compose_form.publish": "Toot", + "compose_form.sensitive": "Media als gevoelig markeren", + "compose_form.spoiler": "Tekst achter waarschuwing verbergen", + "compose_form.spoiler_placeholder": "Waarschuwingstekst", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activiteiten", + "emoji_button.flags": "Vlaggen", + "emoji_button.food": "Eten en drinken", + "emoji_button.label": "Emoji toevoegen", + "emoji_button.nature": "Natuur", + "emoji_button.objects": "Voorwerpen", + "emoji_button.people": "Mensen", + "emoji_button.search": "Zoeken...", + "emoji_button.symbols": "Symbolen", + "emoji_button.travel": "Reizen en plekken", + "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.hashtag": "There is nothing in this hashtag yet.", + "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.", + "empty_column.home.public_timeline": "the public timeline", + "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up", + "follow_request.authorize": "Authorize", + "follow_request.reject": "Reject", + "getting_started.apps": "Er zijn meerdere apps beschikbaar", + "getting_started.heading": "Beginnen", + "getting_started.open_source_notice": "Mastodon is open-sourcesoftware. Je kunt bijdragen of problemen melden op GitHub via {github}. {apps}.", + "home.column_settings.advanced": "Advanced", + "home.column_settings.basic": "Basic", + "home.column_settings.filter_regex": "Filter out by regular expressions", + "home.column_settings.show_reblogs": "Show boosts", + "home.column_settings.show_replies": "Show replies", + "home.settings": "Column settings", + "lightbox.close": "Sluiten", + "loading_indicator.label": "Laden…", + "media_gallery.toggle_visible": "Toggle visibility", + "missing_indicator.label": "Not found", + "navigation_bar.blocks": "Geblokkeerde gebruikers", + "navigation_bar.community_timeline": "Lokale tijdlijn", + "navigation_bar.edit_profile": "Profiel bewerken", + "navigation_bar.favourites": "Favorieten", + "navigation_bar.follow_requests": "Volgverzoeken", + "navigation_bar.info": "Uitgebreide informatie", + "navigation_bar.logout": "Afmelden", + "navigation_bar.mutes": "Genegeerde gebruikers", + "navigation_bar.preferences": "Instellingen", + "navigation_bar.public_timeline": "Globale tijdlijn", + "notification.favourite": "{name} markeerde jouw toot als favoriet", + "notification.follow": "{name} volgt jou nu", + "notification.reblog": "{name} boostte jouw toot", + "notifications.clear": "Meldingen verwijderen", + "notifications.clear_confirmation": "Weet je zeker dat je al jouw meldingen wilt verwijderen?", + "notifications.column_settings.alert": "Desktopmeldingen", + "notifications.column_settings.favourite": "Favorieten:", + "notifications.column_settings.follow": "Nieuwe volgers:", + "notifications.column_settings.mention": "Vermeldingen:", + "notifications.column_settings.reblog": "Boosts:", + "notifications.column_settings.show": "In kolom tonen", + "notifications.column_settings.sound": "Geluid afspelen", + "notifications.settings": "Kolom-instellingen", + "onboarding.done": "Done", + "onboarding.next": "Volgende", + "onboarding.page_five.public_timelines": "De lokale tijdlijn toont openbare toots van iedereen op {domain}. De globale tijdlijn toont openbare toots van iedereen die door gebruikers van {domain} worden gevolgd, dus ook mensen van andere Mastodon-servers. Dit zijn de openbare tijdlijnen en vormen een uitstekende manier om nieuwe mensen te ontdekken.", + "onboarding.page_four.home": "Jouw tijdlijn laat toots zien van mensen die jij volgt.", + "onboarding.page_four.notifications": "De kolom met meldingen toont alle interacties die je met andere Mastodon-gebruikers hebt.", + "onboarding.page_one.federation": "Mastodon is een netwerk van onafhankelijke servers die samen een groot sociaal netwerk vormen.", + "onboarding.page_one.handle": "Je bevindt je nu op {domain}, dus is jouw volledige Mastodon-adres {handle}", + "onboarding.page_one.welcome": "Welkom op Mastodon!", + "onboarding.page_six.admin": "De beheerder van jouw Mastodon-server is {admin}.", + "onboarding.page_six.almost_done": "Bijna klaar...", + "onboarding.page_six.appetoot": "Veel succes!", + "onboarding.page_six.apps_available": "Er zijn {apps} beschikbaar voor iOS, Android en andere platformen.", + "onboarding.page_six.github": "Mastodon kost niets, en is open-source- en vrije software. Je kan bugs melden, nieuwe mogelijkheden aanvragen en als ontwikkelaar meewerken op {github}.", + "onboarding.page_six.guidelines": "communityrichtlijnen", + "onboarding.page_six.read_guidelines": "Vergeet niet de {guidelines} van {domain} te lezen!", + "onboarding.page_six.various_app": "mobiele apps", + "onboarding.page_three.profile": "Bewerk jouw profiel om jouw avatar, bio en weergavenaam te veranderen. Daar vind je ook andere instellingen.", + "onboarding.page_three.search": "Gebruik de zoekbalk linksboven om andere mensen op Mastodon te vinden en om te zoeken op hashtags, zoals {illustration} en {introductions}. Om iemand te vinden die niet op deze Mastodon-server zit, moet je het volledige Mastodon-adres van deze persoon invoeren.", + "onboarding.page_two.compose": "Schrijf berichten (wij noemen dit toots) in het tekstvak in de linkerkolom. Je kan met de pictogrammen daaronder afbeeldingen uploaden, privacy-instellingen veranderen en je tekst een waarschuwing meegeven.", + "onboarding.skip": "Overslaan", + "privacy.change": "Privacy toot aanpassen", + "privacy.direct.long": "Toot alleen naar vermelde gebruikers", + "privacy.direct.short": "Direct", + "privacy.private.long": "Alleen aan volgers tonen", + "privacy.private.short": "Alleen volgers", + "privacy.public.long": "Op openbare tijdlijnen tonen", + "privacy.public.short": "Openbaar", + "privacy.unlisted.long": "Niet op openbare tijdlijnen tonen", + "privacy.unlisted.short": "Minder openbaar", + "reply_indicator.cancel": "Annuleren", + "report.heading": "New report", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting", + "search.placeholder": "Zoeken", + "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Verwijderen", + "status.favourite": "Favoriet", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "@{name} vermelden", + "status.open": "Expand this status", + "status.reblog": "Boost", + "status.reblogged_by": "{name} boostte", + "status.reply": "Reageren", + "status.replyAll": "Reply to thread", + "status.report": "Report @{name}", + "status.sensitive_toggle": "Klik om te zien", + "status.sensitive_warning": "Gevoelige inhoud", + "status.show_less": "Minder tonen", + "status.show_more": "Meer tonen", + "tabs_bar.compose": "Schrijven", + "tabs_bar.federated_timeline": "Federated", + "tabs_bar.home": "Jouw tijdlijn", + "tabs_bar.local_timeline": "Local", + "tabs_bar.notifications": "Meldingen", + "upload_area.title": "Drag & drop to upload", + "upload_button.label": "Media toevoegen", + "upload_form.undo": "Ongedaan maken", + "upload_progress.label": "Uploading...", + "video_player.expand": "Expand video", + "video_player.toggle_sound": "Geluid in-/uitschakelen", + "video_player.toggle_visible": "Toggle visibility", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json new file mode 100644 index 000000000..25dee25a3 --- /dev/null +++ b/app/javascript/mastodon/locales/no.json @@ -0,0 +1,163 @@ +{ + "account.block": "Blokkér @{name}", + "account.disclaimer": "Denne brukeren er fra en annen instans. Dette tallet kan være høyere.", + "account.edit_profile": "Rediger profil", + "account.follow": "Følg", + "account.followers": "Følgere", + "account.follows_you": "Følger deg", + "account.follows": "Følger", + "account.mention": "Nevn @{name}", + "account.mute": "Demp @{name}", + "account.posts": "Innlegg", + "account.report": "Rapportér @{name}", + "account.requested": "Venter på godkjennelse", + "account.unblock": "Avblokker @{name}", + "account.unfollow": "Avfølg", + "account.unmute": "Avdemp @{name}", + "boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang", + "column.blocks": "Blokkerte brukere", + "column.community": "Lokal tidslinje", + "column.favourites": "Likt", + "column.follow_requests": "Følgeforespørsler", + "column.home": "Hjem", + "column.mutes": "Muted users", + "column.notifications": "Varslinger", + "column.public": "Felles tidslinje", + "column_back_button.label": "Tilbake", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Hva har du på hjertet?", + "compose_form.privacy_disclaimer": "Din private status vil leveres til nevnte brukere på {domains}. Stoler du på {domainsCount, plural, one {den serveren} other {de serverne}}? Synlighet fungerer kun på Mastodon-instanser. Hvis {domains} {domainsCount, plural, one {ike er en Mastodon-instans} other {ikke er Mastodon-instanser}}, vil det ikke indikeres at posten din er privat, og den kan kanskje bli fremhevd eller på annen måte bli synlig for uventede mottakere.", + "compose_form.publish": "Tut", + "compose_form.sensitive": "Merk media som følsomt", + "compose_form.spoiler": "Skjul tekst bak advarsel", + "compose_form.spoiler_placeholder": "Innholdsadvarsel", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Sett inn emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "Den lokale tidslinjen er tom. Skriv noe offentlig for å få snøballen til å rulle!", + "empty_column.hashtag": "Det er ingenting i denne hashtagen ennå.", + "empty_column.home": "Du har ikke fulgt noen ennå. Besøk {publlic} eller bruk søk for å komme i gang og møte andre brukere.", + "empty_column.home.public_timeline": "en offentlig tidslinje", + "empty_column.notifications": "Du har ingen varsler ennå. Kommuniser med andre for å begynne samtalen.", + "empty_column.public": "Det er ingenting her! Skriv noe offentlig, eller følg brukere manuelt fra andre instanser for å fylle den opp", + "follow_request.authorize": "Autorisér", + "follow_request.reject": "Avvis", + "getting_started.apps": "Diverse apper er tilgjengelige", + "getting_started.heading": "Kom i gang", + "getting_started.open_source_notice": "Mastodon er fri programvare. Du kan bidra eller rapportere problemer på GitHub på {github}. {apps}.", + "home.column_settings.advanced": "Advansert", + "home.column_settings.basic": "Enkel", + "home.column_settings.filter_regex": "Filtrér med regulære uttrykk", + "home.column_settings.show_reblogs": "Vis fremhevinger", + "home.column_settings.show_replies": "Vis svar", + "home.settings": "Kolonneinnstillinger", + "lightbox.close": "Lukk", + "loading_indicator.label": "Laster...", + "media_gallery.toggle_visible": "Veksle synlighet", + "missing_indicator.label": "Ikke funnet", + "navigation_bar.blocks": "Blokkerte brukere", + "navigation_bar.community_timeline": "Lokal tidslinje", + "navigation_bar.edit_profile": "Rediger profil", + "navigation_bar.favourites": "Likt", + "navigation_bar.follow_requests": "Følgeforespørsler", + "navigation_bar.info": "Utvidet informasjon", + "navigation_bar.logout": "Logg ut", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "Preferanser", + "navigation_bar.public_timeline": "Felles tidslinje", + "notification.favourite": "{name} likte din status", + "notification.follow": "{name} fulgte deg", + "notification.reblog": "{name} fremhevde din status", + "notifications.clear": "Fjern varsler", + "notifications.clear_confirmation": "Er du sikker på at du vil fjerne alle dine varsler?", + "notifications.column_settings.alert": "Skrivebordsvarslinger", + "notifications.column_settings.favourite": "Likt:", + "notifications.column_settings.follow": "Nye følgere:", + "notifications.column_settings.mention": "Nevninger:", + "notifications.column_settings.reblog": "Fremhevinger:", + "notifications.column_settings.show": "Vis i kolonne", + "notifications.column_settings.sound": "Spill lyd", + "notifications.settings": "Kolonneinstillinger", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Justér synlighet", + "privacy.direct.long": "Post kun til nevnte brukere", + "privacy.direct.short": "Direkte", + "privacy.private.long": "Post kun til følgere", + "privacy.private.short": "Privat", + "privacy.public.long": "Post kun til offentlige tidslinjer", + "privacy.public.short": "Offentlig", + "privacy.unlisted.long": "Ikke vis i offentlige tidslinjer", + "privacy.unlisted.short": "Uoppført", + "reply_indicator.cancel": "Avbryt", + "report.heading": "Ny rapport", + "report.placeholder": "Tilleggskommentarer", + "report.submit": "Send inn", + "report.target": "Rapporterer", + "search.placeholder": "Søk", + "search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Slett", + "status.favourite": "Lik", + "status.load_more": "Last mer", + "status.media_hidden": "Media skjult", + "status.mention": "Nevn @{name}", + "status.open": "Utvid denne statusen", + "status.reblog": "Fremhev", + "status.reblogged_by": "Fremhevd av {name}", + "status.reply": "Svar", + "status.replyAll": "Reply to thread", + "status.report": "Rapporter @{name}", + "status.sensitive_toggle": "Klikk for å vise", + "status.sensitive_warning": "Følsomt innhold", + "status.show_less": "Vis mindre", + "status.show_more": "Vis mer", + "tabs_bar.compose": "Komponer", + "tabs_bar.federated_timeline": "Felles", + "tabs_bar.home": "Hjem", + "tabs_bar.local_timeline": "Lokal", + "tabs_bar.notifications": "Varslinger", + "upload_area.title": "Dra og slipp for å laste opp", + "upload_button.label": "Legg til media", + "upload_form.undo": "Angre", + "upload_progress.label": "Laster opp...", + "video_player.expand": "Utvid video", + "video_player.toggle_sound": "Veksle lyd", + "video_player.toggle_visible": "Veksle synlighet", + "video_player.video_error": "Video could not be played" +} diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json new file mode 100644 index 000000000..5a23fe9f8 --- /dev/null +++ b/app/javascript/mastodon/locales/oc.json @@ -0,0 +1,163 @@ +{ + "account.block": "Blocar", + "account.disclaimer": "Aqueste compte es sus una autra instància. Los nombres pòdon èsser mai grandes.", + "account.edit_profile": "Modificar lo perfil", + "account.follow": "Sègre", + "account.followers": "Abonats", + "account.follows": "Abonaments", + "account.follows_you": "Vos sèc", + "account.mention": "Mencionar", + "account.mute": "Rescondre", + "account.posts": "Estatuts", + "account.report": "Senhalar", + "account.requested": "Invitacion mandada", + "account.unblock": "Desblocar", + "account.unfollow": "Quitar de sègre", + "account.unmute": "Quitar de rescondre", + "boost_modal.combo": "You can press {combo} to skip this next time", + "column.blocks": "Personas blocadas", + "column.community": "Fil public local", + "column.favourites": "Favorits", + "column.follow_requests": "Demandas d’abonament", + "column.home": "Acuèlh", + "column.mutes": "Muted users", + "column.notifications": "Notificacions", + "column.public": "Fil public global", + "column_back_button.label": "Tornar", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "A de qué pensatz ?", + "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste{domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias a Mastodons. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists", + "compose_form.publish": "Tut", + "compose_form.sensitive": "Marcar lo mèdia coma embarrassant", + "compose_form.spoiler": "Rescondre lo tèxte darrièr un avertiment", + "compose_form.spoiler_placeholder": "Avertiment", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Inserir un emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "Lo fil public local es void. Escribètz quicòm per lo garnir !", + "empty_column.hashtag": "I a pas encara de contengut ligat a aqueste hashtag", + "empty_column.home": "Pel moment segètz pas segun. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.", + "empty_column.home.public_timeline": "lo fil public", + "empty_column.notifications": "Avètz pas encara de notificacions. Respondètz a qualqu’un per començar una conversacion.", + "empty_column.public": "I a pas res aquí ! Escribètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo fil public.", + "follow_request.authorize": "Autorizar", + "follow_request.reject": "Regetar", + "getting_started.apps": "Various apps are available", + "getting_started.heading": "Per començar", + "getting_started.open_source_notice": "Mastodon es un logicial liure. Podètz contribuir e mandar vòstres comentaris e rapòrt de bug via{github} sus GitHub.", + "home.column_settings.advanced": "Avançat", + "home.column_settings.basic": "Basic", + "home.column_settings.filter_regex": "Filtrar amb una expression racionala", + "home.column_settings.show_reblogs": "Mostrar los partatges", + "home.column_settings.show_replies": "Mostrar las responsas", + "home.settings": "Paramètres de la colomna", + "lightbox.close": "Tampar", + "loading_indicator.label": "Cargament…", + "media_gallery.toggle_visible": "Modificar la visibilitat", + "missing_indicator.label": "Pas trobat", + "navigation_bar.blocks": "Personas blocadas", + "navigation_bar.community_timeline": "Fil public local", + "navigation_bar.edit_profile": "Modificar lo perfil", + "navigation_bar.favourites": "Favorits", + "navigation_bar.follow_requests": "Demandas d'abonament", + "navigation_bar.info": "Mai informacions", + "navigation_bar.logout": "Desconnexion", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "Preferéncias", + "navigation_bar.public_timeline": "Fil public global", + "notification.favourite": "{name} a apondut a sos favorits :", + "notification.follow": "{name} vos sèc.", + "notification.reblog": "{name} a partejat vòstre estatut :", + "notifications.clear": "Levar", + "notifications.clear_confirmation": "Volètz vertadièrament levar totas vòstras las notificacions ?", + "notifications.column_settings.alert": "Notificacions localas", + "notifications.column_settings.favourite": "Favorits :", + "notifications.column_settings.follow": "Nòus abonats :", + "notifications.column_settings.mention": "Mencions :", + "notifications.column_settings.reblog": "Partatges :", + "notifications.column_settings.show": "Mostrar dins la colomna", + "notifications.column_settings.sound": "Emetre un son", + "notifications.settings": "Paramètres de la colomna", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Ajustar la confidencialitat del messatge", + "privacy.direct.long": "Mostrar pas qu'a las personas mencionadas", + "privacy.direct.short": "Dirècte", + "privacy.private.long": "Mostrar pas qu'a vòstres abonats", + "privacy.private.short": "Privat", + "privacy.public.long": "Mostrar dins los fils publics", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Mostrar pas dins los fils publics", + "privacy.unlisted.short": "Pas-listat", + "reply_indicator.cancel": "Anullar", + "report.heading": "Nòu senhalament", + "report.placeholder": "Comentaris addicionals", + "report.submit": "Mandat", + "report.target": "Senhalament", + "search.placeholder": "Recercar", + "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Escafar", + "status.favourite": "Apondre als favorits", + "status.load_more": "Cargar mai", + "status.media_hidden": "Mèdia rescondut", + "status.mention": "Mencionar", + "status.open": "Desplegar aqueste estatut", + "status.reblog": "Partejar", + "status.reblogged_by": "{name} a partejat :", + "status.reply": "Respondre", + "status.replyAll": "Reply to thread", + "status.report": "Senhalar @{name}", + "status.sensitive_toggle": "Clicar per mostrar", + "status.sensitive_warning": "Contengut embarrassant", + "status.show_less": "Tornar plegar", + "status.show_more": "Desplegar", + "tabs_bar.compose": "Compausar", + "tabs_bar.federated_timeline": "Fil public global", + "tabs_bar.home": "Acuèlh", + "tabs_bar.local_timeline": "Fil public local", + "tabs_bar.notifications": "Notifications", + "upload_area.title": "Lisatz e depausatz per mandar", + "upload_button.label": "Apondre un mèdia", + "upload_form.undo": "Anullar", + "upload_progress.label": "Mandadís…", + "video_player.expand": "Expand video", + "video_player.toggle_sound": "Activar/Desactivar lo son", + "video_player.toggle_visible": "Mostrar/Rescondre la vidèo", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json new file mode 100644 index 000000000..12e9f6b5f --- /dev/null +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -0,0 +1,163 @@ +{ + "account.block": "Bloquear @{name}", + "account.disclaimer": "Essa conta está localizado em outra instância. Os nomes podem ser maiores.", + "account.edit_profile": "Editar perfil", + "account.follow": "Seguir", + "account.followers": "Seguidores", + "account.follows": "Segue", + "account.follows_you": "É teu seguidor", + "account.mention": "Mencionar @{name}", + "account.mute": "Silenciar @{name}", + "account.posts": "Posts", + "account.report": "Denunciar @{name}", + "account.requested": "A aguardar aprovação", + "account.unblock": "Não bloquear @{name}", + "account.unfollow": "Deixar de seguir", + "account.unmute": "Não silenciar @{name}", + "boost_modal.combo": "Pode clicar {combo} para não voltar a ver", + "column.blocks": "Utilizadores Bloqueados", + "column.community": "Local", + "column.favourites": "Favoritos", + "column.follow_requests": "Seguidores Pendentes", + "column.home": "Home", + "column.mutes": "Utilizadores silenciados", + "column.notifications": "Notificações", + "column.public": "Global", + "column_back_button.label": "Voltar", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Em que estás a pensar?", + "compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.", + "compose_form.publish": "Publicar", + "compose_form.sensitive": "Marcar media como conteúdo sensível", + "compose_form.spoiler": "Esconder texto com aviso", + "compose_form.spoiler_placeholder": "Aviso de conteúdo", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Inserir Emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "Ainda não existem conteúdo local para mostrar!", + "empty_column.hashtag": "Ainda não existe qualquer conteúdo com essa hashtag", + "empty_column.home": "Ainda não segues qualquer utilizador. Visita {public} ou utiliza a pesquisa para procurar outros utilizadores.", + "empty_column.home.public_timeline": "global", + "empty_column.notifications": "Não tens notificações. Interage com outros utilizadores para iniciar uma conversa.", + "empty_column.public": "Não há nada aqui! Escreve algo publicamente ou segue outros utilizadores para ver aqui os conteúdos públicos.", + "follow_request.authorize": "Autorizar", + "follow_request.reject": "Rejeitar", + "getting_started.apps": "Existem várias aplicações disponíveis", + "getting_started.heading": "Primeiros passos", + "getting_started.open_source_notice": "Mastodon é software de fonte aberta. Podes contribuir ou repostar problemas no GitHub do projecto: {github}. {apps}.", + "home.column_settings.advanced": "Avançado", + "home.column_settings.basic": "Básico", + "home.column_settings.filter_regex": "Filtrar com uma expressão regular", + "home.column_settings.show_reblogs": "Mostrar as partilhas", + "home.column_settings.show_replies": "Mostrar as respostas", + "home.settings": "Parâmetros da listagem Home", + "lightbox.close": "Fechar", + "loading_indicator.label": "Carregando...", + "media_gallery.toggle_visible": "Esconder/Mostrar", + "missing_indicator.label": "Não encontrado", + "navigation_bar.blocks": "Utilizadores bloqueados", + "navigation_bar.community_timeline": "Local", + "navigation_bar.edit_profile": "Editar perfil", + "navigation_bar.favourites": "Favoritos", + "navigation_bar.follow_requests": "Seguidores pendentes", + "navigation_bar.info": "Mais informações", + "navigation_bar.logout": "Sair", + "navigation_bar.mutes": "Utilizadores silenciados", + "navigation_bar.preferences": "Preferências", + "navigation_bar.public_timeline": "Global", + "notification.favourite": "{name} adicionou o teu post aos favoritos", + "notification.follow": "{name} seguiu-te", + "notification.reblog": "{name} partilhou o teu post", + "notifications.clear": "Limpar notificações", + "notifications.clear_confirmation": "Queres mesmo limpar todas as notificações?", + "notifications.column_settings.alert": "Notificações no computador", + "notifications.column_settings.favourite": "Favoritos:", + "notifications.column_settings.follow": "Novos seguidores:", + "notifications.column_settings.mention": "Menções:", + "notifications.column_settings.reblog": "Partilhas:", + "notifications.column_settings.show": "Mostrar nas colunas", + "notifications.column_settings.sound": "Reproduzir som", + "notifications.settings": "Parâmetros da listagem de Notificações", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Ajustar a privacidade da mensagem", + "privacy.direct.long": "Apenas para utilizadores mencionados", + "privacy.direct.short": "Directo", + "privacy.private.long": "Apenas para os seguidores", + "privacy.private.short": "Privado", + "privacy.public.long": "Publicar em todos os feeds", + "privacy.public.short": "Público", + "privacy.unlisted.long": "Não publicar nos feeds públicos", + "privacy.unlisted.short": "Não listar", + "reply_indicator.cancel": "Cancelar", + "report.heading": "Nova denúncia", + "report.placeholder": "Comentários adicionais", + "report.submit": "Enviar", + "report.target": "Denunciar", + "search.placeholder": "Pesquisar", + "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Eliminar", + "status.favourite": "Adicionar aos favoritos", + "status.load_more": "Carregar mais", + "status.media_hidden": "Media escondida", + "status.mention": "Mencionar @{name}", + "status.open": "Expandir", + "status.reblog": "Partilhar", + "status.reblogged_by": "{name} partilhou", + "status.reply": "Responder", + "status.replyAll": "Reply to thread", + "status.report": "Denúnciar @{name}", + "status.sensitive_toggle": "Clique para ver", + "status.sensitive_warning": "Conteúdo sensível", + "status.show_less": "Mostrar menos", + "status.show_more": "Mostrar mais", + "tabs_bar.compose": "Criar", + "tabs_bar.federated_timeline": "Global", + "tabs_bar.home": "Home", + "tabs_bar.local_timeline": "Local", + "tabs_bar.notifications": "Notificações", + "upload_area.title": "Arraste e solte para enviar", + "upload_button.label": "Adicionar media", + "upload_form.undo": "Anular", + "upload_progress.label": "A gravar...", + "video_player.expand": "Expandir vídeo", + "video_player.toggle_sound": "Ligar/Desligar som", + "video_player.toggle_visible": "Ligar/Desligar vídeo", + "video_player.video_error": "Não é possível ver o vídeo" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json new file mode 100644 index 000000000..12e9f6b5f --- /dev/null +++ b/app/javascript/mastodon/locales/pt.json @@ -0,0 +1,163 @@ +{ + "account.block": "Bloquear @{name}", + "account.disclaimer": "Essa conta está localizado em outra instância. Os nomes podem ser maiores.", + "account.edit_profile": "Editar perfil", + "account.follow": "Seguir", + "account.followers": "Seguidores", + "account.follows": "Segue", + "account.follows_you": "É teu seguidor", + "account.mention": "Mencionar @{name}", + "account.mute": "Silenciar @{name}", + "account.posts": "Posts", + "account.report": "Denunciar @{name}", + "account.requested": "A aguardar aprovação", + "account.unblock": "Não bloquear @{name}", + "account.unfollow": "Deixar de seguir", + "account.unmute": "Não silenciar @{name}", + "boost_modal.combo": "Pode clicar {combo} para não voltar a ver", + "column.blocks": "Utilizadores Bloqueados", + "column.community": "Local", + "column.favourites": "Favoritos", + "column.follow_requests": "Seguidores Pendentes", + "column.home": "Home", + "column.mutes": "Utilizadores silenciados", + "column.notifications": "Notificações", + "column.public": "Global", + "column_back_button.label": "Voltar", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Em que estás a pensar?", + "compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.", + "compose_form.publish": "Publicar", + "compose_form.sensitive": "Marcar media como conteúdo sensível", + "compose_form.spoiler": "Esconder texto com aviso", + "compose_form.spoiler_placeholder": "Aviso de conteúdo", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Inserir Emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "Ainda não existem conteúdo local para mostrar!", + "empty_column.hashtag": "Ainda não existe qualquer conteúdo com essa hashtag", + "empty_column.home": "Ainda não segues qualquer utilizador. Visita {public} ou utiliza a pesquisa para procurar outros utilizadores.", + "empty_column.home.public_timeline": "global", + "empty_column.notifications": "Não tens notificações. Interage com outros utilizadores para iniciar uma conversa.", + "empty_column.public": "Não há nada aqui! Escreve algo publicamente ou segue outros utilizadores para ver aqui os conteúdos públicos.", + "follow_request.authorize": "Autorizar", + "follow_request.reject": "Rejeitar", + "getting_started.apps": "Existem várias aplicações disponíveis", + "getting_started.heading": "Primeiros passos", + "getting_started.open_source_notice": "Mastodon é software de fonte aberta. Podes contribuir ou repostar problemas no GitHub do projecto: {github}. {apps}.", + "home.column_settings.advanced": "Avançado", + "home.column_settings.basic": "Básico", + "home.column_settings.filter_regex": "Filtrar com uma expressão regular", + "home.column_settings.show_reblogs": "Mostrar as partilhas", + "home.column_settings.show_replies": "Mostrar as respostas", + "home.settings": "Parâmetros da listagem Home", + "lightbox.close": "Fechar", + "loading_indicator.label": "Carregando...", + "media_gallery.toggle_visible": "Esconder/Mostrar", + "missing_indicator.label": "Não encontrado", + "navigation_bar.blocks": "Utilizadores bloqueados", + "navigation_bar.community_timeline": "Local", + "navigation_bar.edit_profile": "Editar perfil", + "navigation_bar.favourites": "Favoritos", + "navigation_bar.follow_requests": "Seguidores pendentes", + "navigation_bar.info": "Mais informações", + "navigation_bar.logout": "Sair", + "navigation_bar.mutes": "Utilizadores silenciados", + "navigation_bar.preferences": "Preferências", + "navigation_bar.public_timeline": "Global", + "notification.favourite": "{name} adicionou o teu post aos favoritos", + "notification.follow": "{name} seguiu-te", + "notification.reblog": "{name} partilhou o teu post", + "notifications.clear": "Limpar notificações", + "notifications.clear_confirmation": "Queres mesmo limpar todas as notificações?", + "notifications.column_settings.alert": "Notificações no computador", + "notifications.column_settings.favourite": "Favoritos:", + "notifications.column_settings.follow": "Novos seguidores:", + "notifications.column_settings.mention": "Menções:", + "notifications.column_settings.reblog": "Partilhas:", + "notifications.column_settings.show": "Mostrar nas colunas", + "notifications.column_settings.sound": "Reproduzir som", + "notifications.settings": "Parâmetros da listagem de Notificações", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Ajustar a privacidade da mensagem", + "privacy.direct.long": "Apenas para utilizadores mencionados", + "privacy.direct.short": "Directo", + "privacy.private.long": "Apenas para os seguidores", + "privacy.private.short": "Privado", + "privacy.public.long": "Publicar em todos os feeds", + "privacy.public.short": "Público", + "privacy.unlisted.long": "Não publicar nos feeds públicos", + "privacy.unlisted.short": "Não listar", + "reply_indicator.cancel": "Cancelar", + "report.heading": "Nova denúncia", + "report.placeholder": "Comentários adicionais", + "report.submit": "Enviar", + "report.target": "Denunciar", + "search.placeholder": "Pesquisar", + "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Eliminar", + "status.favourite": "Adicionar aos favoritos", + "status.load_more": "Carregar mais", + "status.media_hidden": "Media escondida", + "status.mention": "Mencionar @{name}", + "status.open": "Expandir", + "status.reblog": "Partilhar", + "status.reblogged_by": "{name} partilhou", + "status.reply": "Responder", + "status.replyAll": "Reply to thread", + "status.report": "Denúnciar @{name}", + "status.sensitive_toggle": "Clique para ver", + "status.sensitive_warning": "Conteúdo sensível", + "status.show_less": "Mostrar menos", + "status.show_more": "Mostrar mais", + "tabs_bar.compose": "Criar", + "tabs_bar.federated_timeline": "Global", + "tabs_bar.home": "Home", + "tabs_bar.local_timeline": "Local", + "tabs_bar.notifications": "Notificações", + "upload_area.title": "Arraste e solte para enviar", + "upload_button.label": "Adicionar media", + "upload_form.undo": "Anular", + "upload_progress.label": "A gravar...", + "video_player.expand": "Expandir vídeo", + "video_player.toggle_sound": "Ligar/Desligar som", + "video_player.toggle_visible": "Ligar/Desligar vídeo", + "video_player.video_error": "Não é possível ver o vídeo" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json new file mode 100644 index 000000000..c13c95960 --- /dev/null +++ b/app/javascript/mastodon/locales/ru.json @@ -0,0 +1,163 @@ +{ + "account.block": "Блокировать", + "account.disclaimer": "Это пользователь с другого узла. Число может быть больше.", + "account.edit_profile": "Изменить профиль", + "account.follow": "Подписаться", + "account.followers": "Подписаны", + "account.follows": "Подписки", + "account.follows_you": "Подписан(а) на Вас", + "account.mention": "Упомянуть", + "account.mute": "Заглушить", + "account.posts": "Посты", + "account.report": "Пожаловаться", + "account.requested": "Ожидает подтверждения", + "account.unblock": "Разблокировать", + "account.unfollow": "Отписаться", + "account.unmute": "Снять глушение", + "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз", + "column.blocks": "Список блокировки", + "column.community": "Локальная лента", + "column.favourites": "Понравившееся", + "column.follow_requests": "Запросы на подписку", + "column.home": "Главная", + "column.mutes": "Список глушения", + "column.notifications": "Уведомления", + "column.public": "Глобальная лента", + "column_back_button.label": "Назад", + "column_subheading.navigation": "Навигация", + "column_subheading.settings": "Настройки", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "О чем Вы думаете?", + "compose_form.privacy_disclaimer": "Ваш приватный статус будет доставлен упомянутым пользователям на доменах {domains}. Доверяете ли вы {domainsCount, plural, one {этому серверу} other {этим серверам}}? Приватность постов работает только на узлах Mastodon. Если {domains} {domainsCount, plural, one {не является узлом Mastodon} other {не являются узлами Mastodon}}, приватность поста не будет указана, и он может оказаться продвинут или иным образом показан не обозначенным Вами пользователям.", + "compose_form.publish": "Трубить", + "compose_form.sensitive": "Отметить как чувствительный контент", + "compose_form.spoiler": "Скрыть текст за предупреждением", + "compose_form.spoiler_placeholder": "Предупреждение о скрытом тексте", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Занятия", + "emoji_button.flags": "Флаги", + "emoji_button.food": "Еда и напитки", + "emoji_button.label": "Вставить эмодзи", + "emoji_button.nature": "Природа", + "emoji_button.objects": "Предметы", + "emoji_button.people": "Люди", + "emoji_button.search": "Найти...", + "emoji_button.symbols": "Символы", + "emoji_button.travel": "Путешествия", + "empty_column.community": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!", + "empty_column.hashtag": "Статусов с таким хэштегом еще не существует.", + "empty_column.home": "Пока Вы ни на кого не подписаны. Полистайте {public} или используйте поиск, чтобы освоиться и завести новые знакомства.", + "empty_column.home.public_timeline": "публичные ленты", + "empty_column.notifications": "У Вас еще нет уведомлений. Заведите знакомство с другими пользователями, чтобы начать разговор.", + "empty_column.public": "Здесь ничего нет! Опубликуйте что-нибудь или подпишитесь на пользователей с других узлов, чтобы заполнить ленту.", + "follow_request.authorize": "Авторизовать", + "follow_request.reject": "Отказать", + "getting_started.apps": "Доступны различные приложения.", + "getting_started.heading": "Добро пожаловать", + "getting_started.open_source_notice": "Mastodon - программа с открытым исходным кодом. Вы можете помочь проекту или сообщить о проблемах на GitHub по адресу {github}. {apps}.", + "home.column_settings.advanced": "Дополнительные", + "home.column_settings.basic": "Основные", + "home.column_settings.filter_regex": "Отфильтровать регулярным выражением", + "home.column_settings.show_reblogs": "Показывать продвижения", + "home.column_settings.show_replies": "Показывать ответы", + "home.settings": "Настройки колонки", + "lightbox.close": "Закрыть", + "loading_indicator.label": "Загрузка...", + "media_gallery.toggle_visible": "Показать/скрыть", + "missing_indicator.label": "Не найдено", + "navigation_bar.blocks": "Список блокировки", + "navigation_bar.community_timeline": "Локальная лента", + "navigation_bar.edit_profile": "Изменить профиль", + "navigation_bar.favourites": "Понравившееся", + "navigation_bar.follow_requests": "Запросы на подписку", + "navigation_bar.info": "Об узле", + "navigation_bar.logout": "Выйти", + "navigation_bar.mutes": "Список глушения", + "navigation_bar.preferences": "Опции", + "navigation_bar.public_timeline": "Глобальная лента", + "notification.favourite": "{name} понравился Ваш статус", + "notification.follow": "{name} подписался(-лась) на Вас", + "notification.reblog": "{name} продвинул(а) Ваш статус", + "notifications.clear": "Очистить уведомления", + "notifications.clear_confirmation": "Вы уверены, что хотите очистить все уведомления?", + "notifications.column_settings.alert": "Десктопные уведомления", + "notifications.column_settings.favourite": "Нравится:", + "notifications.column_settings.follow": "Новые подписчики:", + "notifications.column_settings.mention": "Упоминания:", + "notifications.column_settings.reblog": "Продвижения:", + "notifications.column_settings.show": "Показывать в колонке", + "notifications.column_settings.sound": "Проигрывать звук", + "notifications.settings": "Настройки колонки", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Изменить видимость статуса", + "privacy.direct.long": "Показать только упомянутым", + "privacy.direct.short": "Направленный", + "privacy.private.long": "Показать только подписчикам", + "privacy.private.short": "Приватный", + "privacy.public.long": "Показать в публичных лентах", + "privacy.public.short": "Публичный", + "privacy.unlisted.long": "Не показывать в лентах", + "privacy.unlisted.short": "Скрытый", + "reply_indicator.cancel": "Отмена", + "report.heading": "Новая жалоба", + "report.placeholder": "Комментарий", + "report.submit": "Отправить", + "report.target": "Жалуемся на", + "search.placeholder": "Поиск", + "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}", + "status.cannot_reblog": "Этот статус не может быть продвинут", + "status.delete": "Удалить", + "status.favourite": "Нравится", + "status.load_more": "Показать еще", + "status.media_hidden": "Медиаконтент скрыт", + "status.mention": "Упомянуть @{name}", + "status.open": "Развернуть статус", + "status.reblog": "Продвинуть", + "status.reblogged_by": "{name} продвинул(а)", + "status.reply": "Ответить", + "status.replyAll": "Ответить на тред", + "status.report": "Пожаловаться", + "status.sensitive_toggle": "Нажмите для просмотра", + "status.sensitive_warning": "Чувствительный контент", + "status.show_less": "Свернуть", + "status.show_more": "Развернуть", + "tabs_bar.compose": "Написать", + "tabs_bar.federated_timeline": "Глобальная", + "tabs_bar.home": "Главная", + "tabs_bar.local_timeline": "Локальная", + "tabs_bar.notifications": "Уведомления", + "upload_area.title": "Перетащите сюда, чтобы загрузить", + "upload_button.label": "Добавить медиаконтент", + "upload_form.undo": "Отменить", + "upload_progress.label": "Загрузка...", + "video_player.expand": "Развернуть видео", + "video_player.toggle_sound": "Вкл./выкл. звук", + "video_player.toggle_visible": "Показать/скрыть", + "video_player.video_error": "Видео не может быть проиграно" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json new file mode 100644 index 000000000..fde28871d --- /dev/null +++ b/app/javascript/mastodon/locales/uk.json @@ -0,0 +1,163 @@ +{ + "account.block": "Заблокувати", + "account.disclaimer": "This user is from another instance. This number may be larger.", + "account.edit_profile": "Налаштування профілю", + "account.follow": "Підписатися", + "account.followers": "Підписники", + "account.follows": "Підписки", + "account.follows_you": "Підписаний", + "account.mention": "Згадати", + "account.mute": "Mute @{name}", + "account.posts": "Пости", + "account.report": "Report @{name}", + "account.requested": "Awaiting approval", + "account.unblock": "Розблокувати", + "account.unfollow": "Відписатися", + "account.unmute": "Unmute @{name}", + "boost_modal.combo": "You can press {combo} to skip this next time", + "column.blocks": "Blocked users", + "column.community": "Local timeline", + "column.favourites": "Favourites", + "column.follow_requests": "Follow requests", + "column.home": "Головна", + "column.mutes": "Muted users", + "column.notifications": "Сповіщення", + "column.public": "Стіна", + "column_back_button.label": "Назад", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "Що у Вас на думці?", + "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", + "compose_form.publish": "Дмухнути", + "compose_form.sensitive": "Непристойний зміст", + "compose_form.spoiler": "Hide text behind warning", + "compose_form.spoiler_placeholder": "Content warning", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "Insert emoji", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.hashtag": "There is nothing in this hashtag yet.", + "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.", + "empty_column.home.public_timeline": "the public timeline", + "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", + "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up", + "follow_request.authorize": "Authorize", + "follow_request.reject": "Reject", + "getting_started.apps": "Various apps are available", + "getting_started.heading": "Ласкаво просимо", + "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.", + "home.column_settings.advanced": "Advanced", + "home.column_settings.basic": "Basic", + "home.column_settings.filter_regex": "Filter out by regular expressions", + "home.column_settings.show_reblogs": "Show boosts", + "home.column_settings.show_replies": "Show replies", + "home.settings": "Column settings", + "lightbox.close": "Закрити", + "loading_indicator.label": "Завантаження...", + "media_gallery.toggle_visible": "Toggle visibility", + "missing_indicator.label": "Not found", + "navigation_bar.blocks": "Blocked users", + "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.edit_profile": "Редагувати профіль", + "navigation_bar.favourites": "Favourites", + "navigation_bar.follow_requests": "Follow requests", + "navigation_bar.info": "Extended information", + "navigation_bar.logout": "Вийти", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "Налаштування", + "navigation_bar.public_timeline": "Публічна стіна", + "notification.favourite": "{name} сподобався ваш допис", + "notification.follow": "{name} підписався(-лась) на Вас", + "notification.reblog": "{name} передмухнув(-ла) Ваш статус", + "notifications.clear": "Clear notifications", + "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", + "notifications.column_settings.alert": "Desktop notifications", + "notifications.column_settings.favourite": "Favourites:", + "notifications.column_settings.follow": "New followers:", + "notifications.column_settings.mention": "Mentions:", + "notifications.column_settings.reblog": "Boosts:", + "notifications.column_settings.show": "Show in column", + "notifications.column_settings.sound": "Play sound", + "notifications.settings": "Column settings", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "Adjust status privacy", + "privacy.direct.long": "Post to mentioned users only", + "privacy.direct.short": "Direct", + "privacy.private.long": "Post to followers only", + "privacy.private.short": "Followers-only", + "privacy.public.long": "Post to public timelines", + "privacy.public.short": "Public", + "privacy.unlisted.long": "Do not show in public timelines", + "privacy.unlisted.short": "Unlisted", + "reply_indicator.cancel": "Відмінити", + "report.heading": "New report", + "report.placeholder": "Additional comments", + "report.submit": "Submit", + "report.target": "Reporting", + "search.placeholder": "Пошук", + "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "Видалити", + "status.favourite": "Подобається", + "status.load_more": "Load more", + "status.media_hidden": "Media hidden", + "status.mention": "Згадати", + "status.open": "Expand this status", + "status.reblog": "Передмухнути", + "status.reblogged_by": "{name} передмухнув(-ла)", + "status.reply": "Відповісти", + "status.replyAll": "Reply to thread", + "status.report": "Report @{name}", + "status.sensitive_toggle": "Натисніть, щоб подивитися", + "status.sensitive_warning": "Непристойний зміст", + "status.show_less": "Show less", + "status.show_more": "Show more", + "tabs_bar.compose": "Написати", + "tabs_bar.federated_timeline": "Federated", + "tabs_bar.home": "Головна", + "tabs_bar.local_timeline": "Local", + "tabs_bar.notifications": "Сповіщення", + "upload_area.title": "Drag & drop to upload", + "upload_button.label": "Додати медіа", + "upload_form.undo": "Відмінити", + "upload_progress.label": "Uploading...", + "video_player.expand": "Expand video", + "video_player.toggle_sound": "Увімкнути/вимкнути звук", + "video_player.toggle_visible": "Toggle visibility", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_ar.json b/app/javascript/mastodon/locales/whitelist_ar.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_ar.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_bg.json b/app/javascript/mastodon/locales/whitelist_bg.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_bg.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_de.json b/app/javascript/mastodon/locales/whitelist_de.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_de.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_en.json b/app/javascript/mastodon/locales/whitelist_en.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_en.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_eo.json b/app/javascript/mastodon/locales/whitelist_eo.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_eo.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_es.json b/app/javascript/mastodon/locales/whitelist_es.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_es.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_fa.json b/app/javascript/mastodon/locales/whitelist_fa.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_fa.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_fi.json b/app/javascript/mastodon/locales/whitelist_fi.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_fi.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_fr.json b/app/javascript/mastodon/locales/whitelist_fr.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_fr.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_hr.json b/app/javascript/mastodon/locales/whitelist_hr.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_hr.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_hu.json b/app/javascript/mastodon/locales/whitelist_hu.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_hu.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_id.json b/app/javascript/mastodon/locales/whitelist_id.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_id.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_io.json b/app/javascript/mastodon/locales/whitelist_io.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_io.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_it.json b/app/javascript/mastodon/locales/whitelist_it.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_it.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_ja.json b/app/javascript/mastodon/locales/whitelist_ja.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_ja.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_nl.json b/app/javascript/mastodon/locales/whitelist_nl.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_nl.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_no.json b/app/javascript/mastodon/locales/whitelist_no.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_no.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_oc.json b/app/javascript/mastodon/locales/whitelist_oc.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_oc.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_pt-BR.json b/app/javascript/mastodon/locales/whitelist_pt-BR.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_pt-BR.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_pt.json b/app/javascript/mastodon/locales/whitelist_pt.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_pt.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_ru.json b/app/javascript/mastodon/locales/whitelist_ru.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_ru.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_uk.json b/app/javascript/mastodon/locales/whitelist_uk.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_uk.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_zh-CN.json b/app/javascript/mastodon/locales/whitelist_zh-CN.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_zh-CN.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/whitelist_zh-HK.json b/app/javascript/mastodon/locales/whitelist_zh-HK.json new file mode 100644 index 000000000..32960f8ce --- /dev/null +++ b/app/javascript/mastodon/locales/whitelist_zh-HK.json @@ -0,0 +1,2 @@ +[ +] \ No newline at end of file diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json new file mode 100644 index 000000000..1e0d1fa58 --- /dev/null +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -0,0 +1,163 @@ +{ + "account.block": "屏蔽 @{name}", + "account.disclaimer": "由于这个账户处于另一个服务站,实际数字会比这个更多。", + "account.edit_profile": "修改个人资料", + "account.follow": "关注", + "account.followers": "关注者", + "account.follows": "正关注", + "account.follows_you": "关注你", + "account.mention": "提及 @{name}", + "account.mute": "将 @{name} 静音", + "account.posts": "嘟文", + "account.report": "举报 @{name}", + "account.requested": "等候审批", + "account.unblock": "解除对 @{name} 的屏蔽", + "account.unfollow": "取消关注", + "account.unmute": "取消 @{name} 的静音", + "boost_modal.combo": "如你想在下次路过时显示,请按{combo},", + "column.blocks": "屏蔽用户", + "column.community": "本站时间轴", + "column.favourites": "赞过的嘟文", + "column.follow_requests": "关注请求", + "column.home": "主页", + "column.mutes": "Muted users", + "column.notifications": "通知", + "column.public": "跨站公共时间轴", + "column_back_button.label": "返回", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "在想啥?", + "compose_form.privacy_disclaimer": "你的私人嘟文,将被发送至你所提及的 {domains} 用户。你是否信任{domainsCount, plural, one {这个网站} other {这些网站}}?请留意,嘟文隐私设置只适用于各 Mastodon 服务站,如果 {domains} {domainsCount, plural, one {不是 Mastodon 服务站} other {之中有些不是 Mastodon 服务站}},对方将无法收到这篇嘟文的隐私设置,然后可能被转嘟给不能预知的用户阅读。", + "compose_form.publish": "嘟嘟", + "compose_form.sensitive": "将媒体文件标示为“敏感内容”", + "compose_form.spoiler": "将部分文本藏于警告消息之后", + "compose_form.spoiler_placeholder": "敏感内容的警告消息", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "加入表情符号", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "本站时间轴暂时未有内容,快贴文来抢头香啊!", + "empty_column.hashtag": "这个标签暂时未有内容。", + "empty_column.home": "你还没有关注任何用户。快看看{public},向其他用户搭讪吧。", + "empty_column.home.public_timeline": "公共时间轴", + "empty_column.notifications": "你没有任何通知纪录,快向其他用户搭讪吧。", + "empty_column.public": "跨站公共时间轴暂时没有内容!快写一些公共的嘟文,或者关注另一些服务站的用户吧!你和本站、友站的交流,将决定这里出现的内容。", + "follow_request.authorize": "批准", + "follow_request.reject": "拒绝", + "getting_started.apps": "手机或桌面应用程序", + "getting_started.heading": "开始使用", + "getting_started.open_source_notice": "Mastodon 是一个开放源码的软件。你可以在官方 GitHub ({github}) 贡献或者回报问题。你亦可通过{apps}阅读 Mastodon 上的消息。", + "home.column_settings.advanced": "高端", + "home.column_settings.basic": "基本", + "home.column_settings.filter_regex": "使用正则表达式 (regex) 过滤", + "home.column_settings.show_reblogs": "显示被转的嘟文", + "home.column_settings.show_replies": "显示回应嘟文", + "home.settings": "字段设置", + "lightbox.close": "关闭", + "loading_indicator.label": "加载中……", + "media_gallery.toggle_visible": "打开或关上", + "missing_indicator.label": "找不到内容", + "navigation_bar.blocks": "被屏蔽的用户", + "navigation_bar.community_timeline": "本站时间轴", + "navigation_bar.edit_profile": "修改个人资料", + "navigation_bar.favourites": "赞的内容", + "navigation_bar.follow_requests": "关注请求", + "navigation_bar.info": "关于本服务站", + "navigation_bar.logout": "注销", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "首选项", + "navigation_bar.public_timeline": "跨站公共时间轴", + "notification.favourite": "{name} 赞你的嘟文", + "notification.follow": "{name} 开始关注你", + "notification.reblog": "{name} 转嘟你的嘟文", + "notifications.clear": "清空通知纪录", + "notifications.clear_confirmation": "你确定要清空通知纪录吗?", + "notifications.column_settings.alert": "显示桌面通知", + "notifications.column_settings.favourite": "赞你的嘟文:", + "notifications.column_settings.follow": "关注你:", + "notifications.column_settings.mention": "提及你:", + "notifications.column_settings.reblog": "转你的嘟文:", + "notifications.column_settings.show": "在通知栏显示", + "notifications.column_settings.sound": "播放音效", + "notifications.settings": "字段设置", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "调整隐私设置", + "privacy.direct.long": "只有提及的用户能看到", + "privacy.direct.short": "私人消息", + "privacy.private.long": "只有关注你用户能看到", + "privacy.private.short": "关注者", + "privacy.public.long": "在公共时间轴显示", + "privacy.public.short": "公共", + "privacy.unlisted.long": "公开,但不在公共时间轴显示", + "privacy.unlisted.short": "公开", + "reply_indicator.cancel": "取消", + "report.heading": "举报", + "report.placeholder": "额外消息", + "report.submit": "提交", + "report.target": "Reporting", + "search.placeholder": "搜索", + "search_results.total": "{count, number} 项结果", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "删除", + "status.favourite": "赞", + "status.load_more": "加载更多", + "status.media_hidden": "隐藏媒体内容", + "status.mention": "提及 @{name}", + "status.open": "展开嘟文", + "status.reblog": "转嘟", + "status.reblogged_by": "{name} 转嘟", + "status.reply": "回应", + "status.replyAll": "Reply to thread", + "status.report": "举报 @{name}", + "status.sensitive_toggle": "点击显示", + "status.sensitive_warning": "敏感内容", + "status.show_less": "减少显示", + "status.show_more": "显示更多", + "tabs_bar.compose": "撰写", + "tabs_bar.federated_timeline": "跨站", + "tabs_bar.home": "主页", + "tabs_bar.local_timeline": "本站", + "tabs_bar.notifications": "通知", + "upload_area.title": "将文件拖放至此上传", + "upload_button.label": "上传媒体文件", + "upload_form.undo": "还原", + "upload_progress.label": "上传中……", + "video_player.expand": "展开影片", + "video_player.toggle_sound": "开关音效", + "video_player.toggle_visible": "打开或关上", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json new file mode 100644 index 000000000..772b7f8fb --- /dev/null +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -0,0 +1,163 @@ +{ + "account.block": "封鎖 @{name}", + "account.disclaimer": "由於這個用戶在另一個服務站,實際數字會比這個更多。", + "account.edit_profile": "修改個人資料", + "account.follow": "關注", + "account.followers": "關注的人", + "account.follows": "正在關注", + "account.follows_you": "關注你", + "account.mention": "提及 @{name}", + "account.mute": "將 @{name} 靜音", + "account.posts": "文章", + "account.report": "舉報 @{name}", + "account.requested": "等候審批", + "account.unblock": "解除對 @{name} 的封鎖", + "account.unfollow": "取消關注", + "account.unmute": "取消 @{name} 的靜音", + "boost_modal.combo": "如你想在下次路過這顯示,請按{combo},", + "column.blocks": "封鎖用戶", + "column.community": "本站時間軸", + "column.favourites": "喜歡的文章", + "column.follow_requests": "關注請求", + "column.home": "主頁", + "column.mutes": "Muted users", + "column.notifications": "通知", + "column.public": "跨站公共時間軸", + "column_back_button.label": "返回", + "column_subheading.navigation": "Navigation", + "column_subheading.settings": "Settings", + "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "compose_form.lock_disclaimer.lock": "locked", + "compose_form.placeholder": "你在想甚麼?", + "compose_form.privacy_disclaimer": "你的私人文章,將被遞送至你所提及的 {domains} 用戶。你是否信任{domainsCount, plural, one {這個網站} other {這些網站}}?請留意,文章私隱設定只適用於各 Mastodon 服務站,如果 {domains} {domainsCount, plural, one {不是 Mastodon 服務站} other {之中有些不是 Mastodon 服務站}},對方將無法收到這篇文章的私隱設定,然後可能被轉推給不能預知的用戶閱讀。", + "compose_form.publish": "發文", + "compose_form.sensitive": "將媒體檔案標示為「敏感內容」", + "compose_form.spoiler": "將部份文字藏於警告訊息之後", + "compose_form.spoiler_placeholder": "敏感警告訊息", + "confirmation_modal.cancel": "Cancel", + "confirmations.block.confirm": "Block", + "confirmations.block.message": "Are you sure you want to block {name}?", + "confirmations.delete.confirm": "Delete", + "confirmations.delete.message": "Are you sure you want to delete this status?", + "confirmations.mute.confirm": "Mute", + "confirmations.mute.message": "Are you sure you want to mute {name}?", + "emoji_button.activity": "Activity", + "emoji_button.flags": "Flags", + "emoji_button.food": "Food & Drink", + "emoji_button.label": "加入表情符號", + "emoji_button.nature": "Nature", + "emoji_button.objects": "Objects", + "emoji_button.people": "People", + "emoji_button.search": "Search...", + "emoji_button.symbols": "Symbols", + "emoji_button.travel": "Travel & Places", + "empty_column.community": "本站時間軸暫時未有內容,快貼文來搶頭香啊!", + "empty_column.hashtag": "這個標籤暫時未有內容。", + "empty_column.home": "你還沒有關注任何用戶。快看看{public},向其他用戶搭訕吧。", + "empty_column.home.public_timeline": "公共時間軸", + "empty_column.notifications": "你沒有任何通知紀錄,快向其他用戶搭訕吧。", + "empty_column.public": "跨站公共時間軸暫時沒有內容!快寫一些公共的文章,或者關注另一些服務站的用戶吧!你和本站、友站的交流,將決定這裏出現的內容。", + "follow_request.authorize": "批准", + "follow_request.reject": "拒絕", + "getting_started.apps": "手機或桌面應用程式", + "getting_started.heading": "開始使用", + "getting_started.open_source_notice": "Mastodon 是一個開放源碼的軟件。你可以在官方 GitHub ({github}) 貢獻或者回報問題。你亦可透過{apps}閱讀 Mastodon 上的消息。", + "home.column_settings.advanced": "進階", + "home.column_settings.basic": "基本", + "home.column_settings.filter_regex": "使用正規表達式 (regular expression) 過濾", + "home.column_settings.show_reblogs": "顯示被轉推的文章", + "home.column_settings.show_replies": "顯示回應文章", + "home.settings": "欄位設定", + "lightbox.close": "Close", + "loading_indicator.label": "載入中...", + "media_gallery.toggle_visible": "打開或關上", + "missing_indicator.label": "找不到內容", + "navigation_bar.blocks": "被封鎖的用戶", + "navigation_bar.community_timeline": "本站時間軸", + "navigation_bar.edit_profile": "修改個人資料", + "navigation_bar.favourites": "喜歡的內容", + "navigation_bar.follow_requests": "關注請求", + "navigation_bar.info": "關於本服務站", + "navigation_bar.logout": "登出", + "navigation_bar.mutes": "Muted users", + "navigation_bar.preferences": "偏好設定", + "navigation_bar.public_timeline": "跨站公共時間軸", + "notification.favourite": "{name} 喜歡你的文章", + "notification.follow": "{name} 開始關注你", + "notification.reblog": "{name} 轉推你的文章", + "notifications.clear": "清空通知紀錄", + "notifications.clear_confirmation": "你確定要清空通知紀錄嗎?", + "notifications.column_settings.alert": "顯示桌面通知", + "notifications.column_settings.favourite": "喜歡你的文章:", + "notifications.column_settings.follow": "關注你:", + "notifications.column_settings.mention": "提及你:", + "notifications.column_settings.reblog": "轉推你的文章:", + "notifications.column_settings.show": "在通知欄顯示", + "notifications.column_settings.sound": "播放音效", + "notifications.settings": "欄位設定", + "onboarding.done": "Done", + "onboarding.next": "Next", + "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.", + "onboarding.page_four.home": "The home timeline shows posts from people you follow.", + "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", + "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.welcome": "Welcome to Mastodon!", + "onboarding.page_six.admin": "Your instance's admin is {admin}.", + "onboarding.page_six.almost_done": "Almost done...", + "onboarding.page_six.appetoot": "Bon Appetoot!", + "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.", + "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.", + "onboarding.page_six.guidelines": "community guidelines", + "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!", + "onboarding.page_six.various_app": "mobile apps", + "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.", + "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", + "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", + "onboarding.skip": "Skip", + "privacy.change": "調整私隱設定", + "privacy.direct.long": "只有提及的用戶能看到", + "privacy.direct.short": "私人訊息", + "privacy.private.long": "只有關注你用戶能看到", + "privacy.private.short": "關注者", + "privacy.public.long": "在公共時間軸顯示", + "privacy.public.short": "公共", + "privacy.unlisted.long": "公開,但不在公共時間軸顯示", + "privacy.unlisted.short": "公開", + "reply_indicator.cancel": "取消", + "report.heading": "舉報", + "report.placeholder": "額外訊息", + "report.submit": "提交", + "report.target": "Reporting", + "search.placeholder": "搜尋", + "search_results.total": "{count, number} 項結果", + "status.cannot_reblog": "This post cannot be boosted", + "status.delete": "刪除", + "status.favourite": "喜歡", + "status.load_more": "載入更多", + "status.media_hidden": "隱藏媒體內容", + "status.mention": "提及 @{name}", + "status.open": "展開文章", + "status.reblog": "轉推", + "status.reblogged_by": "{name} 轉推", + "status.reply": "回應", + "status.replyAll": "Reply to thread", + "status.report": "舉報 @{name}", + "status.sensitive_toggle": "點擊顯示", + "status.sensitive_warning": "敏感內容", + "status.show_less": "減少顯示", + "status.show_more": "顯示更多", + "tabs_bar.compose": "撰寫", + "tabs_bar.federated_timeline": "跨站", + "tabs_bar.home": "主頁", + "tabs_bar.local_timeline": "本站", + "tabs_bar.notifications": "通知", + "upload_area.title": "將檔案拖放至此上載", + "upload_button.label": "上載媒體檔案", + "upload_form.undo": "還原", + "upload_progress.label": "上載中……", + "video_player.expand": "展開影片", + "video_player.toggle_sound": "開關音效", + "video_player.toggle_visible": "打開或關上", + "video_player.video_error": "Video could not be played" +} \ No newline at end of file diff --git a/app/javascript/mastodon/middleware/errors.js b/app/javascript/mastodon/middleware/errors.js new file mode 100644 index 000000000..9a51257cb --- /dev/null +++ b/app/javascript/mastodon/middleware/errors.js @@ -0,0 +1,33 @@ +import { showAlert } from '../actions/alerts'; + +const defaultSuccessSuffix = 'SUCCESS'; +const defaultFailSuffix = 'FAIL'; + +export default function errorsMiddleware() { + return ({ dispatch }) => next => action => { + if (action.type && !action.skipAlert) { + const isFail = new RegExp(`${defaultFailSuffix}$`, 'g'); + const isSuccess = new RegExp(`${defaultSuccessSuffix}$`, 'g'); + + if (action.type.match(isFail)) { + if (action.error.response) { + const { data, status, statusText } = action.error.response; + + let message = statusText; + let title = `${status}`; + + if (data.error) { + message = data.error; + } + + dispatch(showAlert(title, message)); + } else { + console.error(action.error); // eslint-disable-line no-console + dispatch(showAlert('Oops!', 'An unexpected error occurred.')); + } + } + } + + return next(action); + }; +}; diff --git a/app/javascript/mastodon/middleware/loading_bar.js b/app/javascript/mastodon/middleware/loading_bar.js new file mode 100644 index 000000000..a98f1bb2b --- /dev/null +++ b/app/javascript/mastodon/middleware/loading_bar.js @@ -0,0 +1,25 @@ +import { showLoading, hideLoading } from 'react-redux-loading-bar'; + +const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED']; + +export default function loadingBarMiddleware(config = {}) { + const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes; + + return ({ dispatch }) => next => (action) => { + if (action.type && !action.skipLoading) { + const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes; + + const isPending = new RegExp(`${PENDING}$`, 'g'); + const isFulfilled = new RegExp(`${FULFILLED}$`, 'g'); + const isRejected = new RegExp(`${REJECTED}$`, 'g'); + + if (action.type.match(isPending)) { + dispatch(showLoading()); + } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) { + dispatch(hideLoading()); + } + } + + return next(action); + }; +}; diff --git a/app/javascript/mastodon/middleware/sounds.js b/app/javascript/mastodon/middleware/sounds.js new file mode 100644 index 000000000..200efa3d7 --- /dev/null +++ b/app/javascript/mastodon/middleware/sounds.js @@ -0,0 +1,22 @@ +const play = audio => { + if (!audio.paused) { + audio.pause(); + audio.fastSeek(0); + } + + audio.play(); +}; + +export default function soundsMiddleware() { + const soundCache = { + boop: new Audio(['/sounds/boop.mp3']) + }; + + return ({ dispatch }) => next => (action) => { + if (action.meta && action.meta.sound && soundCache[action.meta.sound]) { + play(soundCache[action.meta.sound]); + } + + return next(action); + }; +}; diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js new file mode 100644 index 000000000..b3c2b6d88 --- /dev/null +++ b/app/javascript/mastodon/reducers/accounts.js @@ -0,0 +1,133 @@ +import { + ACCOUNT_FETCH_SUCCESS, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + ACCOUNT_TIMELINE_FETCH_SUCCESS, + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS +} from '../actions/accounts'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS +} from '../actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS +} from '../actions/mutes'; +import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; +import { + REBLOG_SUCCESS, + UNREBLOG_SUCCESS, + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS +} from '../actions/interactions'; +import { + TIMELINE_REFRESH_SUCCESS, + TIMELINE_UPDATE, + TIMELINE_EXPAND_SUCCESS +} from '../actions/timelines'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS +} from '../actions/statuses'; +import { SEARCH_FETCH_SUCCESS } from '../actions/search'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS +} from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const normalizeAccount = (state, account) => { + account = { ...account }; + + delete account.followers_count; + delete account.following_count; + delete account.statuses_count; + + return state.set(account.id, Immutable.fromJS(account)) +}; + +const normalizeAccounts = (state, accounts) => { + accounts.forEach(account => { + state = normalizeAccount(state, account); + }); + + return state; +}; + +const normalizeAccountFromStatus = (state, status) => { + state = normalizeAccount(state, status.account); + + if (status.reblog && status.reblog.account) { + state = normalizeAccount(state, status.reblog.account); + } + + return state; +}; + +const normalizeAccountsFromStatuses = (state, statuses) => { + statuses.forEach(status => { + state = normalizeAccountFromStatus(state, status); + }); + + return state; +}; + +const initialState = Immutable.Map(); + +export default function accounts(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('accounts')); + case ACCOUNT_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeAccount(state, action.account); + case FOLLOWERS_FETCH_SUCCESS: + case FOLLOWERS_EXPAND_SUCCESS: + case FOLLOWING_FETCH_SUCCESS: + case FOLLOWING_EXPAND_SUCCESS: + case REBLOGS_FETCH_SUCCESS: + case FAVOURITES_FETCH_SUCCESS: + case COMPOSE_SUGGESTIONS_READY: + case FOLLOW_REQUESTS_FETCH_SUCCESS: + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + case BLOCKS_FETCH_SUCCESS: + case BLOCKS_EXPAND_SUCCESS: + case MUTES_FETCH_SUCCESS: + case MUTES_EXPAND_SUCCESS: + return normalizeAccounts(state, action.accounts); + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(state, action.statuses); + case REBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNREBLOG_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeAccountFromStatus(state, action.response); + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + return normalizeAccountFromStatus(state, action.status); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js new file mode 100644 index 000000000..2afc6c3d9 --- /dev/null +++ b/app/javascript/mastodon/reducers/accounts_counters.js @@ -0,0 +1,135 @@ +import { + ACCOUNT_FETCH_SUCCESS, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + ACCOUNT_TIMELINE_FETCH_SUCCESS, + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS +} from '../actions/accounts'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS +} from '../actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS +} from '../actions/mutes'; +import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; +import { + REBLOG_SUCCESS, + UNREBLOG_SUCCESS, + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS +} from '../actions/interactions'; +import { + TIMELINE_REFRESH_SUCCESS, + TIMELINE_UPDATE, + TIMELINE_EXPAND_SUCCESS +} from '../actions/timelines'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS +} from '../actions/statuses'; +import { SEARCH_FETCH_SUCCESS } from '../actions/search'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS +} from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS({ + followers_count: account.followers_count, + following_count: account.following_count, + statuses_count: account.statuses_count, +})); + +const normalizeAccounts = (state, accounts) => { + accounts.forEach(account => { + state = normalizeAccount(state, account); + }); + + return state; +}; + +const normalizeAccountFromStatus = (state, status) => { + state = normalizeAccount(state, status.account); + + if (status.reblog && status.reblog.account) { + state = normalizeAccount(state, status.reblog.account); + } + + return state; +}; + +const normalizeAccountsFromStatuses = (state, statuses) => { + statuses.forEach(status => { + state = normalizeAccountFromStatus(state, status); + }); + + return state; +}; + +const initialState = Immutable.Map(); + +export default function accountsCounters(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('accounts_counters')); + case ACCOUNT_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeAccount(state, action.account); + case FOLLOWERS_FETCH_SUCCESS: + case FOLLOWERS_EXPAND_SUCCESS: + case FOLLOWING_FETCH_SUCCESS: + case FOLLOWING_EXPAND_SUCCESS: + case REBLOGS_FETCH_SUCCESS: + case FAVOURITES_FETCH_SUCCESS: + case COMPOSE_SUGGESTIONS_READY: + case FOLLOW_REQUESTS_FETCH_SUCCESS: + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + case BLOCKS_FETCH_SUCCESS: + case BLOCKS_EXPAND_SUCCESS: + case MUTES_FETCH_SUCCESS: + case MUTES_EXPAND_SUCCESS: + return normalizeAccounts(state, action.accounts); + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return normalizeAccountsFromStatuses(state, action.statuses); + case REBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNREBLOG_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeAccountFromStatus(state, action.response); + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + return normalizeAccountFromStatus(state, action.status); + case ACCOUNT_FOLLOW_SUCCESS: + return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1); + case ACCOUNT_UNFOLLOW_SUCCESS: + return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1)); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js new file mode 100644 index 000000000..dc0145824 --- /dev/null +++ b/app/javascript/mastodon/reducers/alerts.js @@ -0,0 +1,25 @@ +import { + ALERT_SHOW, + ALERT_DISMISS, + ALERT_CLEAR +} from '../actions/alerts'; +import Immutable from 'immutable'; + +const initialState = Immutable.List([]); + +export default function alerts(state = initialState, action) { + switch(action.type) { + case ALERT_SHOW: + return state.push(Immutable.Map({ + key: state.size > 0 ? state.last().get('key') + 1 : 0, + title: action.title, + message: action.message + })); + case ALERT_DISMISS: + return state.filterNot(item => item.get('key') === action.alert.key); + case ALERT_CLEAR: + return state.clear(); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/cards.js b/app/javascript/mastodon/reducers/cards.js new file mode 100644 index 000000000..3c9395011 --- /dev/null +++ b/app/javascript/mastodon/reducers/cards.js @@ -0,0 +1,14 @@ +import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards'; + +import Immutable from 'immutable'; + +const initialState = Immutable.Map(); + +export default function cards(state = initialState, action) { + switch(action.type) { + case STATUS_CARD_FETCH_SUCCESS: + return state.set(action.id, Immutable.fromJS(action.card)); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js new file mode 100644 index 000000000..c87384780 --- /dev/null +++ b/app/javascript/mastodon/reducers/compose.js @@ -0,0 +1,232 @@ +import { + COMPOSE_MOUNT, + COMPOSE_UNMOUNT, + COMPOSE_CHANGE, + COMPOSE_REPLY, + COMPOSE_REPLY_CANCEL, + COMPOSE_MENTION, + COMPOSE_SUBMIT_REQUEST, + COMPOSE_SUBMIT_SUCCESS, + COMPOSE_SUBMIT_FAIL, + COMPOSE_UPLOAD_REQUEST, + COMPOSE_UPLOAD_SUCCESS, + COMPOSE_UPLOAD_FAIL, + COMPOSE_UPLOAD_UNDO, + COMPOSE_UPLOAD_PROGRESS, + COMPOSE_SUGGESTIONS_CLEAR, + COMPOSE_SUGGESTIONS_READY, + COMPOSE_SUGGESTION_SELECT, + COMPOSE_SENSITIVITY_CHANGE, + COMPOSE_SPOILERNESS_CHANGE, + COMPOSE_SPOILER_TEXT_CHANGE, + COMPOSE_VISIBILITY_CHANGE, + COMPOSE_LISTABILITY_CHANGE, + COMPOSE_EMOJI_INSERT +} from '../actions/compose'; +import { TIMELINE_DELETE } from '../actions/timelines'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; +import uuid from '../uuid'; + +const initialState = Immutable.Map({ + mounted: false, + sensitive: false, + spoiler: false, + spoiler_text: '', + privacy: null, + text: '', + focusDate: null, + preselectDate: null, + in_reply_to: null, + is_submitting: false, + is_uploading: false, + progress: 0, + media_attachments: Immutable.List(), + suggestion_token: null, + suggestions: Immutable.List(), + me: null, + default_privacy: 'public', + resetFileKey: Math.floor((Math.random() * 0x10000)), + idempotencyKey: null +}); + +function statusToTextMentions(state, status) { + let set = Immutable.OrderedSet([]); + let me = state.get('me'); + + if (status.getIn(['account', 'id']) !== me) { + set = set.add(`@${status.getIn(['account', 'acct'])} `); + } + + return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join(''); +}; + +function clearAll(state) { + return state.withMutations(map => { + map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('is_submitting', false); + map.set('in_reply_to', null); + map.set('privacy', state.get('default_privacy')); + map.set('sensitive', false); + map.update('media_attachments', list => list.clear()); + map.set('idempotencyKey', uuid()); + }); +}; + +function appendMedia(state, media) { + return state.withMutations(map => { + map.update('media_attachments', list => list.push(media)); + map.set('is_uploading', false); + map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); + map.update('text', oldText => `${oldText.trim()} ${media.get('text_url')}`); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + }); +}; + +function removeMedia(state, mediaId) { + const media = state.get('media_attachments').find(item => item.get('id') === mediaId); + const prevSize = state.get('media_attachments').size; + + return state.withMutations(map => { + map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId)); + map.update('text', text => text.replace(media.get('text_url'), '').trim()); + map.set('idempotencyKey', uuid()); + + if (prevSize === 1) { + map.set('sensitive', false); + } + }); +}; + +const insertSuggestion = (state, position, token, completion) => { + return state.withMutations(map => { + map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`); + map.set('suggestion_token', null); + map.update('suggestions', Immutable.List(), list => list.clear()); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + }); +}; + +const insertEmoji = (state, position, emojiData) => { + const emoji = emojiData.shortname; + + return state.withMutations(map => { + map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + }); +}; + +const privacyPreference = (a, b) => { + if (a === 'direct' || b === 'direct') { + return 'direct'; + } else if (a === 'private' || b === 'private') { + return 'private'; + } else if (a === 'unlisted' || b === 'unlisted') { + return 'unlisted'; + } else { + return 'public'; + } +}; + +export default function compose(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return clearAll(state.merge(action.state.get('compose'))); + case COMPOSE_MOUNT: + return state.set('mounted', true); + case COMPOSE_UNMOUNT: + return state.set('mounted', false); + case COMPOSE_SENSITIVITY_CHANGE: + return state + .set('sensitive', !state.get('sensitive')) + .set('idempotencyKey', uuid()); + case COMPOSE_SPOILERNESS_CHANGE: + return state.withMutations(map => { + map.set('spoiler_text', ''); + map.set('spoiler', !state.get('spoiler')); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SPOILER_TEXT_CHANGE: + return state + .set('spoiler_text', action.text) + .set('idempotencyKey', uuid()); + case COMPOSE_VISIBILITY_CHANGE: + return state + .set('privacy', action.value) + .set('idempotencyKey', uuid()); + case COMPOSE_CHANGE: + return state + .set('text', action.text) + .set('idempotencyKey', uuid()); + case COMPOSE_REPLY: + return state.withMutations(map => { + map.set('in_reply_to', action.status.get('id')); + map.set('text', statusToTextMentions(state, action.status)); + map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.set('focusDate', new Date()); + map.set('preselectDate', new Date()); + map.set('idempotencyKey', uuid()); + + if (action.status.get('spoiler_text').length > 0) { + map.set('spoiler', true); + map.set('spoiler_text', action.status.get('spoiler_text')); + } else { + map.set('spoiler', false); + map.set('spoiler_text', ''); + } + }); + case COMPOSE_REPLY_CANCEL: + return state.withMutations(map => { + map.set('in_reply_to', null); + map.set('text', ''); + map.set('spoiler', false); + map.set('spoiler_text', ''); + map.set('privacy', state.get('default_privacy')); + map.set('idempotencyKey', uuid()); + }); + case COMPOSE_SUBMIT_REQUEST: + return state.set('is_submitting', true); + case COMPOSE_SUBMIT_SUCCESS: + return clearAll(state); + case COMPOSE_SUBMIT_FAIL: + return state.set('is_submitting', false); + case COMPOSE_UPLOAD_REQUEST: + return state.withMutations(map => { + map.set('is_uploading', true); + }); + case COMPOSE_UPLOAD_SUCCESS: + return appendMedia(state, Immutable.fromJS(action.media)); + case COMPOSE_UPLOAD_FAIL: + return state.set('is_uploading', false); + case COMPOSE_UPLOAD_UNDO: + return removeMedia(state, action.media_id); + case COMPOSE_UPLOAD_PROGRESS: + return state.set('progress', Math.round((action.loaded / action.total) * 100)); + case COMPOSE_MENTION: + return state + .update('text', text => `${text}@${action.account.get('acct')} `) + .set('focusDate', new Date()) + .set('idempotencyKey', uuid()); + case COMPOSE_SUGGESTIONS_CLEAR: + return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null); + case COMPOSE_SUGGESTIONS_READY: + return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token); + case COMPOSE_SUGGESTION_SELECT: + return insertSuggestion(state, action.position, action.token, action.completion); + case TIMELINE_DELETE: + if (action.id === state.get('in_reply_to')) { + return state.set('in_reply_to', null); + } else { + return state; + } + case COMPOSE_EMOJI_INSERT: + return insertEmoji(state, action.position, action.emoji); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js new file mode 100644 index 000000000..f05067c47 --- /dev/null +++ b/app/javascript/mastodon/reducers/index.js @@ -0,0 +1,38 @@ +import { combineReducers } from 'redux-immutable'; +import timelines from './timelines'; +import meta from './meta'; +import compose from './compose'; +import alerts from './alerts'; +import { loadingBarReducer } from 'react-redux-loading-bar'; +import modal from './modal'; +import user_lists from './user_lists'; +import accounts from './accounts'; +import accounts_counters from './accounts_counters'; +import statuses from './statuses'; +import relationships from './relationships'; +import search from './search'; +import notifications from './notifications'; +import settings from './settings'; +import status_lists from './status_lists'; +import cards from './cards'; +import reports from './reports'; + +export default combineReducers({ + timelines, + meta, + compose, + alerts, + loadingBar: loadingBarReducer, + modal, + user_lists, + status_lists, + accounts, + accounts_counters, + statuses, + relationships, + search, + notifications, + settings, + cards, + reports +}); diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js new file mode 100644 index 000000000..acf6d4be1 --- /dev/null +++ b/app/javascript/mastodon/reducers/meta.js @@ -0,0 +1,17 @@ +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + streaming_api_base_url: null, + access_token: null, + me: null +}); + +export default function meta(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.merge(action.state.get('meta')); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js new file mode 100644 index 000000000..3566820ef --- /dev/null +++ b/app/javascript/mastodon/reducers/modal.js @@ -0,0 +1,18 @@ +import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal'; +import Immutable from 'immutable'; + +const initialState = { + modalType: null, + modalProps: {} +}; + +export default function modal(state = initialState, action) { + switch(action.type) { + case MODAL_OPEN: + return { modalType: action.modalType, modalProps: action.modalProps }; + case MODAL_CLOSE: + return initialState; + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js new file mode 100644 index 000000000..c567a3a59 --- /dev/null +++ b/app/javascript/mastodon/reducers/notifications.js @@ -0,0 +1,104 @@ +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS, + NOTIFICATIONS_REFRESH_REQUEST, + NOTIFICATIONS_EXPAND_REQUEST, + NOTIFICATIONS_REFRESH_FAIL, + NOTIFICATIONS_EXPAND_FAIL, + NOTIFICATIONS_CLEAR, + NOTIFICATIONS_SCROLL_TOP +} from '../actions/notifications'; +import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + items: Immutable.List(), + next: null, + top: true, + unread: 0, + loaded: false, + isLoading: true +}); + +const notificationToMap = notification => Immutable.Map({ + id: notification.id, + type: notification.type, + account: notification.account.id, + status: notification.status ? notification.status.id : null +}); + +const normalizeNotification = (state, notification) => { + if (!state.get('top')) { + state = state.update('unread', unread => unread + 1); + } + + return state.update('items', list => list.unshift(notificationToMap(notification))); +}; + +const normalizeNotifications = (state, notifications, next) => { + let items = Immutable.List(); + const loaded = state.get('loaded'); + + notifications.forEach((n, i) => { + items = items.set(i, notificationToMap(n)); + }); + + if (state.get('next') === null) { + state = state.set('next', next); + } + + return state + .update('items', list => loaded ? items.concat(list) : list.concat(items)) + .set('loaded', true) + .set('isLoading', false); +}; + +const appendNormalizedNotifications = (state, notifications, next) => { + let items = Immutable.List(); + + notifications.forEach((n, i) => { + items = items.set(i, notificationToMap(n)); + }); + + return state + .update('items', list => list.concat(items)) + .set('next', next) + .set('isLoading', false); +}; + +const filterNotifications = (state, relationship) => { + return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id)); +}; + +const updateTop = (state, top) => { + if (top) { + state = state.set('unread', 0); + } + + return state.set('top', top); +}; + +export default function notifications(state = initialState, action) { + switch(action.type) { + case NOTIFICATIONS_REFRESH_REQUEST: + case NOTIFICATIONS_EXPAND_REQUEST: + case NOTIFICATIONS_REFRESH_FAIL: + case NOTIFICATIONS_EXPAND_FAIL: + return state.set('isLoading', true); + case NOTIFICATIONS_SCROLL_TOP: + return updateTop(state, action.top); + case NOTIFICATIONS_UPDATE: + return normalizeNotification(state, action.notification); + case NOTIFICATIONS_REFRESH_SUCCESS: + return normalizeNotifications(state, action.notifications, action.next); + case NOTIFICATIONS_EXPAND_SUCCESS: + return appendNormalizedNotifications(state, action.notifications, action.next); + case ACCOUNT_BLOCK_SUCCESS: + return filterNotifications(state, action.relationship); + case NOTIFICATIONS_CLEAR: + return state.set('items', Immutable.List()).set('next', null); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js new file mode 100644 index 000000000..c65c48b43 --- /dev/null +++ b/app/javascript/mastodon/reducers/relationships.js @@ -0,0 +1,38 @@ +import { + ACCOUNT_FOLLOW_SUCCESS, + ACCOUNT_UNFOLLOW_SUCCESS, + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_UNBLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, + ACCOUNT_UNMUTE_SUCCESS, + RELATIONSHIPS_FETCH_SUCCESS +} from '../actions/accounts'; +import Immutable from 'immutable'; + +const normalizeRelationship = (state, relationship) => state.set(relationship.id, Immutable.fromJS(relationship)); + +const normalizeRelationships = (state, relationships) => { + relationships.forEach(relationship => { + state = normalizeRelationship(state, relationship); + }); + + return state; +}; + +const initialState = Immutable.Map(); + +export default function relationships(state = initialState, action) { + switch(action.type) { + case ACCOUNT_FOLLOW_SUCCESS: + case ACCOUNT_UNFOLLOW_SUCCESS: + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_UNBLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + case ACCOUNT_UNMUTE_SUCCESS: + return normalizeRelationship(state, action.relationship); + case RELATIONSHIPS_FETCH_SUCCESS: + return normalizeRelationships(state, action.relationships); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/reports.js b/app/javascript/mastodon/reducers/reports.js new file mode 100644 index 000000000..eab004377 --- /dev/null +++ b/app/javascript/mastodon/reducers/reports.js @@ -0,0 +1,60 @@ +import { + REPORT_INIT, + REPORT_SUBMIT_REQUEST, + REPORT_SUBMIT_SUCCESS, + REPORT_SUBMIT_FAIL, + REPORT_CANCEL, + REPORT_STATUS_TOGGLE, + REPORT_COMMENT_CHANGE +} from '../actions/reports'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + new: Immutable.Map({ + isSubmitting: false, + account_id: null, + status_ids: Immutable.Set(), + comment: '' + }) +}); + +export default function reports(state = initialState, action) { + switch(action.type) { + case REPORT_INIT: + return state.withMutations(map => { + map.setIn(['new', 'isSubmitting'], false); + map.setIn(['new', 'account_id'], action.account.get('id')); + + if (state.getIn(['new', 'account_id']) !== action.account.get('id')) { + map.setIn(['new', 'status_ids'], action.status ? Immutable.Set([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : Immutable.Set()); + map.setIn(['new', 'comment'], ''); + } else { + map.updateIn(['new', 'status_ids'], Immutable.Set(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id')))); + } + }); + case REPORT_STATUS_TOGGLE: + return state.updateIn(['new', 'status_ids'], Immutable.Set(), set => { + if (action.checked) { + return set.add(action.statusId); + } + + return set.remove(action.statusId); + }); + case REPORT_COMMENT_CHANGE: + return state.setIn(['new', 'comment'], action.comment); + case REPORT_SUBMIT_REQUEST: + return state.setIn(['new', 'isSubmitting'], true); + case REPORT_SUBMIT_FAIL: + return state.setIn(['new', 'isSubmitting'], false); + case REPORT_CANCEL: + case REPORT_SUBMIT_SUCCESS: + return state.withMutations(map => { + map.setIn(['new', 'account_id'], null); + map.setIn(['new', 'status_ids'], Immutable.Set()); + map.setIn(['new', 'comment'], ''); + map.setIn(['new', 'isSubmitting'], false); + }); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js new file mode 100644 index 000000000..b3fe6c7be --- /dev/null +++ b/app/javascript/mastodon/reducers/search.js @@ -0,0 +1,96 @@ +import { + SEARCH_CHANGE, + SEARCH_CLEAR, + SEARCH_FETCH_SUCCESS, + SEARCH_SHOW +} from '../actions/search'; +import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + value: '', + submitted: false, + hidden: false, + results: Immutable.Map() +}); + +const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => { + let newSuggestions = []; + + if (accounts.length > 0) { + newSuggestions.push({ + title: 'account', + items: accounts.map(item => ({ + type: 'account', + id: item.id, + value: item.acct + })) + }); + } + + if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 || hashtags.length > 0) { + let hashtagItems = hashtags.map(item => ({ + type: 'hashtag', + id: item, + value: `#${item}` + })); + + if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 && !value.startsWith('http://') && !value.startsWith('https://') && hashtags.indexOf(value) === -1) { + hashtagItems.unshift({ + type: 'hashtag', + id: value, + value: `#${value}` + }); + } + + if (hashtagItems.length > 0) { + newSuggestions.push({ + title: 'hashtag', + items: hashtagItems + }); + } + } + + if (statuses.length > 0) { + newSuggestions.push({ + title: 'status', + items: statuses.map(item => ({ + type: 'status', + id: item.id, + value: item.id + })) + }); + } + + return state.withMutations(map => { + map.set('suggestions', newSuggestions); + map.set('loaded_value', value); + }); +}; + +export default function search(state = initialState, action) { + switch(action.type) { + case SEARCH_CHANGE: + return state.set('value', action.value); + case SEARCH_CLEAR: + return state.withMutations(map => { + map.set('value', ''); + map.set('results', Immutable.Map()); + map.set('submitted', false); + map.set('hidden', false); + }); + case SEARCH_SHOW: + return state.set('hidden', false); + case COMPOSE_REPLY: + case COMPOSE_MENTION: + return state.set('hidden', true); + case SEARCH_FETCH_SUCCESS: + return state.set('results', Immutable.Map({ + accounts: Immutable.List(action.results.accounts.map(item => item.id)), + statuses: Immutable.List(action.results.statuses.map(item => item.id)), + hashtags: Immutable.List(action.results.hashtags) + })).set('submitted', true); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js new file mode 100644 index 000000000..b255aabc4 --- /dev/null +++ b/app/javascript/mastodon/reducers/settings.js @@ -0,0 +1,52 @@ +import { SETTING_CHANGE } from '../actions/settings'; +import { STORE_HYDRATE } from '../actions/store'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + onboarded: false, + + home: Immutable.Map({ + shows: Immutable.Map({ + reblog: true, + reply: true + }), + + regex: Immutable.Map({ + body: '' + }) + }), + + notifications: Immutable.Map({ + alerts: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }), + + shows: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }), + + sounds: Immutable.Map({ + follow: true, + favourite: true, + reblog: true, + mention: true + }) + }) +}); + +export default function settings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return state.mergeDeep(action.state.get('settings')); + case SETTING_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js new file mode 100644 index 000000000..fd463cd63 --- /dev/null +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -0,0 +1,39 @@ +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + favourites: Immutable.Map({ + next: null, + loaded: false, + items: Immutable.List() + }) +}); + +const normalizeList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('loaded', true); + map.set('items', Immutable.List(statuses.map(item => item.id))); + })); +}; + +const appendToList = (state, listType, statuses, next) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('next', next); + map.set('items', map.get('items').concat(statuses.map(item => item.id))); + })); +}; + +export default function statusLists(state = initialState, action) { + switch(action.type) { + case FAVOURITED_STATUSES_FETCH_SUCCESS: + return normalizeList(state, 'favourites', action.statuses, action.next); + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + return appendToList(state, 'favourites', action.statuses, action.next); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js new file mode 100644 index 000000000..2002d2223 --- /dev/null +++ b/app/javascript/mastodon/reducers/statuses.js @@ -0,0 +1,124 @@ +import { + REBLOG_REQUEST, + REBLOG_SUCCESS, + REBLOG_FAIL, + UNREBLOG_SUCCESS, + FAVOURITE_REQUEST, + FAVOURITE_SUCCESS, + FAVOURITE_FAIL, + UNFAVOURITE_SUCCESS +} from '../actions/interactions'; +import { + STATUS_FETCH_SUCCESS, + CONTEXT_FETCH_SUCCESS +} from '../actions/statuses'; +import { + TIMELINE_REFRESH_SUCCESS, + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_EXPAND_SUCCESS +} from '../actions/timelines'; +import { + ACCOUNT_TIMELINE_FETCH_SUCCESS, + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + ACCOUNT_BLOCK_SUCCESS +} from '../actions/accounts'; +import { + NOTIFICATIONS_UPDATE, + NOTIFICATIONS_REFRESH_SUCCESS, + NOTIFICATIONS_EXPAND_SUCCESS +} from '../actions/notifications'; +import { + FAVOURITED_STATUSES_FETCH_SUCCESS, + FAVOURITED_STATUSES_EXPAND_SUCCESS +} from '../actions/favourites'; +import { SEARCH_FETCH_SUCCESS } from '../actions/search'; +import Immutable from 'immutable'; + +const normalizeStatus = (state, status) => { + if (!status) { + return state; + } + + const normalStatus = { ...status }; + normalStatus.account = status.account.id; + + if (status.reblog && status.reblog.id) { + state = normalizeStatus(state, status.reblog); + normalStatus.reblog = status.reblog.id; + } + + const linebreakComplemented = status.content.replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + normalStatus.unescaped_content = new DOMParser().parseFromString(linebreakComplemented, 'text/html').documentElement.textContent; + + return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(normalStatus))); +}; + +const normalizeStatuses = (state, statuses) => { + statuses.forEach(status => { + state = normalizeStatus(state, status); + }); + + return state; +}; + +const deleteStatus = (state, id, references) => { + references.forEach(ref => { + state = deleteStatus(state, ref[0], []); + }); + + return state.delete(id); +}; + +const filterStatuses = (state, relationship) => { + state.forEach(status => { + if (status.get('account') !== relationship.id) { + return; + } + + state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id'))); + }); + + return state; +}; + +const initialState = Immutable.Map(); + +export default function statuses(state = initialState, action) { + switch(action.type) { + case TIMELINE_UPDATE: + case STATUS_FETCH_SUCCESS: + case NOTIFICATIONS_UPDATE: + return normalizeStatus(state, action.status); + case REBLOG_SUCCESS: + case UNREBLOG_SUCCESS: + case FAVOURITE_SUCCESS: + case UNFAVOURITE_SUCCESS: + return normalizeStatus(state, action.response); + case FAVOURITE_REQUEST: + return state.setIn([action.status.get('id'), 'favourited'], true); + case FAVOURITE_FAIL: + return state.setIn([action.status.get('id'), 'favourited'], false); + case REBLOG_REQUEST: + return state.setIn([action.status.get('id'), 'reblogged'], true); + case REBLOG_FAIL: + return state.setIn([action.status.get('id'), 'reblogged'], false); + case TIMELINE_REFRESH_SUCCESS: + case TIMELINE_EXPAND_SUCCESS: + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + case CONTEXT_FETCH_SUCCESS: + case NOTIFICATIONS_REFRESH_SUCCESS: + case NOTIFICATIONS_EXPAND_SUCCESS: + case FAVOURITED_STATUSES_FETCH_SUCCESS: + case FAVOURITED_STATUSES_EXPAND_SUCCESS: + case SEARCH_FETCH_SUCCESS: + return normalizeStatuses(state, action.statuses); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.references); + case ACCOUNT_BLOCK_SUCCESS: + return filterStatuses(state, action.relationship); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js new file mode 100644 index 000000000..31e79f9f6 --- /dev/null +++ b/app/javascript/mastodon/reducers/timelines.js @@ -0,0 +1,317 @@ +import { + TIMELINE_REFRESH_REQUEST, + TIMELINE_REFRESH_SUCCESS, + TIMELINE_REFRESH_FAIL, + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_FAIL, + TIMELINE_SCROLL_TOP, + TIMELINE_CONNECT, + TIMELINE_DISCONNECT +} from '../actions/timelines'; +import { + REBLOG_SUCCESS, + UNREBLOG_SUCCESS, + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS +} from '../actions/interactions'; +import { + ACCOUNT_TIMELINE_FETCH_REQUEST, + ACCOUNT_TIMELINE_FETCH_SUCCESS, + ACCOUNT_TIMELINE_FETCH_FAIL, + ACCOUNT_TIMELINE_EXPAND_REQUEST, + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + ACCOUNT_TIMELINE_EXPAND_FAIL, + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS +} from '../actions/accounts'; +import { + CONTEXT_FETCH_SUCCESS +} from '../actions/statuses'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + home: Immutable.Map({ + path: () => '/api/v1/timelines/home', + next: null, + isLoading: false, + online: false, + loaded: false, + top: true, + unread: 0, + items: Immutable.List() + }), + + public: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, + isLoading: false, + online: false, + loaded: false, + top: true, + unread: 0, + items: Immutable.List() + }), + + community: Immutable.Map({ + path: () => '/api/v1/timelines/public', + next: null, + params: { local: true }, + isLoading: false, + online: false, + loaded: false, + top: true, + unread: 0, + items: Immutable.List() + }), + + tag: Immutable.Map({ + path: (id) => `/api/v1/timelines/tag/${id}`, + next: null, + isLoading: false, + id: null, + loaded: false, + top: true, + unread: 0, + items: Immutable.List() + }), + + accounts_timelines: Immutable.Map(), + ancestors: Immutable.Map(), + descendants: Immutable.Map() +}); + +const normalizeStatus = (state, status) => { + const replyToId = status.get('in_reply_to_id'); + const id = status.get('id'); + + if (replyToId) { + if (!state.getIn(['descendants', replyToId], Immutable.List()).includes(id)) { + state = state.updateIn(['descendants', replyToId], Immutable.List(), set => set.push(id)); + } + + if (!state.getIn(['ancestors', id], Immutable.List()).includes(replyToId)) { + state = state.updateIn(['ancestors', id], Immutable.List(), set => set.push(replyToId)); + } + } + + return state; +}; + +const normalizeTimeline = (state, timeline, statuses, next) => { + let ids = Immutable.List(); + const loaded = state.getIn([timeline, 'loaded']); + + statuses.forEach((status, i) => { + state = normalizeStatus(state, status); + ids = ids.set(i, status.get('id')); + }); + + state = state.setIn([timeline, 'loaded'], true); + state = state.setIn([timeline, 'isLoading'], false); + + if (state.getIn([timeline, 'next']) === null) { + state = state.setIn([timeline, 'next'], next); + } + + return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? ids.concat(list) : ids)); +}; + +const appendNormalizedTimeline = (state, timeline, statuses, next) => { + let moreIds = Immutable.List(); + + statuses.forEach((status, i) => { + state = normalizeStatus(state, status); + moreIds = moreIds.set(i, status.get('id')); + }); + + state = state.setIn([timeline, 'isLoading'], false); + state = state.setIn([timeline, 'next'], next); + + return state.updateIn([timeline, 'items'], Immutable.List(), list => list.concat(moreIds)); +}; + +const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => { + let ids = Immutable.List(); + + statuses.forEach((status, i) => { + state = normalizeStatus(state, status); + ids = ids.set(i, status.get('id')); + }); + + return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map + .set('isLoading', false) + .set('loaded', true) + .set('next', true) + .update('items', Immutable.List(), list => (replace ? ids : ids.concat(list)))); +}; + +const appendNormalizedAccountTimeline = (state, accountId, statuses, next) => { + let moreIds = Immutable.List([]); + + statuses.forEach((status, i) => { + state = normalizeStatus(state, status); + moreIds = moreIds.set(i, status.get('id')); + }); + + return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map + .set('isLoading', false) + .set('next', next) + .update('items', list => list.concat(moreIds))); +}; + +const updateTimeline = (state, timeline, status, references) => { + const top = state.getIn([timeline, 'top']); + + state = normalizeStatus(state, status); + + if (!top) { + state = state.updateIn([timeline, 'unread'], unread => unread + 1); + } + + state = state.updateIn([timeline, 'items'], Immutable.List(), list => { + if (top && list.size > 40) { + list = list.take(20); + } + + if (list.includes(status.get('id'))) { + return list; + } + + const reblogOfId = status.getIn(['reblog', 'id'], null); + + if (reblogOfId !== null) { + list = list.filterNot(itemId => references.includes(itemId)); + } + + return list.unshift(status.get('id')); + }); + + return state; +}; + +const deleteStatus = (state, id, accountId, references, reblogOf) => { + if (reblogOf) { + // If we are deleting a reblog, just replace reblog with its original + return state.updateIn(['home', 'items'], list => list.map(item => item === id ? reblogOf : item)); + } + + // Remove references from timelines + ['home', 'public', 'community', 'tag'].forEach(function (timeline) { + state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); + }); + + // Remove references from account timelines + state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id)); + + // Remove references from context + state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => { + state = state.updateIn(['ancestors', descendantId], Immutable.List(), list => list.filterNot(itemId => itemId === id)); + }); + + state.getIn(['ancestors', id], Immutable.List()).forEach(ancestorId => { + state = state.updateIn(['descendants', ancestorId], Immutable.List(), list => list.filterNot(itemId => itemId === id)); + }); + + state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]); + + // Remove reblogs of deleted status + references.forEach(ref => { + state = deleteStatus(state, ref[0], ref[1], []); + }); + + return state; +}; + +const filterTimelines = (state, relationship, statuses) => { + let references; + + statuses.forEach(status => { + if (status.get('account') !== relationship.id) { + return; + } + + references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]); + state = deleteStatus(state, status.get('id'), status.get('account'), references); + }); + + return state; +}; + +const normalizeContext = (state, id, ancestors, descendants) => { + const ancestorsIds = ancestors.map(ancestor => ancestor.get('id')); + const descendantsIds = descendants.map(descendant => descendant.get('id')); + + return state.withMutations(map => { + map.setIn(['ancestors', id], ancestorsIds); + map.setIn(['descendants', id], descendantsIds); + }); +}; + +const resetTimeline = (state, timeline, id) => { + if (timeline === 'tag' && typeof id !== 'undefined' && state.getIn([timeline, 'id']) !== id) { + state = state.update(timeline, map => map + .set('id', id) + .set('isLoading', true) + .set('loaded', false) + .set('next', null) + .set('top', true) + .update('items', list => list.clear())); + } else { + state = state.setIn([timeline, 'isLoading'], true); + } + + return state; +}; + +const updateTop = (state, timeline, top) => { + if (top) { + state = state.setIn([timeline, 'unread'], 0); + } + + return state.setIn([timeline, 'top'], top); +}; + +export default function timelines(state = initialState, action) { + switch(action.type) { + case TIMELINE_REFRESH_REQUEST: + case TIMELINE_EXPAND_REQUEST: + return resetTimeline(state, action.timeline, action.id); + case TIMELINE_REFRESH_FAIL: + case TIMELINE_EXPAND_FAIL: + return state.setIn([action.timeline, 'isLoading'], false); + case TIMELINE_REFRESH_SUCCESS: + return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next); + case TIMELINE_EXPAND_SUCCESS: + return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next); + case TIMELINE_UPDATE: + return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references); + case TIMELINE_DELETE: + return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); + case CONTEXT_FETCH_SUCCESS: + return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants)); + case ACCOUNT_TIMELINE_FETCH_REQUEST: + case ACCOUNT_TIMELINE_EXPAND_REQUEST: + return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true)); + case ACCOUNT_TIMELINE_FETCH_FAIL: + case ACCOUNT_TIMELINE_EXPAND_FAIL: + return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false)); + case ACCOUNT_TIMELINE_FETCH_SUCCESS: + return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace); + case ACCOUNT_TIMELINE_EXPAND_SUCCESS: + return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.next); + case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: + return filterTimelines(state, action.relationship, action.statuses); + case TIMELINE_SCROLL_TOP: + return updateTop(state, action.timeline, action.top); + case TIMELINE_CONNECT: + return state.setIn([action.timeline, 'online'], true); + case TIMELINE_DISCONNECT: + return state.setIn([action.timeline, 'online'], false); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js new file mode 100644 index 000000000..af9492119 --- /dev/null +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -0,0 +1,80 @@ +import { + FOLLOWERS_FETCH_SUCCESS, + FOLLOWERS_EXPAND_SUCCESS, + FOLLOWING_FETCH_SUCCESS, + FOLLOWING_EXPAND_SUCCESS, + FOLLOW_REQUESTS_FETCH_SUCCESS, + FOLLOW_REQUESTS_EXPAND_SUCCESS, + FOLLOW_REQUEST_AUTHORIZE_SUCCESS, + FOLLOW_REQUEST_REJECT_SUCCESS +} from '../actions/accounts'; +import { + REBLOGS_FETCH_SUCCESS, + FAVOURITES_FETCH_SUCCESS +} from '../actions/interactions'; +import { + BLOCKS_FETCH_SUCCESS, + BLOCKS_EXPAND_SUCCESS +} from '../actions/blocks'; +import { + MUTES_FETCH_SUCCESS, + MUTES_EXPAND_SUCCESS +} from '../actions/mutes'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + followers: Immutable.Map(), + following: Immutable.Map(), + reblogged_by: Immutable.Map(), + favourited_by: Immutable.Map(), + follow_requests: Immutable.Map(), + blocks: Immutable.Map(), + mutes: Immutable.Map() +}); + +const normalizeList = (state, type, id, accounts, next) => { + return state.setIn([type, id], Immutable.Map({ + next, + items: Immutable.List(accounts.map(item => item.id)) + })); +}; + +const appendToList = (state, type, id, accounts, next) => { + return state.updateIn([type, id], map => { + return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id))); + }); +}; + +export default function userLists(state = initialState, action) { + switch(action.type) { + case FOLLOWERS_FETCH_SUCCESS: + return normalizeList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWERS_EXPAND_SUCCESS: + return appendToList(state, 'followers', action.id, action.accounts, action.next); + case FOLLOWING_FETCH_SUCCESS: + return normalizeList(state, 'following', action.id, action.accounts, action.next); + case FOLLOWING_EXPAND_SUCCESS: + return appendToList(state, 'following', action.id, action.accounts, action.next); + case REBLOGS_FETCH_SUCCESS: + return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FAVOURITES_FETCH_SUCCESS: + return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FOLLOW_REQUESTS_FETCH_SUCCESS: + return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUESTS_EXPAND_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next); + case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: + case FOLLOW_REQUEST_REJECT_SUCCESS: + return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id)); + case BLOCKS_FETCH_SUCCESS: + return state.setIn(['blocks', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); + case BLOCKS_EXPAND_SUCCESS: + return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next); + case MUTES_FETCH_SUCCESS: + return state.setIn(['mutes', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + case MUTES_EXPAND_SUCCESS: + return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/rtl.js b/app/javascript/mastodon/rtl.js new file mode 100644 index 000000000..8f14bb338 --- /dev/null +++ b/app/javascript/mastodon/rtl.js @@ -0,0 +1,27 @@ +// U+0590 to U+05FF - Hebrew +// U+0600 to U+06FF - Arabic +// U+0700 to U+074F - Syriac +// U+0750 to U+077F - Arabic Supplement +// U+0780 to U+07BF - Thaana +// U+07C0 to U+07FF - N'Ko +// U+0800 to U+083F - Samaritan +// U+08A0 to U+08FF - Arabic Extended-A +// U+FB1D to U+FB4F - Hebrew presentation forms +// U+FB50 to U+FDFF - Arabic presentation forms A +// U+FE70 to U+FEFF - Arabic presentation forms B + +const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg; + +export function isRtl(text) { + if (text.length === 0) { + return false; + } + + const matches = text.match(rtlChars); + + if (!matches) { + return false; + } + + return matches.length / text.trim().length > 0.3; +}; diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js new file mode 100644 index 000000000..7a75e2660 --- /dev/null +++ b/app/javascript/mastodon/selectors/index.js @@ -0,0 +1,73 @@ +import { createSelector } from 'reselect'; +import Immutable from 'immutable'; + +const getStatuses = state => state.get('statuses'); +const getAccounts = state => state.get('accounts'); + +const getAccountBase = (state, id) => state.getIn(['accounts', id], null); +const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null); +const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null); + +export const makeGetAccount = () => { + return createSelector([getAccountBase, getAccountCounters, getAccountRelationship], (base, counters, relationship) => { + if (base === null) { + return null; + } + + return base.merge(counters).set('relationship', relationship); + }); +}; + +export const makeGetStatus = () => { + return createSelector( + [ + (state, id) => state.getIn(['statuses', id]), + (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), + (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), + (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), + ], + + (statusBase, statusReblog, accountBase, accountReblog) => { + if (!statusBase) { + return null; + } + + if (statusReblog) { + statusReblog = statusReblog.set('account', accountReblog); + } else { + statusReblog = null; + } + + return statusBase.withMutations(map => { + map.set('reblog', statusReblog); + map.set('account', accountBase); + }); + } + ); +}; + +const getAlertsBase = state => state.get('alerts'); + +export const getAlerts = createSelector([getAlertsBase], (base) => { + let arr = []; + + base.forEach(item => { + arr.push({ + message: item.get('message'), + title: item.get('title'), + key: item.get('key'), + dismissAfter: 5000 + }); + }); + + return arr; +}); + +export const makeGetNotification = () => { + return createSelector([ + (_, base) => base, + (state, _, accountId) => state.getIn(['accounts', accountId]) + ], (base, account) => { + return base.set('account', account); + }); +}; diff --git a/app/javascript/mastodon/store/configureStore.js b/app/javascript/mastodon/store/configureStore.js new file mode 100644 index 000000000..a92d756f5 --- /dev/null +++ b/app/javascript/mastodon/store/configureStore.js @@ -0,0 +1,16 @@ +import { createStore, applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import appReducer from '../reducers'; +import loadingBarMiddleware from '../middleware/loading_bar'; +import errorsMiddleware from '../middleware/errors'; +import soundsMiddleware from '../middleware/sounds'; +import Immutable from 'immutable'; + +export default function configureStore() { + return createStore(appReducer, compose(applyMiddleware( + thunk, + loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), + errorsMiddleware(), + soundsMiddleware() + ), window.devToolsExtension ? window.devToolsExtension() : f => f)); +}; diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js new file mode 100644 index 000000000..08da71607 --- /dev/null +++ b/app/javascript/mastodon/stream.js @@ -0,0 +1,22 @@ +import WebSocketClient from 'websocket.js'; + +const createWebSocketURL = (url) => { + const a = document.createElement('a'); + + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace('http', 'ws'); + + return a.href; +}; + +export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) { + const ws = new WebSocketClient(`${createWebSocketURL(streamingAPIBaseURL)}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`); + + ws.onopen = connected; + ws.onmessage = e => received(JSON.parse(e.data)); + ws.onclose = disconnected; + ws.onreconnect = reconnected; + + return ws; +}; diff --git a/app/javascript/mastodon/uuid.js b/app/javascript/mastodon/uuid.js new file mode 100644 index 000000000..be1899305 --- /dev/null +++ b/app/javascript/mastodon/uuid.js @@ -0,0 +1,3 @@ +export default function uuid(a) { + return a ? (a^Math.random() * 16 >> a / 4).toString(16) : ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid); +}; |